Files
ages/agame/display/unix.py
Alek Ratzloff c71077a8f3 Move colorization to the display layer
Color output isn't necessarily always going to be a terminal output
thing, and terminals don't always support the same escape codes (e.g. on
Windows). Thus, all colorization efforts are done in the Display rather
than in the Game.

Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
2021-11-28 17:37:37 -08:00

155 lines
4.3 KiB
Python

import re
import sys
import termios
from typing import Optional, Sequence, TextIO, Tuple, TYPE_CHECKING
from agame.action import Action
from agame.dialog import DialogOption
from .display import Display
if TYPE_CHECKING:
from agame.game import Game
__all__ = ("ANSIDisplay",)
ESC = "\u001b"
ANSI_MOVE_BOTTOM = f"{ESC}[999;0f"
ANSI_BOLD_COL = f"{ESC}[1m"
ANSI_ITALIC_COL = f"{ESC}[3m"
ANSI_INTEREST_COL = f"{ESC}[34;1m"
ANSI_SHADOW_COL = f"{ESC}[30;1m"
ANSI_RESET_COL = f"{ESC}[0m"
BOLD_PAT = re.compile(r"\*\*(.+?)\*\*", re.MULTILINE)
ITALIC_PAT = re.compile(r"//(.+?)//", re.MULTILINE)
INTEREST_PAT = re.compile(r"\(\((.+?)\)\)", re.MULTILINE)
SHADOW_PAT = re.compile(r"\{\{(.+?)\}\}", re.MULTILINE)
def colorize(text: str) -> str:
"""
Colorizes text for output on an ANSI terminal.
This will use escape codes to replace things.
Style guide:
((This)) is "interest" styling. This will make the text blue.
{{This}} is "shadow" styling. This will make the text a dark grey (or at
least, more subtle.)
"""
replacements = [
(INTEREST_PAT, ANSI_INTEREST_COL),
(SHADOW_PAT, ANSI_SHADOW_COL),
(BOLD_PAT, ANSI_BOLD_COL),
(ITALIC_PAT, ANSI_ITALIC_COL),
]
for (pat, col) in replacements:
text = pat.sub(col + r"\1" + ANSI_RESET_COL, text)
return text
class ANSIDisplay(Display):
"""
Display for a modern terminal.
This display attempts to keep the input at the bottom, and the output
scrolling up.
This utilizes `termios` and is therefore UNIX-only.
"""
def __init__(
self,
input_prompt: str = colorize("{{>}} "),
stdin: TextIO = sys.stdin,
stdout: TextIO = sys.stdout,
):
self.input_prompt = input_prompt
self.__stdin = stdin
self.__stdout = stdout
# Initial set-up:
# . Move the cursor to the bottom left part of the screen (this will set
# the current line to a really high value and will move us to the
# bottom of the screen)
self.stdout.write(ANSI_MOVE_BOTTOM)
# . Disable echo, set cbreak
fd = self.stdin.fileno()
self.__old_attrs = termios.tcgetattr(fd)
self.cbreak()
# . Clear
self.clear()
def line(self, line: str = ""):
line = colorize(line)
self.stdout.write(line)
self.stdout.write("\n")
def input(self, prompt: Optional[str] = None) -> str:
if prompt is None:
prompt = self.input_prompt
try:
self.nocbreak()
return input(prompt)
finally:
self.cbreak()
def wait_for_ack(self, prompt: Optional[str] = "Press any key to continue..."):
# Flush unread data
termios.tcflush(self.stdin.fileno(), termios.TCIFLUSH)
if prompt is not None:
self.line(prompt)
self.stdin.read(1)
def dialog_options(self, options: Sequence[DialogOption]) -> DialogOption:
valid_opts = range(1, len(options) + 1)
def print_opts():
for i, opt in zip(valid_opts, options):
# TODO - format/colorize this so it stands out
self.line(f"{i}. {opt.text}")
print_opts()
while True:
got = self.input("? ")
try:
choice = int(got)
except ValueError:
print_opts()
continue
if choice in valid_opts:
break
index = choice - 1
selection = options[index]
return selection
def finish(self):
# Clean up by restoring the old terminal state
fd = self.stdin.fileno()
termios.tcsetattr(fd, termios.TCSADRAIN, self.__old_attrs)
def cbreak(self):
fd = self.stdin.fileno()
new = termios.tcgetattr(fd)
new[3] &= ~(termios.ECHO | termios.ICANON)
termios.tcsetattr(fd, termios.TCSADRAIN, new)
def nocbreak(self):
fd = self.stdin.fileno()
new = termios.tcgetattr(fd)
new[3] |= termios.ECHO | termios.ICANON
termios.tcsetattr(fd, termios.TCSADRAIN, new)
def clear(self):
self.stdout.write(f"{ESC}[2J")
@property
def stdin(self) -> TextIO:
return self.__stdin
@property
def stdout(self) -> TextIO:
return self.__stdout