From da48b1c6428b40c87c77d9535613723da2f3527d Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Mon, 9 Jun 2025 15:15:26 +0200 Subject: [PATCH] feat: Created tui --- game/main.py | 5 +- game/tests/cli/__init__.py | 0 game/tests/{ => cli}/cli.py | 14 +++- game/tests/cli/expected_display.txt | 7 ++ game/tests/{ => cli}/test_map.txt | 0 game/utils/map.py | 18 +---- game/utils/tui.py | 71 +++++++++++++++++ pyproject.toml | 1 + uv.lock | 113 ++++++++++++++++++++++++++++ 9 files changed, 211 insertions(+), 18 deletions(-) create mode 100644 game/tests/cli/__init__.py rename game/tests/{ => cli}/cli.py (71%) create mode 100644 game/tests/cli/expected_display.txt rename game/tests/{ => cli}/test_map.txt (100%) create mode 100644 game/utils/tui.py diff --git a/game/main.py b/game/main.py index 85a46c0..8059ed1 100644 --- a/game/main.py +++ b/game/main.py @@ -1,6 +1,7 @@ import typer from game.utils.map import Map +from game.utils.tui import run app = typer.Typer() @@ -21,14 +22,14 @@ def main( game_map = Map() if map_grid is not None: game_map.load_from_str(map_grid) - game_map.run() + run(game_map) @app.command() def from_file(file_path: str): game_map = Map() game_map.load_from_file(file_path) - game_map.run() + run(game_map) if __name__ == "__main__": diff --git a/game/tests/cli/__init__.py b/game/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/tests/cli.py b/game/tests/cli/cli.py similarity index 71% rename from game/tests/cli.py rename to game/tests/cli/cli.py index f1ddeca..a60ab12 100644 --- a/game/tests/cli.py +++ b/game/tests/cli/cli.py @@ -1,4 +1,5 @@ from game.utils.map import Map +from game.utils.tui import prepare_display def test_from_arg(): @@ -32,6 +33,17 @@ def test_from_arg_and_from_file_are_identical(): game_map_from_arg.load_from_str("..... .### # ....# .") game_map_from_file = Map() - game_map_from_file.load_from_file("game/tests/test_map.txt") + game_map_from_file.load_from_file("game/tests/cli/test_map.txt") assert game_map_from_arg.map == game_map_from_file.map + + +def test_tui(): + game_map = Map() + game_map.load_from_file("game/tests/cli/test_map.txt") + + display = prepare_display(game_map, state={"paused": False}) + + expected_display = open("game/tests/cli/expected_display.txt").read() + + assert display == expected_display diff --git a/game/tests/cli/expected_display.txt b/game/tests/cli/expected_display.txt new file mode 100644 index 0000000..8d5dfd4 --- /dev/null +++ b/game/tests/cli/expected_display.txt @@ -0,0 +1,7 @@ +Generation: 0 Population: 5 Press (p/r/q) to pause/restart game/quit. + +. . . . . +. # # # . +# . . . . +. . . . # +. . . . . diff --git a/game/tests/test_map.txt b/game/tests/cli/test_map.txt similarity index 100% rename from game/tests/test_map.txt rename to game/tests/cli/test_map.txt diff --git a/game/utils/map.py b/game/utils/map.py index 5e93ba3..2c8f862 100644 --- a/game/utils/map.py +++ b/game/utils/map.py @@ -1,9 +1,3 @@ -import time - -from rich.console import Console -from rich.live import Live - - class AbstractMap: """ Abstract class for the map. @@ -87,6 +81,7 @@ def __str__(self) -> str: map_str = "" for row in self.map: map_str += "".join(["# " if cell else ". " for cell in row]) + map_str = map_str.strip() map_str += "\n" return map_str @@ -114,12 +109,13 @@ def load_from_rows(self, rows: list[str]): self.map = [[False for _ in range(self.number_of_columns)] for _ in range(self.number_of_rows)] for y, line in enumerate(rows): for x, char in enumerate(line.strip()): - print(x, y, char, line) if char == "#": self.map[y][x] = True elif char != ".": raise ValueError(f"Invalid character '{char}' in map string.") + self.population = self.count_population() + def next_generation(self): new_map = [[False for _ in range(self.number_of_columns)] for _ in range(self.number_of_rows)] population = 0 @@ -140,11 +136,3 @@ def next_generation(self): self.map = new_map self.generation += 1 self.population = population - - def run(self): - console = Console() - with Live(self.__str__(), refresh_per_second=5, console=console) as live: - while self.population > 0: - self.next_generation() - live.update(self.__str__()) - time.sleep(0.1) # small pause between generations diff --git a/game/utils/tui.py b/game/utils/tui.py new file mode 100644 index 0000000..73f0d22 --- /dev/null +++ b/game/utils/tui.py @@ -0,0 +1,71 @@ +import threading +from copy import deepcopy +from time import sleep + +from pynput import keyboard +from rich.console import Console +from rich.live import Live + +from game.utils.map import Map + + +def prepare_display(game_map: Map, state: dict) -> str: + display_str = "" + display_str += ( + f"Generation: {game_map.generation} Population: {game_map.population}" + f" Press (p/r/q) to pause/restart game/quit.\n" + ) + if state["paused"]: + display_str += "Paused\n\n" + else: + display_str += "\n" + + display_str += str(game_map) + + return display_str + + +def detect_key_input(state: dict, lock): + def on_press(key): + try: + if key.char == "p": + with lock: + state["paused"] = not state["paused"] + elif key.char == "r": + with lock: + state["restart"] = True + elif key.char == "q": + with lock: + state["quit"] = True + return + except AttributeError: + pass + + with keyboard.Listener(on_press=on_press) as listener: + listener.join() + + +def run(game_map: Map): + console = Console() + starting_map = deepcopy(game_map) + state = {"paused": False, "restart": False, "quit": False} + lock = threading.Lock() + + key_listener = threading.Thread(target=detect_key_input, args=(state, lock), daemon=True) + key_listener.start() + + with Live(prepare_display(game_map, state), refresh_per_second=5, console=console) as live: + while game_map.population > 0: + with lock: + if state["quit"]: + return + if state["restart"]: + game_map = deepcopy(starting_map) + live.update(prepare_display(game_map, state)) + state["restart"] = False + + if not state["paused"]: + game_map.next_generation() + + live.update(prepare_display(game_map, state)) + sleep(0.1) diff --git a/pyproject.toml b/pyproject.toml index ade3023..e42bd56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "pytest>=8.4.0", "ruff>=0.11.13", "typer>=0.16.0", + "pynput>=1.8.1" ] [build-system] diff --git a/uv.lock b/uv.lock index 33f316f..1dd0ced 100644 --- a/uv.lock +++ b/uv.lock @@ -41,6 +41,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, ] +[[package]] +name = "evdev" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/fe/a17c106a1f4061ce83f04d14bcedcfb2c38c7793ea56bfb906a6fadae8cb/evdev-1.9.2.tar.gz", hash = "sha256:5d3278892ce1f92a74d6bf888cc8525d9f68af85dbe336c95d1c87fb8f423069", size = 33301, upload-time = "2025-05-01T19:53:47.69Z" } + [[package]] name = "filelock" version = "3.17.0" @@ -56,6 +62,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "pre-commit" }, + { name = "pynput" }, { name = "pytest" }, { name = "ruff" }, { name = "typer" }, @@ -69,6 +76,7 @@ test = [ [package.metadata] requires-dist = [ { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "pynput", specifier = ">=1.8.1" }, { name = "pytest", specifier = ">=8.4.0" }, { name = "pytest", marker = "extra == 'test'" }, { name = "ruff", specifier = ">=0.11.13" }, @@ -176,6 +184,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pynput" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "evdev", marker = "'linux' in sys_platform" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "'linux' in sys_platform" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/c3/dccf44c68225046df5324db0cc7d563a560635355b3e5f1d249468268a6f/pynput-1.8.1.tar.gz", hash = "sha256:70d7c8373ee98911004a7c938742242840a5628c004573d84ba849d4601df81e", size = 82289, upload-time = "2025-03-17T17:12:01.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl", hash = "sha256:42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2", size = 91693, upload-time = "2025-03-17T17:12:00.094Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/94/a111239b98260869780a5767e5d74bfd3a8c13a40457f479c28dcd91f89d/pyobjc_core-11.0.tar.gz", hash = "sha256:63bced211cb8a8fb5c8ff46473603da30e51112861bd02c438fbbbc8578d9a70", size = 994931, upload-time = "2025-01-14T19:02:13.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/16/0c468e73dbecb821e3da8819236fe832dfc53eb5f66a11775b055a7589ea/pyobjc_core-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c338c1deb7ab2e9436d4175d1127da2eeed4a1b564b3d83b9f3ae4844ba97e86", size = 743900, upload-time = "2025-01-14T18:46:54.654Z" }, + { url = "https://files.pythonhosted.org/packages/f3/88/cecec88fd51f62a6cd7775cc4fb6bfde16652f97df88d28c84fb77ca0c18/pyobjc_core-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b4e9dc4296110f251a4033ff3f40320b35873ea7f876bd29a1c9705bb5e08c59", size = 791905, upload-time = "2025-01-14T18:46:56.473Z" }, +] + +[[package]] +name = "pyobjc-framework-applicationservices" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coretext" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/fb/4e42573b0d3baa3fa18ec53614cf979f951313f1451e8f2e17df9429da1f/pyobjc_framework_applicationservices-11.0.tar.gz", hash = "sha256:d6ea18dfc7d5626a3ecf4ac72d510405c0d3a648ca38cae8db841acdebecf4d2", size = 224334, upload-time = "2025-01-14T19:02:26.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/47/ab4155ec966aff2f8f0f6978b40f12255e8ef46111ca0bda7987959b4052/pyobjc_framework_ApplicationServices-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:59becf3cd87a4f4cedf4be02ff6cf46ed736f5c1123ce629f788aaafad91eff0", size = 30924, upload-time = "2025-01-14T18:48:08.165Z" }, + { url = "https://files.pythonhosted.org/packages/a3/73/747aab95970e0b7b5d38c650028e5e034c0432d9451335ff790ca104f11a/pyobjc_framework_ApplicationServices-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:44b466e8745fb49e8ac20f29f2ffd7895b45e97aa63a844b2a80a97c3a34346f", size = 31279, upload-time = "2025-01-14T18:48:09.112Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/32/53809096ad5fc3e7a2c5ddea642590a5f2cb5b81d0ad6ea67fdb2263d9f9/pyobjc_framework_cocoa-11.0.tar.gz", hash = "sha256:00346a8cb81ad7b017b32ff7bf596000f9faa905807b1bd234644ebd47f692c5", size = 6173848, upload-time = "2025-01-14T19:03:00.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/a5/609281a7e89efefbef9db1d8fe66bc0458c3b4e74e2227c644f9c18926fa/pyobjc_framework_Cocoa-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:15b2bd977ed340074f930f1330f03d42912d5882b697d78bd06f8ebe263ef92e", size = 385889, upload-time = "2025-01-14T18:49:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/93/f6/2d5a863673ef7b85a3cba875c43e6c495fb1307427a6801001ae94bb5e54/pyobjc_framework_Cocoa-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5750001db544e67f2b66f02067d8f0da96bb2ef71732bde104f01b8628f9d7ea", size = 389831, upload-time = "2025-01-14T18:49:31.963Z" }, +] + +[[package]] +name = "pyobjc-framework-coretext" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/e8/9b68dc788828e38143a3e834e66346713751cb83d7f0955016323005c1a2/pyobjc_framework_coretext-11.0.tar.gz", hash = "sha256:a68437153e627847e3898754dd3f13ae0cb852246b016a91f9c9cbccb9f91a43", size = 274222, upload-time = "2025-01-14T19:03:21.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/f0/53b681481e9429e8f9ac2c039da6a820d7417ca92f763f01d629db36c530/pyobjc_framework_CoreText-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7947f755782456bd663e0b00c7905eeffd10f839f0bf2af031f68ded6a1ea360", size = 30453, upload-time = "2025-01-14T18:51:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/2a/3f/a6d09952e83d70be6d337a5f1d457018459a57a110a91c3e771a2f2a7de0/pyobjc_framework_CoreText-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5356116bae33ec49f1f212c301378a7d08000440a2d6a7281aab351945528ab9", size = 31092, upload-time = "2025-01-14T18:51:39.423Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/ad/f00f3f53387c23bbf4e0bb1410e11978cbf87c82fa6baff0ee86f74c5fb6/pyobjc_framework_quartz-11.0.tar.gz", hash = "sha256:3205bf7795fb9ae34747f701486b3db6dfac71924894d1f372977c4d70c3c619", size = 3952463, upload-time = "2025-01-14T19:05:07.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/9e/54c48fe8faab06ee5eb80796c8c17ec61fc313d84398540ee70abeaf7070/pyobjc_framework_Quartz-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:973b4f9b8ab844574461a038bd5269f425a7368d6e677e3cc81fcc9b27b65498", size = 212478, upload-time = "2025-01-14T18:58:11.491Z" }, + { url = "https://files.pythonhosted.org/packages/4a/28/456b54a59bfe11a91b7b4e94f8ffdcf174ffd1efa169f4283e5b3bc10194/pyobjc_framework_Quartz-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:66ab58d65348863b8707e63b2ec5cdc54569ee8189d1af90d52f29f5fdf6272c", size = 217973, upload-time = "2025-01-14T18:58:12.739Z" }, +] + [[package]] name = "pytest" version = "8.4.0" @@ -192,6 +284,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, ] +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -256,6 +360,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "typer" version = "0.16.0"