From 91a02fda8e93673ea38b76a5b2e90382972b24fb Mon Sep 17 00:00:00 2001 From: Seppe De Loore Date: Sat, 4 Apr 2026 09:55:55 +0200 Subject: [PATCH] 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) --- h2g2/engine/input_history.py | 143 +++++++++++++++++++++++ h2g2/engine/loop.py | 4 +- tests/test_input_history.py | 212 +++++++++++++++++++++++++++++++++++ 3 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 h2g2/engine/input_history.py create mode 100644 tests/test_input_history.py diff --git a/h2g2/engine/input_history.py b/h2g2/engine/input_history.py new file mode 100644 index 0000000..1a35148 --- /dev/null +++ b/h2g2/engine/input_history.py @@ -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() diff --git a/h2g2/engine/loop.py b/h2g2/engine/loop.py index 896d667..2a6c118 100644 --- a/h2g2/engine/loop.py +++ b/h2g2/engine/loop.py @@ -8,6 +8,7 @@ from h2g2.engine.game_object import ( GameObject, Room, Flag, Direction, DirectExit, ConditionalExit, GatedExit, BlockedExit, ) +from h2g2.engine.input_history import InputHistory from h2g2.engine.parser import Parser, ParseResult from h2g2.engine.state import GameState from h2g2.engine.output import Output @@ -47,6 +48,7 @@ class GameLoop: def __init__(self, state: GameState, parser: Parser) -> None: self.state = state self.parser = parser + self._input = InputHistory() def run(self) -> None: """Run the main game loop until the game ends.""" @@ -59,7 +61,7 @@ class GameLoop: while state.running: try: - raw = input("\n> ") + raw = self._input.input("\n> ") except (EOFError, KeyboardInterrupt): print("\nGoodbye!") break diff --git a/tests/test_input_history.py b/tests/test_input_history.py new file mode 100644 index 0000000..5c66420 --- /dev/null +++ b/tests/test_input_history.py @@ -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()