Add up/down arrow command history to game input
Replace bare input() with a custom terminal reader that supports arrow-key history navigation, cursor movement, and line editing. History buffer grows dynamically with no fixed limit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
"""Terminal input with up/down arrow command history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tty
|
||||||
|
import termios
|
||||||
|
|
||||||
|
|
||||||
|
class InputHistory:
|
||||||
|
"""Reads a line of input with up/down arrow history navigation.
|
||||||
|
|
||||||
|
History buffer grows dynamically with no fixed limit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._history: list[str] = []
|
||||||
|
|
||||||
|
def input(self, prompt: str = "> ") -> str:
|
||||||
|
"""Read a line with history support. Raises EOFError on Ctrl-D."""
|
||||||
|
sys.stdout.write(prompt)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setraw(fd)
|
||||||
|
line = self._read_line(fd, prompt)
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
if line:
|
||||||
|
self._history.append(line)
|
||||||
|
return line
|
||||||
|
|
||||||
|
def _read_line(self, fd: int, prompt: str) -> str:
|
||||||
|
buf: list[str] = []
|
||||||
|
cursor = 0
|
||||||
|
# Index into history; len(history) means "current new line"
|
||||||
|
hist_idx = len(self._history)
|
||||||
|
saved_line = "" # what the user typed before browsing history
|
||||||
|
|
||||||
|
while True:
|
||||||
|
ch = self._read_char(fd)
|
||||||
|
|
||||||
|
if ch == "\r" or ch == "\n":
|
||||||
|
return "".join(buf)
|
||||||
|
|
||||||
|
if ch == "\x04": # Ctrl-D
|
||||||
|
if not buf:
|
||||||
|
raise EOFError
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == "\x03": # Ctrl-C
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
if ch == "\x7f" or ch == "\x08": # Backspace
|
||||||
|
if cursor > 0:
|
||||||
|
buf.pop(cursor - 1)
|
||||||
|
cursor -= 1
|
||||||
|
self._redraw(buf, cursor, prompt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == "\x1b": # Escape sequence
|
||||||
|
seq1 = self._read_char(fd)
|
||||||
|
if seq1 == "[":
|
||||||
|
seq2 = self._read_char(fd)
|
||||||
|
if seq2 == "A": # Up arrow
|
||||||
|
if hist_idx > 0:
|
||||||
|
if hist_idx == len(self._history):
|
||||||
|
saved_line = "".join(buf)
|
||||||
|
hist_idx -= 1
|
||||||
|
buf = list(self._history[hist_idx])
|
||||||
|
cursor = len(buf)
|
||||||
|
self._redraw(buf, cursor, prompt)
|
||||||
|
continue
|
||||||
|
if seq2 == "B": # Down arrow
|
||||||
|
if hist_idx < len(self._history):
|
||||||
|
hist_idx += 1
|
||||||
|
if hist_idx == len(self._history):
|
||||||
|
buf = list(saved_line)
|
||||||
|
else:
|
||||||
|
buf = list(self._history[hist_idx])
|
||||||
|
cursor = len(buf)
|
||||||
|
self._redraw(buf, cursor, prompt)
|
||||||
|
continue
|
||||||
|
if seq2 == "C": # Right arrow
|
||||||
|
if cursor < len(buf):
|
||||||
|
cursor += 1
|
||||||
|
sys.stdout.write("\x1b[C")
|
||||||
|
sys.stdout.flush()
|
||||||
|
continue
|
||||||
|
if seq2 == "D": # Left arrow
|
||||||
|
if cursor > 0:
|
||||||
|
cursor -= 1
|
||||||
|
sys.stdout.write("\x1b[D")
|
||||||
|
sys.stdout.flush()
|
||||||
|
continue
|
||||||
|
if seq2 == "3": # Possible Delete key
|
||||||
|
seq3 = self._read_char(fd)
|
||||||
|
if seq3 == "~" and cursor < len(buf):
|
||||||
|
buf.pop(cursor)
|
||||||
|
self._redraw(buf, cursor, prompt)
|
||||||
|
continue
|
||||||
|
if seq2 == "H": # Home
|
||||||
|
cursor = 0
|
||||||
|
self._redraw(buf, cursor, prompt)
|
||||||
|
continue
|
||||||
|
if seq2 == "F": # End
|
||||||
|
cursor = len(buf)
|
||||||
|
self._redraw(buf, cursor, prompt)
|
||||||
|
continue
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Regular printable character
|
||||||
|
if ch.isprintable():
|
||||||
|
buf.insert(cursor, ch)
|
||||||
|
cursor += 1
|
||||||
|
if cursor == len(buf):
|
||||||
|
sys.stdout.write(ch)
|
||||||
|
sys.stdout.flush()
|
||||||
|
else:
|
||||||
|
self._redraw(buf, cursor, prompt)
|
||||||
|
|
||||||
|
return "".join(buf) # unreachable, keeps type-checker happy
|
||||||
|
|
||||||
|
def _read_char(self, fd: int) -> str:
|
||||||
|
return sys.stdin.read(1)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redraw(buf: list[str], cursor: int, prompt: str) -> None:
|
||||||
|
"""Clear the line and redraw the prompt + buffer with cursor positioned."""
|
||||||
|
line = "".join(buf)
|
||||||
|
# Move to start of line, clear it, rewrite
|
||||||
|
sys.stdout.write(f"\r\x1b[K{prompt}{line}")
|
||||||
|
# Position cursor
|
||||||
|
back = len(line) - cursor
|
||||||
|
if back > 0:
|
||||||
|
sys.stdout.write(f"\x1b[{back}D")
|
||||||
|
sys.stdout.flush()
|
||||||
+3
-1
@@ -8,6 +8,7 @@ from h2g2.engine.game_object import (
|
|||||||
GameObject, Room, Flag, Direction,
|
GameObject, Room, Flag, Direction,
|
||||||
DirectExit, ConditionalExit, GatedExit, BlockedExit,
|
DirectExit, ConditionalExit, GatedExit, BlockedExit,
|
||||||
)
|
)
|
||||||
|
from h2g2.engine.input_history import InputHistory
|
||||||
from h2g2.engine.parser import Parser, ParseResult
|
from h2g2.engine.parser import Parser, ParseResult
|
||||||
from h2g2.engine.state import GameState
|
from h2g2.engine.state import GameState
|
||||||
from h2g2.engine.output import Output
|
from h2g2.engine.output import Output
|
||||||
@@ -47,6 +48,7 @@ class GameLoop:
|
|||||||
def __init__(self, state: GameState, parser: Parser) -> None:
|
def __init__(self, state: GameState, parser: Parser) -> None:
|
||||||
self.state = state
|
self.state = state
|
||||||
self.parser = parser
|
self.parser = parser
|
||||||
|
self._input = InputHistory()
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
"""Run the main game loop until the game ends."""
|
"""Run the main game loop until the game ends."""
|
||||||
@@ -59,7 +61,7 @@ class GameLoop:
|
|||||||
|
|
||||||
while state.running:
|
while state.running:
|
||||||
try:
|
try:
|
||||||
raw = input("\n> ")
|
raw = self._input.input("\n> ")
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
print("\nGoodbye!")
|
print("\nGoodbye!")
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Tests for InputHistory — command history buffer and line editing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from h2g2.engine.input_history import InputHistory
|
||||||
|
|
||||||
|
# Escape sequences for arrow keys
|
||||||
|
UP = "\x1b[A"
|
||||||
|
DOWN = "\x1b[B"
|
||||||
|
RIGHT = "\x1b[C"
|
||||||
|
LEFT = "\x1b[D"
|
||||||
|
HOME = "\x1b[H"
|
||||||
|
END = "\x1b[F"
|
||||||
|
DELETE = "\x1b[3~"
|
||||||
|
BACKSPACE = "\x7f"
|
||||||
|
ENTER = "\r"
|
||||||
|
CTRL_D = "\x04"
|
||||||
|
CTRL_C = "\x03"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeInputHistory(InputHistory):
|
||||||
|
"""InputHistory subclass that reads from a character queue
|
||||||
|
and captures terminal output instead of touching the real terminal."""
|
||||||
|
|
||||||
|
def __init__(self, chars: str = "") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._chars: deque[str] = deque(chars)
|
||||||
|
self.output = io.StringIO()
|
||||||
|
|
||||||
|
def feed(self, chars: str) -> None:
|
||||||
|
self._chars.extend(chars)
|
||||||
|
|
||||||
|
def _read_char(self, fd: int) -> str:
|
||||||
|
return self._chars.popleft()
|
||||||
|
|
||||||
|
def input(self, prompt: str = "> ") -> str:
|
||||||
|
"""Bypass terminal raw-mode setup; drive _read_line directly."""
|
||||||
|
self.output.write(prompt)
|
||||||
|
line = self._read_line(0, prompt)
|
||||||
|
if line:
|
||||||
|
self._history.append(line)
|
||||||
|
return line
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redraw(buf: list[str], cursor: int, prompt: str) -> None:
|
||||||
|
# Suppress terminal escape codes during tests
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# History buffer basics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestHistoryGrowsDynamically:
|
||||||
|
def test_single_entry(self):
|
||||||
|
h = FakeInputHistory("hello" + ENTER)
|
||||||
|
assert h.input() == "hello"
|
||||||
|
assert h._history == ["hello"]
|
||||||
|
|
||||||
|
def test_multiple_entries(self):
|
||||||
|
h = FakeInputHistory()
|
||||||
|
for cmd in ["north", "take lamp", "inventory"]:
|
||||||
|
h.feed(cmd + ENTER)
|
||||||
|
h.input()
|
||||||
|
assert h._history == ["north", "take lamp", "inventory"]
|
||||||
|
|
||||||
|
def test_empty_input_not_stored(self):
|
||||||
|
h = FakeInputHistory(ENTER)
|
||||||
|
assert h.input() == ""
|
||||||
|
assert h._history == []
|
||||||
|
|
||||||
|
def test_grows_unbounded(self):
|
||||||
|
h = FakeInputHistory()
|
||||||
|
for i in range(500):
|
||||||
|
h.feed(f"cmd{i}" + ENTER)
|
||||||
|
h.input()
|
||||||
|
assert len(h._history) == 500
|
||||||
|
assert h._history[0] == "cmd0"
|
||||||
|
assert h._history[499] == "cmd499"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Up/Down arrow navigation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestUpDownNavigation:
|
||||||
|
def _make_history(self, *commands: str) -> FakeInputHistory:
|
||||||
|
h = FakeInputHistory()
|
||||||
|
for cmd in commands:
|
||||||
|
h.feed(cmd + ENTER)
|
||||||
|
h.input()
|
||||||
|
return h
|
||||||
|
|
||||||
|
def test_up_recalls_last_command(self):
|
||||||
|
h = self._make_history("look", "north")
|
||||||
|
h.feed(UP + ENTER)
|
||||||
|
assert h.input() == "north"
|
||||||
|
|
||||||
|
def test_up_twice_recalls_older(self):
|
||||||
|
h = self._make_history("look", "north", "take lamp")
|
||||||
|
h.feed(UP + UP + ENTER)
|
||||||
|
assert h.input() == "north"
|
||||||
|
|
||||||
|
def test_up_then_down_returns_to_newer(self):
|
||||||
|
h = self._make_history("look", "north")
|
||||||
|
h.feed(UP + UP + DOWN + ENTER)
|
||||||
|
assert h.input() == "north"
|
||||||
|
|
||||||
|
def test_down_past_end_restores_current_input(self):
|
||||||
|
h = self._make_history("look")
|
||||||
|
# Type "hel", arrow up (recalls "look"), arrow down (restores "hel")
|
||||||
|
h.feed("hel" + UP + DOWN + ENTER)
|
||||||
|
assert h.input() == "hel"
|
||||||
|
|
||||||
|
def test_up_at_top_stays_at_oldest(self):
|
||||||
|
h = self._make_history("only")
|
||||||
|
h.feed(UP + UP + UP + ENTER)
|
||||||
|
assert h.input() == "only"
|
||||||
|
|
||||||
|
def test_down_with_no_history_is_noop(self):
|
||||||
|
h = FakeInputHistory(DOWN + "hello" + ENTER)
|
||||||
|
assert h.input() == "hello"
|
||||||
|
|
||||||
|
def test_up_with_no_history_is_noop(self):
|
||||||
|
h = FakeInputHistory(UP + "hello" + ENTER)
|
||||||
|
assert h.input() == "hello"
|
||||||
|
|
||||||
|
def test_browse_full_history(self):
|
||||||
|
h = self._make_history("a", "b", "c")
|
||||||
|
# Go all the way up (c -> b -> a), then submit
|
||||||
|
h.feed(UP + UP + UP + ENTER)
|
||||||
|
assert h.input() == "a"
|
||||||
|
|
||||||
|
def test_recalled_command_added_to_history(self):
|
||||||
|
h = self._make_history("look")
|
||||||
|
h.feed(UP + ENTER)
|
||||||
|
result = h.input()
|
||||||
|
assert result == "look"
|
||||||
|
# "look" is now in history twice (original + re-submitted)
|
||||||
|
assert h._history == ["look", "look"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Line editing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLineEditing:
|
||||||
|
def test_backspace_deletes_char(self):
|
||||||
|
h = FakeInputHistory("helloo" + BACKSPACE + ENTER)
|
||||||
|
assert h.input() == "hello"
|
||||||
|
|
||||||
|
def test_backspace_at_start_is_noop(self):
|
||||||
|
h = FakeInputHistory(BACKSPACE + "hi" + ENTER)
|
||||||
|
assert h.input() == "hi"
|
||||||
|
|
||||||
|
def test_left_arrow_and_insert(self):
|
||||||
|
# Type "hllo", left 3 times to position after 'h', insert 'e'
|
||||||
|
h = FakeInputHistory("hllo" + LEFT + LEFT + LEFT + "e" + ENTER)
|
||||||
|
assert h.input() == "hello"
|
||||||
|
|
||||||
|
def test_right_arrow(self):
|
||||||
|
# Type "hllo", left 3, right 1, insert 'e' (between l and l)
|
||||||
|
h = FakeInputHistory("hllo" + LEFT + LEFT + LEFT + RIGHT + "e" + ENTER)
|
||||||
|
assert h.input() == "hlelo"
|
||||||
|
|
||||||
|
def test_home_moves_to_start(self):
|
||||||
|
h = FakeInputHistory("ello" + HOME + "h" + ENTER)
|
||||||
|
assert h.input() == "hello"
|
||||||
|
|
||||||
|
def test_end_moves_to_end(self):
|
||||||
|
h = FakeInputHistory("hel" + HOME + END + "lo" + ENTER)
|
||||||
|
assert h.input() == "hello"
|
||||||
|
|
||||||
|
def test_delete_key(self):
|
||||||
|
# Type "hello", left twice (cursor before 'l' 'o'), delete removes 'l'
|
||||||
|
h = FakeInputHistory("hello" + LEFT + LEFT + DELETE + ENTER)
|
||||||
|
assert h.input() == "helo"
|
||||||
|
|
||||||
|
def test_delete_at_end_is_noop(self):
|
||||||
|
h = FakeInputHistory("hi" + DELETE + ENTER)
|
||||||
|
assert h.input() == "hi"
|
||||||
|
|
||||||
|
def test_backspace_mid_line(self):
|
||||||
|
# Type "helxlo", left 2 (before 'l' 'o'), backspace removes 'x'...
|
||||||
|
# Actually: "helxlo" then LEFT LEFT puts cursor before "lo", backspace removes 'x'
|
||||||
|
h = FakeInputHistory("helxlo" + LEFT + LEFT + BACKSPACE + ENTER)
|
||||||
|
assert h.input() == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ctrl-D / Ctrl-C
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestControlKeys:
|
||||||
|
def test_ctrl_d_on_empty_raises_eof(self):
|
||||||
|
h = FakeInputHistory(CTRL_D)
|
||||||
|
with pytest.raises(EOFError):
|
||||||
|
h.input()
|
||||||
|
|
||||||
|
def test_ctrl_d_with_content_is_ignored(self):
|
||||||
|
h = FakeInputHistory("hi" + CTRL_D + ENTER)
|
||||||
|
assert h.input() == "hi"
|
||||||
|
|
||||||
|
def test_ctrl_c_raises_keyboard_interrupt(self):
|
||||||
|
h = FakeInputHistory(CTRL_C)
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
h.input()
|
||||||
Reference in New Issue
Block a user