mud/src/mudlib/zmachine/trivialzui.py
Jared Miller ec4e53b2d4
Fix backspace echo for terminals that send chr(127)
The input detection handled both chr(8) and chr(127) but the echo
logic only checked chr(8). Most modern terminals send chr(127) for
backspace, so the cursor never moved back visually.
2026-02-09 23:11:43 -05:00

406 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 in (_BACKSPACE_CHAR, _DELETE_CHAR):
char_to_print = f"{_BACKSPACE_CHAR} {_BACKSPACE_CHAR}"
else:
char_to_print = char
sys.stdout.write(char_to_print)
sys.stdout.flush()
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(" "),
)