Spec fixes: implement op_test (bitwise AND branch), add missing branch to op_get_child, handle call-to-address-0 as no-op in op_call/_call. I/O fixes: correct keyboard API (keyboard_input.read_line), non-TTY fallbacks in trivialzui, stdout flush for immediate output. Graceful handling of unmapped ZSCII characters. Add instruction trace buffer for debugging.
405 lines
12 KiB
Python
405 lines
12 KiB
Python
#
|
|
# A trivial user interface for a Z-Machine that uses (mostly) stdio for
|
|
# everything and supports little to no optional features.
|
|
#
|
|
# For the license of this file, please consult the LICENSE file in the
|
|
# root directory of this distribution.
|
|
#
|
|
|
|
# TODO: There are a few edge-cases in this UI implementation in
|
|
# regards to word-wrapping. For example, if keyboard input doesn't
|
|
# terminate in a newline, then word-wrapping can be temporarily thrown
|
|
# off; the text I/O performed by the audio and filesystem doesn't
|
|
# really communicate with the screen object, which means that
|
|
# operations performed by them can temporarily throw off word-wrapping
|
|
# as well.
|
|
|
|
import sys
|
|
from functools import reduce
|
|
|
|
from . import zaudio, zfilesystem, zscreen, zstream, zui
|
|
from .zlogging import log
|
|
|
|
|
|
class TrivialAudio(zaudio.ZAudio):
|
|
def __init__(self):
|
|
zaudio.ZAudio.__init__(self)
|
|
self.features = {
|
|
"has_more_than_a_bleep": False,
|
|
}
|
|
|
|
def play_bleep(self, bleep_type):
|
|
if bleep_type == zaudio.BLEEP_HIGH:
|
|
sys.stdout.write("AUDIO: high-pitched bleep\n")
|
|
elif bleep_type == zaudio.BLEEP_LOW:
|
|
sys.stdout.write("AUDIO: low-pitched bleep\n")
|
|
else:
|
|
raise AssertionError(f"Invalid bleep_type: {str(bleep_type)}")
|
|
|
|
|
|
class TrivialScreen(zscreen.ZScreen):
|
|
def __init__(self):
|
|
zscreen.ZScreen.__init__(self)
|
|
self.__styleIsAllUppercase = False
|
|
|
|
# Current column of text being printed.
|
|
self.__curr_column = 0
|
|
|
|
# Number of rows displayed since we last took input; needed to
|
|
# keep track of when we need to display the [MORE] prompt.
|
|
self.__rows_since_last_input = 0
|
|
|
|
def split_window(self, height):
|
|
log(f"TODO: split window here to height {height}")
|
|
|
|
def select_window(self, window):
|
|
log(f"TODO: select window {window} here")
|
|
|
|
def set_cursor_position(self, x, y):
|
|
log(f"TODO: set cursor position to ({x},{y}) here")
|
|
|
|
def erase_window(self, window=zscreen.WINDOW_LOWER, color=zscreen.COLOR_CURRENT):
|
|
for _row in range(self._rows):
|
|
sys.stdout.write("\n")
|
|
self.__curr_column = 0
|
|
self.__rows_since_last_input = 0
|
|
|
|
def set_font(self, font_number):
|
|
if font_number == zscreen.FONT_NORMAL:
|
|
return font_number
|
|
else:
|
|
# We aren't going to support anything but the normal font.
|
|
return None
|
|
|
|
def set_text_style(self, style):
|
|
# We're pretty much limited to stdio here; even if we might be
|
|
# able to use terminal hackery under Unix, supporting styled text
|
|
# in a Windows console is problematic [1]. The closest thing we
|
|
# can do is have our "bold" style be all-caps, so we'll do that.
|
|
#
|
|
# [1] http://mail.python.org/pipermail/tutor/2004-February/028474.html
|
|
|
|
if style == zscreen.STYLE_BOLD:
|
|
self.__styleIsAllUppercase = True
|
|
else:
|
|
self.__styleIsAllUppercase = False
|
|
|
|
def __show_more_prompt(self):
|
|
"""Display a [MORE] prompt, wait for the user to press a key, and
|
|
then erase the [MORE] prompt, leaving the cursor at the same
|
|
position that it was at before the call was made."""
|
|
|
|
assert self.__curr_column == 0, "Precondition: current column must be zero."
|
|
|
|
MORE_STRING = "[MORE]"
|
|
sys.stdout.write(MORE_STRING)
|
|
_read_char()
|
|
# Erase the [MORE] prompt and reset the cursor position.
|
|
sys.stdout.write(f"\r{' ' * len(MORE_STRING)}\r")
|
|
self.__rows_since_last_input = 0
|
|
|
|
def on_input_occurred(self, newline_occurred=False):
|
|
"""Callback function that should be called whenever keyboard input
|
|
has occurred; this is so we can keep track of when we need to
|
|
display a [MORE] prompt."""
|
|
|
|
self.__rows_since_last_input = 0
|
|
if newline_occurred:
|
|
self.__curr_column = 0
|
|
|
|
def __unbuffered_write(self, string):
|
|
"""Write the given string, inserting newlines at the end of
|
|
columns as appropriate, and displaying [MORE] prompts when
|
|
appropriate. This function does not perform word-wrapping."""
|
|
|
|
for char in string:
|
|
newline_printed = False
|
|
sys.stdout.write(char)
|
|
sys.stdout.flush()
|
|
|
|
if char == "\n":
|
|
newline_printed = True
|
|
else:
|
|
self.__curr_column += 1
|
|
|
|
if self.__curr_column == self._columns:
|
|
sys.stdout.write("\n")
|
|
newline_printed = True
|
|
|
|
if newline_printed:
|
|
self.__rows_since_last_input += 1
|
|
self.__curr_column = 0
|
|
if (
|
|
self.__rows_since_last_input == self._rows
|
|
and self._rows != zscreen.INFINITE_ROWS
|
|
):
|
|
self.__show_more_prompt()
|
|
|
|
def write(self, string):
|
|
if self.__styleIsAllUppercase:
|
|
# Apply our fake "bold" transformation.
|
|
string = string.upper()
|
|
|
|
if self.buffer_mode:
|
|
# This is a hack to get words to wrap properly, based on our
|
|
# current cursor position.
|
|
|
|
# First, add whitespace padding up to the column of text that
|
|
# we're at.
|
|
string = (" " * self.__curr_column) + string
|
|
|
|
# Next, word wrap our current string.
|
|
string = _word_wrap(string, self._columns - 1)
|
|
|
|
# Now remove the whitespace padding.
|
|
string = string[self.__curr_column :]
|
|
|
|
self.__unbuffered_write(string)
|
|
|
|
|
|
class TrivialKeyboardInputStream(zstream.ZInputStream):
|
|
def __init__(self, screen):
|
|
zstream.ZInputStream.__init__(self)
|
|
self.__screen = screen
|
|
self.features = {
|
|
"has_timed_input": False,
|
|
}
|
|
|
|
def read_line(
|
|
self,
|
|
original_text=None,
|
|
max_length=0,
|
|
terminating_characters=None,
|
|
timed_input_routine=None,
|
|
timed_input_interval=0,
|
|
):
|
|
result = _read_line(original_text, terminating_characters)
|
|
if max_length > 0:
|
|
result = result[:max_length]
|
|
|
|
# TODO: The value of 'newline_occurred' here is not accurate,
|
|
# because terminating_characters may include characters other than
|
|
# carriage return.
|
|
self.__screen.on_input_occurred(newline_occurred=True)
|
|
|
|
return str(result)
|
|
|
|
def read_char(self, timed_input_routine=None, timed_input_interval=0):
|
|
result = _read_char()
|
|
self.__screen.on_input_occurred()
|
|
return ord(result)
|
|
|
|
|
|
class TrivialFilesystem(zfilesystem.ZFilesystem):
|
|
def __report_io_error(self, exception):
|
|
sys.stdout.write(f"FILESYSTEM: An error occurred: {exception}\n")
|
|
|
|
def save_game(self, data, suggested_filename=None):
|
|
success = False
|
|
|
|
sys.stdout.write("Enter a name for the saved game (hit enter to cancel): ")
|
|
filename = _read_line(suggested_filename)
|
|
if filename:
|
|
try:
|
|
with open(filename, "wb") as file_obj:
|
|
file_obj.write(data)
|
|
success = True
|
|
except OSError as e:
|
|
self.__report_io_error(e)
|
|
|
|
return success
|
|
|
|
def restore_game(self):
|
|
data = None
|
|
|
|
sys.stdout.write(
|
|
"Enter the name of the saved game to restore (hit enter to cancel): "
|
|
)
|
|
filename = _read_line()
|
|
if filename:
|
|
try:
|
|
with open(filename, "rb") as file_obj:
|
|
data = file_obj.read()
|
|
except OSError as e:
|
|
self.__report_io_error(e)
|
|
|
|
return data
|
|
|
|
def open_transcript_file_for_writing(self):
|
|
file_obj = None
|
|
|
|
sys.stdout.write("Enter a name for the transcript file (hit enter to cancel): ")
|
|
filename = _read_line()
|
|
if filename:
|
|
try:
|
|
file_obj = open(filename, "w") # noqa: SIM115
|
|
except OSError as e:
|
|
self.__report_io_error(e)
|
|
|
|
return file_obj
|
|
|
|
def open_transcript_file_for_reading(self):
|
|
file_obj = None
|
|
|
|
sys.stdout.write(
|
|
"Enter the name of the transcript file to read (hit enter to cancel): "
|
|
)
|
|
filename = _read_line()
|
|
if filename:
|
|
try:
|
|
file_obj = open(filename) # noqa: SIM115
|
|
except OSError as e:
|
|
self.__report_io_error(e)
|
|
|
|
return file_obj
|
|
|
|
|
|
def create_zui():
|
|
"""Creates and returns a ZUI instance representing a trivial user
|
|
interface."""
|
|
|
|
audio = TrivialAudio()
|
|
screen = TrivialScreen()
|
|
keyboard_input = TrivialKeyboardInputStream(screen)
|
|
filesystem = TrivialFilesystem()
|
|
|
|
return zui.ZUI(audio, screen, keyboard_input, filesystem)
|
|
|
|
|
|
# Keyboard input functions
|
|
|
|
_INTERRUPT_CHAR = chr(3)
|
|
_BACKSPACE_CHAR = chr(8)
|
|
_DELETE_CHAR = chr(127)
|
|
|
|
|
|
def _win32_read_char():
|
|
"""Win32-specific function that reads a character of input from the
|
|
keyboard and returns it without printing it to the screen."""
|
|
|
|
import msvcrt
|
|
|
|
return str(msvcrt.getch()) # type: ignore[possibly-missing-attribute]
|
|
|
|
|
|
def _unix_read_char():
|
|
"""Unix-specific function that reads a character of input from the
|
|
keyboard and returns it without printing it to the screen."""
|
|
|
|
# This code was excised from:
|
|
# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/134892
|
|
|
|
import termios
|
|
import tty
|
|
|
|
fd = sys.stdin.fileno()
|
|
|
|
# Check if stdin is a TTY - if not, use simple read
|
|
if not sys.stdin.isatty():
|
|
return sys.stdin.read(1)
|
|
|
|
old_settings = termios.tcgetattr(fd)
|
|
try:
|
|
tty.setraw(sys.stdin.fileno())
|
|
ch = sys.stdin.read(1)
|
|
finally:
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
return str(ch)
|
|
|
|
|
|
def _read_char():
|
|
"""Reads a character of input from the keyboard and returns it
|
|
without printing it to the screen."""
|
|
|
|
if sys.platform == "win32":
|
|
_platform_read_char = _win32_read_char
|
|
else:
|
|
# We're not running on Windows, so assume we're running on Unix.
|
|
_platform_read_char = _unix_read_char
|
|
|
|
char = _platform_read_char()
|
|
if char == _INTERRUPT_CHAR:
|
|
raise KeyboardInterrupt()
|
|
else:
|
|
return char
|
|
|
|
|
|
def _read_line(original_text=None, terminating_characters=None):
|
|
"""Reads a line of input with the given unicode string of original
|
|
text, which is editable, and the given unicode string of terminating
|
|
characters (used to terminate text input). By default,
|
|
terminating_characters is a string containing the carriage return
|
|
character ('\r')."""
|
|
|
|
if original_text is None:
|
|
original_text = ""
|
|
if not terminating_characters:
|
|
terminating_characters = "\r"
|
|
|
|
assert isinstance(original_text, str)
|
|
assert isinstance(terminating_characters, str)
|
|
|
|
# If stdin is not a TTY, use simple line reading
|
|
if not sys.stdin.isatty():
|
|
line = sys.stdin.readline()
|
|
if not line: # EOF
|
|
raise EOFError("End of input")
|
|
# Strip newline but keep the content
|
|
return line.rstrip("\n\r")
|
|
|
|
chars_entered = len(original_text)
|
|
sys.stdout.write(original_text)
|
|
string = original_text
|
|
finished = False
|
|
while not finished:
|
|
char = _read_char()
|
|
|
|
if char in (_BACKSPACE_CHAR, _DELETE_CHAR):
|
|
if chars_entered > 0:
|
|
chars_entered -= 1
|
|
string = string[:-1]
|
|
else:
|
|
continue
|
|
elif char in terminating_characters:
|
|
finished = True
|
|
else:
|
|
string += char
|
|
chars_entered += 1
|
|
|
|
if char == "\r":
|
|
char_to_print = "\n"
|
|
elif char == _BACKSPACE_CHAR:
|
|
char_to_print = f"{_BACKSPACE_CHAR} {_BACKSPACE_CHAR}"
|
|
else:
|
|
char_to_print = char
|
|
|
|
sys.stdout.write(char_to_print)
|
|
return string
|
|
|
|
|
|
# Word wrapping helper function
|
|
|
|
|
|
def _word_wrap(text, width):
|
|
"""
|
|
A word-wrap function that preserves existing line breaks
|
|
and most spaces in the text. Expects that existing line
|
|
breaks are posix newlines (\n).
|
|
"""
|
|
|
|
# This code was taken from:
|
|
# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/148061
|
|
|
|
return reduce(
|
|
lambda line, word, width=width: "{}{}{}".format(
|
|
line,
|
|
" \n"[
|
|
(
|
|
len(line) - line.rfind("\n") - 1 + len(word.split("\n", 1)[0])
|
|
>= width
|
|
)
|
|
],
|
|
word,
|
|
),
|
|
text.split(" "),
|
|
)
|