Skip to content

Commit 239d1bc

Browse files
committed
feat: Created tui
1 parent e3b7395 commit 239d1bc

File tree

9 files changed

+211
-18
lines changed

9 files changed

+211
-18
lines changed

game/main.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import typer
22

33
from game.utils.map import Map
4+
from game.utils.tui import run
45

56
app = typer.Typer()
67

@@ -21,14 +22,14 @@ def main(
2122
game_map = Map()
2223
if map_grid is not None:
2324
game_map.load_from_str(map_grid)
24-
game_map.run()
25+
run(game_map)
2526

2627

2728
@app.command()
2829
def from_file(file_path: str):
2930
game_map = Map()
3031
game_map.load_from_file(file_path)
31-
game_map.run()
32+
run(game_map)
3233

3334

3435
if __name__ == "__main__":

game/tests/cli/__init__.py

Whitespace-only changes.
Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from game.utils.map import Map
2+
from game.utils.tui import prepare_display
23

34

45
def test_from_arg():
@@ -32,6 +33,17 @@ def test_from_arg_and_from_file_are_identical():
3233
game_map_from_arg.load_from_str("..... .### # ....# .")
3334

3435
game_map_from_file = Map()
35-
game_map_from_file.load_from_file("game/tests/test_map.txt")
36+
game_map_from_file.load_from_file("game/tests/cli/test_map.txt")
3637

3738
assert game_map_from_arg.map == game_map_from_file.map
39+
40+
41+
def test_tui():
42+
game_map = Map()
43+
game_map.load_from_file("game/tests/cli/test_map.txt")
44+
45+
display = prepare_display(game_map, state={"paused": False})
46+
47+
expected_display = open("game/tests/cli/expected_display.txt").read()
48+
49+
assert display == expected_display
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Generation: 0 Population: 5 Press (p/r/q) to pause/restart game/quit.
2+
3+
. . . . .
4+
. # # # .
5+
# . . . .
6+
. . . . #
7+
. . . . .

game/utils/map.py

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
import time
2-
3-
from rich.console import Console
4-
from rich.live import Live
5-
6-
71
class AbstractMap:
82
"""
93
Abstract class for the map.
@@ -87,6 +81,7 @@ def __str__(self) -> str:
8781
map_str = ""
8882
for row in self.map:
8983
map_str += "".join(["# " if cell else ". " for cell in row])
84+
map_str = map_str.strip()
9085
map_str += "\n"
9186

9287
return map_str
@@ -114,12 +109,13 @@ def load_from_rows(self, rows: list[str]):
114109
self.map = [[False for _ in range(self.number_of_columns)] for _ in range(self.number_of_rows)]
115110
for y, line in enumerate(rows):
116111
for x, char in enumerate(line.strip()):
117-
print(x, y, char, line)
118112
if char == "#":
119113
self.map[y][x] = True
120114
elif char != ".":
121115
raise ValueError(f"Invalid character '{char}' in map string.")
122116

117+
self.population = self.count_population()
118+
123119
def next_generation(self):
124120
new_map = [[False for _ in range(self.number_of_columns)] for _ in range(self.number_of_rows)]
125121
population = 0
@@ -140,11 +136,3 @@ def next_generation(self):
140136
self.map = new_map
141137
self.generation += 1
142138
self.population = population
143-
144-
def run(self):
145-
console = Console()
146-
with Live(self.__str__(), refresh_per_second=5, console=console) as live:
147-
while self.population > 0:
148-
self.next_generation()
149-
live.update(self.__str__())
150-
time.sleep(0.1) # small pause between generations

game/utils/tui.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import threading
2+
from copy import deepcopy
3+
from time import sleep
4+
5+
from pynput import keyboard
6+
from rich.console import Console
7+
from rich.live import Live
8+
9+
from game.utils.map import Map
10+
11+
12+
def prepare_display(game_map: Map, state: dict) -> str:
13+
display_str = ""
14+
display_str += (
15+
f"Generation: {game_map.generation} Population: {game_map.population}"
16+
f" Press (p/r/q) to pause/restart game/quit.\n"
17+
)
18+
if state["paused"]:
19+
display_str += "Paused\n\n"
20+
else:
21+
display_str += "\n"
22+
23+
display_str += str(game_map)
24+
25+
return display_str
26+
27+
28+
def detect_key_input(state: dict, lock):
29+
def on_press(key):
30+
try:
31+
if key.char == "p":
32+
with lock:
33+
state["paused"] = not state["paused"]
34+
elif key.char == "r":
35+
with lock:
36+
state["restart"] = True
37+
elif key.char == "q":
38+
with lock:
39+
state["quit"] = True
40+
return
41+
except AttributeError:
42+
pass
43+
44+
with keyboard.Listener(on_press=on_press) as listener:
45+
listener.join()
46+
47+
48+
def run(game_map: Map):
49+
console = Console()
50+
starting_map = deepcopy(game_map)
51+
state = {"paused": False, "restart": False, "quit": False}
52+
lock = threading.Lock()
53+
54+
key_listener = threading.Thread(target=detect_key_input, args=(state, lock), daemon=True)
55+
key_listener.start()
56+
57+
with Live(prepare_display(game_map, state), refresh_per_second=5, console=console) as live:
58+
while game_map.population > 0:
59+
with lock:
60+
if state["quit"]:
61+
return
62+
if state["restart"]:
63+
game_map = deepcopy(starting_map)
64+
live.update(prepare_display(game_map, state))
65+
state["restart"] = False
66+
67+
if not state["paused"]:
68+
game_map.next_generation()
69+
70+
live.update(prepare_display(game_map, state))
71+
sleep(0.1)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies = [
88
"pytest>=8.4.0",
99
"ruff>=0.11.13",
1010
"typer>=0.16.0",
11+
"pynput>=1.8.1"
1112
]
1213

1314
[build-system]

uv.lock

Lines changed: 113 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)