rpgsheet

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit e88d351065a7c5516bb6c032bcb1e495a1196c1e
parent a7e47e17535ce4dc298c5003ebb0d56d68dc676c
Author: Skylar Hill <stellarskylark@posteo.net>
Date:   Sun,  5 Jun 2022 09:12:15 -0500

Big code refactor and style update

Diffstat:
Mrpgsheet.nimble | 2+-
Msrc/tui.nim | 524++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Msrc/utils.nim | 4++--
Msrc/verb.nim | 20++++++++++----------
4 files changed, 308 insertions(+), 242 deletions(-)

diff --git a/rpgsheet.nimble b/rpgsheet.nimble @@ -1,6 +1,6 @@ # Package -version = "0.2.4" +version = "0.2.5" author = "Skylar Hill" description = "CLI/TUI application for TTRPG character sheets" license = "GPL-3.0-only" diff --git a/src/tui.nim b/src/tui.nim @@ -1,21 +1,82 @@ import - algorithm, illwill, - os, + std/algorithm, + std/os, + std/strutils, + std/tables, std/wordwrap, strformat, - strutils, - tables, yaml import utils, verb + type Mode = enum Normal, Command, Help + State = ref StateObj + + StateObj = object + commandText: string + descScroll: int + itemsByWindow: Table[string, seq[string]] + mode: Mode + scrollByWindow: Table[string, int] + selWinIndex: int + selectedByWindow: Table[string, int] + sheet: YamlDocument + sleepTime: int + statusText: string + tabIndex: int + tabName: string + tabWindows: seq[YamlNode] + + +proc newState(sheet: YamlDocument): State = + result = State( + commandText : "", + descScroll : 0, + itemsByWindow : initTable[string, seq[string]](), + mode : Normal, + scrollByWindow : initTable[string, int](), + selWinIndex : 0, + selectedByWindow : initTable[string, int](), + sheet: sheet, + sleepTime : 20, + statusText : "", + tabIndex: 0 + ) + + +proc selectedWindow(state: State): string = + state.tabWindows[state.selWinIndex].content + + +proc selectedItemList(state: State): seq[string] = + let win = state.selectedWindow + result = state.itemsByWindow[win] + + +proc selectedItem(state: State): string = + let win = state.selectedWindow + let items = state.selectedItemList + if items.len > 0: + result = items[state.selectedByWindow[win]] + else: + result = "" + + +proc selectedItemIndex(state: State): int = + state.selectedByWindow[state.selectedWindow] + + +proc `selectedItemIndex=`(state: State, index: int): void = + state.selectedByWindow[state.selectedWindow] = index + + proc toAscii(key: Key): string = result = case key @@ -91,6 +152,7 @@ proc toAscii(key: Key): string = of Key.Dot: "." else: "" + proc drawHelp(): void = var tb = newTerminalBuffer(terminalWidth(), terminalHeight()) var bb = newBoxBuffer(tb.width, tb.height) @@ -119,6 +181,7 @@ proc drawHelp(): void = tb.display() sleep(20) + proc placeWindows(tb: TerminalBuffer, windows: seq[YamlNode], descY: int): Table[string, (int, int, int, int)] = result = initTable[string, (int, int, int, int)]() let windowWidth = @@ -132,12 +195,15 @@ proc placeWindows(tb: TerminalBuffer, windows: seq[YamlNode], descY: int): Table result[win.content] = (currentX, 3, currentX + windowWidth - 1, descY - 1) currentX += windowWidth + proc exitProc(message = "") {.noconv.} = illwillDeinit() showCursor() quit(message, 0) -proc command(cmdline: string, sheet: YamlDocument, file: string): string = + +proc command(state: State, file: string): string = + let cmdline = state.commandText if cmdline == "": return "" let tokens = cmdline.split(" ") @@ -145,290 +211,290 @@ proc command(cmdline: string, sheet: YamlDocument, file: string): string = case cmd of "r", "roll": let action = tokens[1..tokens.high].join(" ").deFancy - result = action.fancyDisplay(-1) & ": " & action.doVerb(Roll, sheet) + result = action.fancyDisplay(-1) & ": " & action.doVerb(Roll, state.sheet) of "w", "write": if tokens.len == 2: - sheet.write(tokens[1]) + state.sheet.write(tokens[1]) else: - sheet.write(file) + state.sheet.write(file) of "q", "quit": exitProc() else: result = "command not recognized" -proc runTui*(sheet: YamlDocument, file: string): void = - let tabs = sheet.root["tabs"] - var tabIndex = 0 - - illwillInit(fullscreen=true) - hideCursor() - var selWinIndex = 0 - var selectedByWindow = initTable[string, int]() - var itemsByWindow = initTable[string, seq[string]]() - var scrollByWindow = initTable[string, int]() - var statusText = "" - var commandText = "" - var descScroll = 0 - var mode = Normal - var sleepTime = 20 - - # Initialize tabs, windows, and expressions +proc initializeState(state: State): void = + let tabs = state.sheet.root["tabs"] for tab in tabs: for name, windows in tab.fields.pairs: for win in windows.elems: - itemsByWindow[win.content] = newSeqOfCap[string](100) - selectedByWindow[win.content] = 0 - scrollByWindow[win.content] = 0 + state.itemsByWindow[win.content] = newSeqOfCap[string](100) + state.selectedByWindow[win.content] = 0 + state.scrollByWindow[win.content] = 0 - for name, content in sheet.root["expressions"].fields.pairs: - let win = content.getContent("window") + for name, content in state.sheet.root["expressions"].fields.pairs: + let win = content.field("window") if win == "": exitProc(fmt"Error reading {name.content}: no window specified.") var working: seq[string] try: - working = itemsByWindow[win] + working = state.itemsByWindow[win] except KeyError: exitProc(fmt"Error reading {name.content}: window '{win}' does not exist.") working.add(name.content) - itemsByWindow[win] = working + state.itemsByWindow[win] = working - for win in itemsByWindow.keys: - itemsbyWindow[win] = itemsByWindow[win].sorted + for win in state.itemsByWindow.keys: + state.itemsbyWindow[win] = state.itemsByWindow[win].sorted - while true: - var tabName: string - var tabWindows: seq[YamlNode] - # There should only be one item, but because - # fields is a Table, it's necessary to use an iterator - # to get the name and content - for n, w in tabs[tabIndex].fields.pairs: - tabName = n.content - tabWindows = w.elems - let win = tabWindows[selWinIndex].content - - var key = getKey() - case mode + for n, w in tabs[state.tabIndex].fields.pairs: + state.tabName = n.content + state.tabWindows = w.elems + + +proc addToItem(state: State, amt: int): void = + let sel = state.selectedItem + let node = state.sheet.expression(sel) + try: + let val = node.field("modifier").parseInt + for key, content in node.fields.pairs: + if key.content == "modifier": + node[key] = newYamlNode($(val + amt)) + except ValueError: + state.statusText = sel & " is not an integer value." + + +# Returns 1 if the render loop should immediately restart, +# 0 if it should continue normally. +proc handleInput(state: State, file: string): int = + let tabs = state.sheet.root["tabs"] + let key = getKey() + + case state.mode of Help: drawHelp() if key in [Key.Q, Key.Escape]: - mode = Normal - continue + state.mode = Normal + return 1 + of Command: case key of Key.Escape: - mode = Normal - statusText = "" + state.mode = Normal + state.statusText = "" of Key.Enter: - mode = Normal - statusText = command(commandText, sheet, file) - sleepTime = 20 # better performance + state.mode = Normal + state.statusText = command(state, file) + state.sleepTime = 20 # better performance of Key.Backspace: - if commandText.len > 0: - commandText = commandText[0..^2] + if state.commandText.len > 0: + state.commandText = state.commandText[0..^2] of Key.CtrlU: - commandText = "" + state.commandText = "" of Key.Tab: - let words = commandText.split(" ") + let words = state.commandText.split(" ") let last = words[words.high] var matches = newSeqOfCap[string](99) - for key in sheet.root["expressions"].fields.keys: + for key in state.sheet.root["expressions"].fields.keys: if key.content.startsWith(last): matches.add(key.content) if matches.len == 1: - commandText = commandText.replace(last, matches[0]) + state.commandText = state.commandText.replace(last, matches[0]) else: - commandText &= key.toAscii() - statusText = ":" & commandText - else: + state.commandText &= key.toAscii() + state.statusText = ":" & state.commandText + + of Normal: case key of Key.Q, Key.Escape: exitProc() + of Key.ShiftJ, Key.Tab: - tabIndex = tabIndex.loopInc(tabs.elems) - selWinIndex = 0 + state.tabIndex = state.tabIndex.loopInc(tabs.elems) + state.selWinIndex = 0 + # There should only be one item, but because + # fields is a Table, it's necessary to use an iterator + # to get the name and content + for n, w in tabs[state.tabIndex].fields.pairs: + state.tabName = n.content + state.tabWindows = w.elems of Key.ShiftK: - tabIndex = tabIndex.loopDec(tabs.elems) - selWinIndex = 0 + state.tabIndex = state.tabIndex.loopDec(tabs.elems) + state.selWinIndex = 0 + for n, w in tabs[state.tabIndex].fields.pairs: + state.tabName = n.content + state.tabWindows = w.elems + of Key.L, Key.Right: - selWinIndex = selWinIndex.loopInc(tabWindows) - descScroll = 0 + state.selWinIndex = state.selWinIndex.loopInc(state.tabWindows) + state.descScroll = 0 of Key.H, Key.Left: - selWinIndex = selWinIndex.loopDec(tabWindows) - descScroll = 0 + state.selWinIndex = state.selWinIndex.loopDec(state.tabWindows) + state.descScroll = 0 of Key.J, Key.Down: - selectedByWindow[win] = selectedByWindow[win].loopInc(itemsByWindow[win]) - descScroll = 0 + let cur = state.selectedItemIndex + state.selectedItemIndex = cur.loopInc(state.selectedItemList) + state.descScroll = 0 of Key.K, Key.Up: - selectedByWindow[win] = selectedByWindow[win].loopDec(itemsByWindow[win]) - descScroll = 0 + let cur = state.selectedItemIndex + state.selectedItemIndex = cur.loopDec(state.selectedItemList) + state.descScroll = 0 + of Key.Enter: - let itemList = itemsByWindow[win] - let action = itemList[selectedByWindow[win]] - statusText = fmt"{fancyDisplay(action, -1)}: {doVerb(action, Roll, sheet)}" + let action = state.selectedItem + state.statusText = fmt"{fancyDisplay(action, -1)}: {doVerb(action, Roll, state.sheet)}" + of Key.RightBracket, Key.PageDown: - descScroll += 1 + state.descScroll += 1 of Key.LeftBracket, Key.PageUp: - descScroll = - if descScroll == 0: 0 - else: descScroll - 1 - of Key.Equals: - let sel = itemsByWindow[win][selectedByWindow[win]] - let node = sheet.getExpression(sel) - try: - let val = node.getContent("modifier").parseInt - for key, content in node.fields.pairs: - if key.content == "modifier": - node[key] = newYamlNode($(val + 1)) - except ValueError: - statusText = sel & " is not an integer value." - of Key.Plus: - let sel = itemsByWindow[win][selectedByWindow[win]] - let node = sheet.getExpression(sel) - try: - let val = node.getContent("modifier").parseInt - for key, content in node.fields.pairs: - if key.content == "modifier": - node[key] = newYamlNode($(val + 10)) - except ValueError: - statusText = sel & " is not an integer value." - of Key.Minus: - let sel = itemsByWindow[win][selectedByWindow[win]] - let node = sheet.getExpression(sel) - try: - let val = node.getContent("modifier").parseInt - for key, content in node.fields.pairs: - if key.content == "modifier": - node[key] = newYamlNode($(val - 1)) - except ValueError: - statusText = sel & " is not an integer value." - of Key.Underscore: - let sel = itemsByWindow[win][selectedByWindow[win]] - let node = sheet.getExpression(sel) - try: - let val = node.getContent("modifier").parseInt - for key, content in node.fields.pairs: - if key.content == "modifier": - node[key] = newYamlNode($(val - 10)) - except ValueError: - statusText = sel & " is not an integer value." + state.descScroll = + if state.descScroll == 0: 0 + else: state.descScroll - 1 + + of Key.Equals: addToItem(state, 1) + of Key.Plus: addToItem(state, 10) + of Key.Minus: addToItem(state, -1) + of Key.Underscore: addToItem(state, -10) + of Key.Colon: - mode = Command - statusText = ":" - commandText = "" - sleepTime = 0 # smoother typing + state.mode = Command + state.statusText = ":" + state.commandText = "" + state.sleepTime = 0 # smoother typing of Key.QuestionMark: - mode = Help + state.mode = Help else: discard - # Prepare for drawing + +proc drawHeader(state: State, tb: var TerminalBuffer, + bb: var BoxBuffer): void = + # Write title + let title = fmt"""{state.sheet.root.field("name")}, {state.sheet.root.field("class")} {state.sheet.root.field("level")}""" + let titleWidth = title.len + 3 + bb.drawRect( + 1, 0, + 1 + titleWidth, 2, + doubleStyle = true + ) + tb.write(3, 1, title) + + # Write current tab + let tabX = title.len + 5 + let tabWidth = state.tabName.len + 3 + bb.drawRect( + tabX, 0, + tabX + tabWidth, 2, + doubleStyle = true + ) + tb.write(tabX + 2, 1, state.tabName.fancyDisplay(-1)) + + # Write status line + let statusX = tabX + tabWidth + 1 + bb.drawRect( + statusX, 0, + tb.width - 1, 2, + ) + tb.write(statusX + 2, 1, state.statusText) + + +proc drawWindows(state: State, tb: var TerminalBuffer, + bb: var BoxBuffer, sbb: var BoxBuffer): void = + let descY = tb.height - 10 + let windowPositions = placeWindows(tb, state.tabWindows, descY) + # Write windows + for window in state.tabWindows: + let (x1, y1, x2, y2) = windowPositions[window.content] + if window.content == state.selectedWindow: + sbb.drawRect(x1, y1, x2, y2) + sbb.drawHorizLine(x1, x2, y1+2, doubleStyle=true) + else: + bb.drawRect(x1, y1, x2, y2) + bb.drawHorizLine(x1, x2, y1+2, doubleStyle=true) + + let textWidth = x2 - x1 - 4 + tb.write(x1 + 2, y1+1, fancyDisplay(window.content, textWidth)) + + # Write expressions + for windowNode in state.tabWindows: + let window = windowNode.content + let items = state.itemsByWindow[window] + var scroll = state.scrollByWindow[window] + let sel = state.selectedByWindow[window] + let (x1, y1, x2, y2) = windowPositions[window] + if sel < scroll: + scroll = sel + state.scrollByWindow[window] = sel + # height of the writable area plus scroll = maximum visible index + if sel > y2-y1-4+scroll: + # scroll = sel-(y2-y1-4), simplified + scroll = sel-y2+y1+4 + state.scrollByWindow[window] = scroll + for item in items: + let itemIndex = items.find(item) + if itemIndex < scroll: + continue + if itemIndex > y2-y1-4+scroll: + continue + let textWidth = x2 - x1 - 4 + if item == items[sel]: + tb.setForegroundColor(fgBlue) + tb.write( + x1 + 2, + y1 + 3 + itemIndex - scroll, + fancyDisplay(item, textWidth) + ) + tb.setForegroundColor(fgNone) + + +proc drawDescription(state: State, tb: var TerminalBuffer, + bb: var BoxBuffer): void = + let descY = tb.height - 10 + bb.drawRect(1, descY, + tb.width - 1, tb.height - 1, doubleStyle = true) + + let sel = state.selectedItem + if sel == "": + return + let item = state.sheet.expression(sel) + let desc = item.field("desc") + let val = strip(item.field("dice") & " " & item.field("modifier")) + let roll = + if not item.contains("dice"): + let rolled = doVerb(sel, Roll, state.sheet) + if rolled != item.field("modifier"): + rolled + else: "hardcoded" + else: "Press ENTER to roll" + tb.write(3, descY + 1, fmt"Value: {val} [{roll}]") + if desc != "": + let formattedDesc = desc.wrapWords(maxLineWidth = min(60, tb.width - 6)) + let lines = formattedDesc.splitLines + for i in 0..lines.high: + if i < state.descScroll: + continue + tb.write(3, descY + 2 + i - state.descScroll, lines[i]) + + +proc runTui*(sheet: YamlDocument, file: string): void = + illwillInit(fullscreen=true) + hideCursor() + + let state = newState(sheet) + initializeState(state) + while true: + if handleInput(state, file) == 1: + continue + var tb = newTerminalBuffer(terminalWidth(), terminalHeight()) var bb = newBoxBuffer(tb.width, tb.height) var sbb = newBoxBuffer(tb.width, tb.height) - let descY = tb.height - 10 - let windowPositions = placeWindows(tb, tabWindows, descY) - - - # Write title - let title = fmt"""{sheet.root.getContent("name")}, {sheet.root.getContent("class")} {sheet.root.getContent("level")}""" - let titleWidth = title.len + 3 - bb.drawRect( - 1, 0, - 1 + titleWidth, 2, - doubleStyle = true - ) - tb.write(3, 1, title) - - # Write current tab - let tabX = title.len + 5 - let tabWidth = tabName.len + 3 - bb.drawRect( - tabX, 0, - tabX + tabWidth, 2, - doubleStyle = true - ) - tb.write(tabX + 2, 1, tabName.fancyDisplay(-1)) - - # Write status line - let statusX = tabX + tabWidth + 1 - bb.drawRect( - statusX, 0, - tb.width - 1, 2, - ) - tb.write(statusX + 2, 1, statusText) - - # Write windows - for window in tabWindows: - let (x1, y1, x2, y2) = windowPositions[window.content] - if window == tabWindows[selWinIndex]: - sbb.drawRect(x1, y1, x2, y2) - sbb.drawHorizLine(x1, x2, y1+2, doubleStyle=true) - else: - bb.drawRect(x1, y1, x2, y2) - bb.drawHorizLine(x1, x2, y1+2, doubleStyle=true) - - let textWidth = x2 - x1 - 4 - tb.write(x1 + 2, y1+1, fancyDisplay(window.content, textWidth)) - - # Write expressions - for windowNode in tabWindows: - let window = windowNode.content - let items = itemsByWindow[window] - var scroll = scrollByWindow[window] - let sel = selectedByWindow[window] - let (x1, y1, x2, y2) = windowPositions[window] - if sel < scroll: - scroll = sel - scrollByWindow[window] = sel - # height of the writable area plus scroll = maximum visible index - if sel > y2-y1-4+scroll: - # scroll = sel-(y2-y1-4), simplified - scroll = sel-y2+y1+4 - scrollByWindow[window] = scroll - for item in items: - let itemIndex = items.find(item) - if itemIndex < scroll: - continue - if itemIndex > y2-y1-4+scroll: - continue - let textWidth = x2 - x1 - 4 - if item == items[sel]: - tb.setForegroundColor(fgBlue) - tb.write( - x1 + 2, - y1 + 3 + itemIndex - scroll, - fancyDisplay(item, textWidth) - ) - tb.setForegroundColor(fgNone) - - # Write description - bb.drawRect(1, descY, tb.width - 1, tb.height - 1, doubleStyle = true) - let selWin = tabWindows[selWinIndex].content - let items = itemsByWindow[selWin] - if items.len > 0: - let sel = items[selectedByWindow[selWin]] - let item = sheet.getExpression(sel) - let desc = item.getContent("desc") - let val = (item.getContent("dice") & " " & item.getContent("modifier")).strip - let roll = - if not item.contains("dice"): - let rolled = doVerb(sel, Roll, sheet) - if rolled != item.getContent("modifier"): - rolled - else: "hardcoded" - else: "Press ENTER to roll" - tb.write(3, descY + 1, fmt"Value: {val} [{roll}]") - if desc != "": - let formattedDesc = desc.wrapWords(maxLineWidth = min(60, tb.width - 6)) - let lines = formattedDesc.splitLines - for i in 0..lines.high: - if i < descScroll: - continue - tb.write(3, descY + 2 + i - descScroll, lines[i]) + drawHeader(state, tb, bb) + drawWindows(state, tb, bb, sbb) + drawDescription(state, tb, bb) tb.write(bb) tb.setForegroundColor(fgBlue) tb.write(sbb) tb.display() - sleep(sleepTime) + sleep(state.sleepTime) diff --git a/src/utils.nim b/src/utils.nim @@ -14,7 +14,7 @@ proc loopDec*(index: int, list: seq): int = index - 1 else: list.high -proc getExpression*(sheet: YamlDocument, exp: string): YamlNode = +proc expression*(sheet: YamlDocument, exp: string): YamlNode = sheet.root["expressions"][exp] proc find*[T](list: seq[T], item: T): int = @@ -48,7 +48,7 @@ proc contains*(node: YamlNode, search: string): bool = return true return false -proc getContent*(node: YamlNode, search: string): string = +proc field*(node: YamlNode, search: string): string = for key, value in pairs(node.fields): if key.content == search: return value.content diff --git a/src/verb.nim b/src/verb.nim @@ -20,10 +20,10 @@ type proc expressionAsString(node: YamlNode): string = - result = fmt"""Dice: {node.getContent("dice")}""" & "\n" - result = result & fmt"""Modifier: {node.getContent("modifier")}""" & "\n" - result = result & fmt"""Tab: {node.getContent("tab")}""" & "\n" - result = result & fmt"""Window: {node.getContent("window")}""" + result = fmt"""Dice: {node.field("dice")}""" & "\n" + result = result & fmt"""Modifier: {node.field("modifier")}""" & "\n" + result = result & fmt"""Tab: {node.field("tab")}""" & "\n" + result = result & fmt"""Window: {node.field("window")}""" proc calculateModifier(exp: string, sheet: YamlDocument): string = @@ -41,8 +41,8 @@ proc calculateModifier(exp: string, sheet: YamlDocument): string = return working if sheet.root["expressions"].contains exp: - let subExp = sheet.getExpression(exp) - working = calculateModifier(subExp.getContent("modifier"), sheet) + let subExp = sheet.expression(exp) + working = calculateModifier(subExp.field("modifier"), sheet) return working for match in exp.findAll reRawExp: @@ -61,19 +61,19 @@ proc roll(action: string, sheet: YamlDocument): string = var exp: YamlNode try: - exp = sheet.getExpression(action) + exp = sheet.expression(action) except KeyError: return action & " is not a dice expression or defined expression" var modifier: string try: - modifier = calculateModifier(exp.getContent("modifier"), sheet) + modifier = calculateModifier(exp.field("modifier"), sheet) except KeyError: return "ERROR: " & getCurrentExceptionMsg() if not exp.contains "dice": return modifier - let dice = exp.getContent("dice") + let dice = exp.field("dice") var op = if modifier.parseInt < 0: "" # the minus is the negative sign in the int @@ -91,7 +91,7 @@ proc roll(action: string, sheet: YamlDocument): string = proc view(action: string, sheet: YamlDocument): string = - let node = sheet.getExpression(action) + let node = sheet.expression(action) echo action & ":" echo expressionAsString(node)