commit c08bb1bd1a8025db5724dc14a81cf1f3e9e74e00
parent d6a3c0ed94e6f38c0d9762e2ff9af2e882e0402c
Author: Skylar Hill <stellarskylark@posteo.net>
Date: Wed, 1 Jun 2022 22:34:44 -0500
Add basic TUI
Diffstat:
7 files changed, 380 insertions(+), 69 deletions(-)
diff --git a/examplesheet.yaml b/examplesheet.yaml
@@ -1,34 +1,39 @@
+name: Cragthar
+class: Paladin (Oath of Failure)
+level: 3
tabs:
- main:
- - ability-scores
- - ability-modifiers
- - skills
- - misc
- attacks:
- - weapons
+ - main:
+ - ability-scores
+ - ability-modifiers
+ - skills
+ - misc
+ - attacks:
+ - weapons
expressions:
dexterity:
- exp: "16"
+ modifier: 16
tab: main
window: ability-scores
DEX:
- exp: "(dexterity-10)/2"
+ modifier: "(dexterity-10)/2"
tab: main
window: ability-modifiers
- prof:
- exp: "2"
+ proficiency:
+ modifier: 2
tab: main
window: misc
sleight-of-hand:
- dice: "d20"
- exp: "DEX+prof"
- shortsword-atk:
- dice: "d20"
- exp: "DEX+prof"
+ dice: d20
+ modifier: "DEX+proficiency"
+ tab: main
+ window: skills
+ shortsword-attack:
+ dice: d20
+ modifier: "DEX+proficiency"
tab: attacks
window: weapons
- shortsword-dmg:
- dice: "d6"
- exp: "DEX"
+ shortsword-damage:
+ dice: d6
+ modifier: "DEX"
tab: attacks
window: weapons
diff --git a/rpgsheet.nimble b/rpgsheet.nimble
@@ -12,3 +12,4 @@ bin = @["rpgsheet"]
requires "nim >= 1.6.6"
requires "yaml >= 0.16.0"
+requires "illwill >= 0.3.0"
diff --git a/src/rpgsheet.nim b/src/rpgsheet.nim
@@ -1,51 +1,54 @@
-import json, yaml, re, strutils, osproc, os
-
-let reRawExp = re"([a-zA-Z\-]+[a-zA-Z])"
-let reProcessedExp = re"^[\d*+\-/()]*$"
-
-proc decodeExpression(exp: string, sheet: JsonNode): string =
- var working = exp
- if exp.contains(reProcessedExp):
- # I think that execCmdEx adds whitespace in input;
- # the xargs pipe first is necessary to prevent bc
- # from throwing up
- let (output, code) = execCmdEx("xargs | bc", input=exp)
- if code == 0:
- working = output.strip
- return working
-
- if sheet["expressions"].contains exp:
- working = decodeExpression(sheet["expressions"][exp]["exp"].getStr, sheet)
- return working
-
- for match in exp.findAll reRawExp:
- working = working.replace(match, decodeExpression(match, sheet))
- return decodeExpression(working, sheet)
-
-proc roll(action: string, sheet: JsonNode): string =
- let exp = sheet["expressions"][action]
- if not exp.contains "dice":
- return decodeExpression(exp["exp"].getStr, sheet)
- let dice = exp["dice"].getStr
- var modifier = decodeExpression(exp["exp"].getStr, sheet)
- var op =
- if modifier.parseInt < 0:
- "" # the minus is the negative sign in the int
+import
+ os,
+ parseopt,
+ streams,
+ yaml
+
+import verb, tui
+
+let usage = """
+Usage:
+ rpgsheet sheet.yaml action
+
+Options:
+ -v --view View action
+ -i --interactive Interactive mode
+ (don't need to supply
+ an action)"""
+
+proc loadSheet(file: string): YamlDocument =
+ let stream = openFileStream(file, fmRead)
+ result = loadDom(stream)
+ stream.close()
+
+proc main(): void =
+ var arg = 0
+ var sheetLocation: string
+ var action: string
+ var verb: Verb = Roll
+
+ for kind, key, val in getopt(commandLineParams()):
+ case kind
+ of cmdArgument:
+ if arg == 0:
+ sheetLocation = key
+ if arg == 1:
+ action = key
+ arg += 1
+ of cmdShortOption, cmdLongOption:
+ case key
+ of "v", "view": verb = View
+ of "i", "interactive": verb = Tui
else:
- "+"
- let rollexp = dice & op & modifier
- let (output, code) = execCmdEx("rolldice", input = rollexp)
- if code == 0:
- # rolldice returns its input; the actual roll is the second line
- result = output.splitLines[1]
-
-if paramCount() < 2:
- quit(
- """Usage:
- ./rpgsheet sheet.yaml action
- """
- )
-
-let file = open(paramStr(1))
-let sheetNode = file.readAll.loadtoJson[0]
-echo roll(paramStr(2), sheetNode)
+ quit(usage)
+
+ if arg < 2 and verb != Tui:
+ quit(usage)
+
+ let sheet = loadSheet(sheetLocation)
+ if verb == Tui:
+ runTui(sheet)
+ return
+ echo doVerb(action, verb, sheet)
+
+main()
diff --git a/src/tui b/src/tui
Binary files differ.
diff --git a/src/tui.nim b/src/tui.nim
@@ -0,0 +1,182 @@
+import
+ illwill,
+ os,
+ strformat,
+ tables,
+ yaml
+
+import utils, verb
+
+proc placeWindows(tb: TerminalBuffer, windows: seq[YamlNode]): Table[string, (int, int, int, int)] =
+ result = initTable[string, (int, int, int, int)]()
+ let windowWidth =
+ if windows.len == 1:
+ tb.width - 1
+ else:
+ tb.width div windows.len
+
+ var currentX = 1
+ for win in windows:
+ result[win.content] = (currentX, 3, currentX + windowWidth - 1, tb.height - 1)
+ currentX += windowWidth
+
+proc exitProc() {.noconv.} =
+ illwillDeinit()
+ showCursor()
+ quit(0)
+
+proc runTui*(sheet: YamlDocument): void =
+ let tabs = sheet.root["tabs"]
+ var currentTabIndex = 0
+
+ illwillInit(fullscreen=true)
+ setControlCHook(exitProc)
+ hideCursor()
+
+ var selectedWindowIndex = 0
+ var selectedByWindow = initTable[string, int]()
+ var itemsByWindow = initTable[string, seq[string]]()
+ var statusText = ""
+
+ while true:
+ var tb = newTerminalBuffer(terminalWidth(), terminalHeight())
+ var bb = newBoxBuffer(tb.width, tb.height)
+ var sbb = newBoxBuffer(tb.width, tb.height)
+
+ var currentTabName: string
+ var currentTabWindows: 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[currentTabIndex].fields.pairs:
+ currentTabName = n.content
+ currentTabWindows = w.elems
+
+ let windowPositions = placeWindows(tb, currentTabWindows)
+
+ for win in currentTabWindows:
+ if win.content notin itemsByWindow:
+ itemsByWindow[win.content] = newSeqOfCap[string](100)
+
+ var key = getKey()
+ case key
+ of Key.Q, Key.Escape: exitProc()
+ of Key.ShiftJ:
+ if currentTabIndex < tabs.elems.high:
+ currentTabIndex += 1
+ else:
+ currentTabIndex = tabs.elems.low
+ selectedWindowIndex = 0
+ of Key.ShiftK:
+ if currentTabIndex > tabs.elems.low:
+ currentTabIndex -= 1
+ else:
+ currentTabIndex = tabs.elems.high
+ selectedWindowIndex = 0
+ of Key.L:
+ if selectedWindowIndex < currentTabWindows.high:
+ selectedWindowIndex += 1
+ else:
+ selectedWindowIndex = currentTabWindows.low
+ of Key.H:
+ if selectedWindowIndex > currentTabWindows.low:
+ selectedWindowIndex -= 1
+ else:
+ selectedWindowIndex = currentTabWindows.high
+ of Key.J:
+ let win = currentTabWindows[selectedWindowIndex].content
+ if selectedByWindow[win] < itemsByWindow[win].high:
+ selectedByWindow[win] = selectedByWindow[win] + 1
+ else:
+ selectedByWindow[win] = itemsByWindow[win].low
+ of Key.K:
+ let win = currentTabWindows[selectedWindowIndex].content
+ if selectedByWindow[win] > itemsByWindow[win].low:
+ selectedByWindow[win] = selectedByWindow[win] - 1
+ else:
+ selectedByWindow[win] = itemsByWindow[win].high
+ of Key.Enter:
+ let win = currentTabWindows[selectedWindowIndex].content
+ let itemList = itemsByWindow[win]
+ let action = itemList[selectedByWindow[win]]
+ statusText = fmt"{fancyDisplay(action, 99)}: {doVerb(action, Roll, sheet)}"
+ else: discard
+
+ # 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 = currentTabName.len + 3
+ bb.drawRect(
+ tabX, 0,
+ tabX + tabWidth, 2,
+ doubleStyle = true
+ )
+ tb.write(tabX + 2, 1, currentTabName)
+
+ # 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 win in currentTabWindows:
+ let (x1, y1, x2, y2) = windowPositions[win.content]
+ if win == currentTabWindows[selectedWindowIndex]:
+ 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(win.content, textWidth))
+
+ # Write expressions
+ for name, content in sheet.root["expressions"].fields.pairs:
+ if content.getContent("tab") != currentTabName:
+ continue
+
+ let win = content.getContent("window")
+ if win == "":
+ continue
+ if win notin windowPositions:
+ continue
+
+ if win notin selectedByWindow:
+ selectedByWindow[win] = 0
+
+ let (x1, y1, x2, _) = windowPositions[win]
+ let textWidth = x2 - x1 - 4
+ let sel = selectedByWindow[win]
+ var currentItems = itemsByWindow[win]
+ if name.content notin currentItems:
+ currentItems.add(name.content)
+ if sel <= currentItems.high:
+ if name.content == currentItems[sel]:
+ tb.setForegroundColor(fgBlue)
+ tb.write(
+ x1 + 2,
+ y1 + 3 + currentItems.find(name.content),
+ fancyDisplay(name.content, textWidth)
+ )
+ tb.setForegroundColor(fgNone)
+ itemsbyWindow[win] = currentItems
+
+ tb.write(bb)
+ tb.setForegroundColor(fgBlue)
+ tb.write(sbb)
+ tb.display()
+
+ sleep(20)
diff --git a/src/utils.nim b/src/utils.nim
@@ -0,0 +1,28 @@
+import tables, strutils, yaml
+
+proc find*[T](list: seq[T], item: T): int =
+ for i in 0..list.high:
+ if list[i] == item: return i
+ result = -1
+
+proc fancyDisplay*(exp: string, maxLen: int): string =
+ result =
+ if maxLen == -1:
+ exp
+ else:
+ exp[0..min(maxLen-1, exp.len-1)]
+ result = result.replace("-", " ")
+ for word in result.split(" "):
+ result = result.replace(word, word.capitalizeAscii)
+
+proc contains*(node: YamlNode, search: string): bool =
+ for key in keys(node.fields):
+ if key.content == search:
+ return true
+ return false
+
+proc getContent*(node: YamlNode, search: string): string =
+ for key, value in pairs(node.fields):
+ if key.content == search:
+ return value.content
+ return ""
diff --git a/src/verb.nim b/src/verb.nim
@@ -0,0 +1,92 @@
+import
+ osproc,
+ re,
+ streams,
+ strformat,
+ strutils,
+ yaml
+
+import utils
+
+let reRawExp = re"([a-zA-Z\-]+[a-zA-Z])"
+let reProcessedExp = re"^[\d*+\-/()]*$"
+let reDice = re"^\d*d\d+$"
+
+type
+ Verb* = enum
+ Roll
+ View
+ Tui
+
+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")}"""
+
+proc calculateModifier(exp: string, sheet: YamlNode): string =
+ var working = exp
+ if exp.contains(reProcessedExp):
+ # I think that execCmdEx adds whitespace in input;
+ # the xargs pipe first is necessary to prevent bc
+ # from throwing up
+ let (output, code) = execCmdEx("xargs | bc", input=exp)
+ if code == 0:
+ working = output.strip
+ return working
+
+ if sheet["expressions"].contains exp:
+ let subExp = sheet["expressions"][exp]
+ working = calculateModifier(subExp.getContent("modifier"), sheet)
+ return working
+
+ for match in exp.findAll reRawExp:
+ working = working.replace(match, calculateModifier(match, sheet))
+ return calculateModifier(working, sheet)
+
+proc roll(action: string, sheet: YamlNode): string =
+ if action.contains(reDice):
+ let (output, code) = execCmdEx("rolldice -s", input = action)
+ if code == 0:
+ # rolldice returns its input; the actual roll is the second line
+ return " " & output.splitLines[1]
+
+ var exp: YamlNode
+ try:
+ exp = sheet["expressions"][action]
+ except KeyError:
+ return action & " is not a dice expression or defined expression"
+
+ var modifier = calculateModifier(exp.getContent("modifier"), sheet)
+ if not exp.contains "dice":
+ return modifier
+ let dice = exp.getContent("dice")
+ var op =
+ if modifier.parseInt < 0:
+ "" # the minus is the negative sign in the int
+ else:
+ "+"
+ let rollexp = dice & op & modifier
+ let (output, code) = execCmdEx("rolldice -s", input = rollexp)
+ if code == 0:
+ # rolldice returns its input; the actual roll is the second line
+ result = output.splitLines[1]
+
+proc view(action: string, sheet: YamlNode): string =
+ let node = sheet["expressions"][action]
+ echo action & ":"
+ echo expressionAsString(node)
+
+proc write(doc: YamlDocument, file: string) =
+ let s = newfileStream(file, fmWrite)
+ dumpDom(doc, s, options = defineOptions(style = psBlockOnly))
+ s.close()
+
+proc doVerb*(action: string, verb: Verb, sheet: YamlDocument): string =
+ case verb
+ of Roll:
+ return roll(action, sheet.root)
+ of View:
+ return view(action, sheet.root)
+ of Tui:
+ raise newException(Exception, "Please report this error, this code should never get run.")