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:
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)