Last active
May 10, 2025 07:59
-
-
Save ddkasa/7dd108d1f49817b17bb75663c63751b4 to your computer and use it in GitHub Desktop.
Conway's Game of Life in Textual
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""Simple Conway's Game of Life built with Textual | |
Requires: | |
textual >= 3.1 | |
textual_hires_canvas >= 0.7 | |
""" | |
from __future__ import annotations | |
import random | |
from typing import ClassVar, Final, Literal, get_args | |
from textual import on | |
from textual.app import App, ComposeResult | |
from textual.containers import Center, HorizontalGroup, Middle | |
from textual.coordinate import Coordinate | |
from textual.events import MouseDown, MouseEvent, MouseMove | |
from textual.events import Timer as Tick | |
from textual.geometry import Size | |
from textual.validation import Integer | |
from textual.widgets import Button, Input, Label, Switch | |
from textual_hires_canvas import Canvas, HiResMode | |
HalfBlock = Literal[" ", "▀", "▄", "█"] | |
HALFBLOCKS = tuple(get_args(HalfBlock)) | |
EMPTY, TOP, BOT, FULL = HALFBLOCKS | |
class Point(Coordinate): | |
def __add__(self, other: Point | tuple[int, int]) -> Point: # type: ignore[override] | |
if isinstance(other, Point | tuple): | |
row, col = other | |
return Point(self.row + row, self.column + col) | |
return NotImplemented | |
TOP_DIRECTIONS: Final = ( | |
(Point(-1, 0), frozenset({FULL, BOT, TOP}), True), # Left | |
(Point(-1, -1), frozenset({FULL, BOT}), False), # Top Left | |
(Point(0, -1), frozenset({FULL, BOT}), False), # Top | |
(Point(1, -1), frozenset({FULL, BOT}), False), # Top Right | |
(Point(1, 0), frozenset({FULL, BOT, TOP}), True), # Right | |
) | |
BOT_DIRECTIONS: Final = ( | |
(Point(1, 0), frozenset({FULL, BOT, TOP}), True), # Right | |
(Point(1, 1), frozenset({FULL, TOP}), False), # Bottom Right | |
(Point(0, 1), frozenset({FULL, TOP}), False), # Bottom | |
(Point(-1, 1), frozenset({FULL, TOP}), False), # Bottom Left | |
(Point(-1, 0), frozenset({FULL, BOT, TOP}), True), # Left | |
) | |
class ConwayCanvas(Canvas, can_focus=True): | |
INIT_SIZE = Size(80, 40) | |
DEFAULT_WEIGHT: ClassVar[int] = 50 | |
DEFAULT_CSS = """\ | |
ConwayCanvas { | |
height: auto; | |
width: auto; | |
border: tab $secondary; | |
border-title-style: bold; | |
border-title-align: center; | |
&:focus { | |
border: tab $primary; | |
} | |
} | |
""" | |
def __init__( | |
self, | |
name: str | None = None, | |
id: str | None = None, | |
classes: str | None = None, | |
*, | |
disabled: bool = False, | |
) -> None: | |
super().__init__( | |
*self.INIT_SIZE, | |
HiResMode.HALFBLOCK, | |
name=name, | |
id=id, | |
classes=classes, | |
disabled=disabled, | |
) | |
def on_mount(self) -> None: | |
self.height = self.INIT_SIZE.height * 2 | |
self.width = self.INIT_SIZE.width | |
self.reset() | |
self.setup_game() | |
self.timer = self.set_interval(1 / 30) | |
self.border_title = "Conway's Game of Life" | |
def on_show(self) -> None: | |
self.timer.resume() | |
def on_hide(self) -> None: | |
self.timer.pause() | |
def _find_alive_neighbors( | |
self, | |
pt: Point, | |
pixel: HalfBlock, | |
pixels: list[list[HalfBlock]], | |
*, | |
is_top: bool, | |
) -> int: | |
width, height = self.INIT_SIZE | |
DIRECTIONS = TOP_DIRECTIONS if is_top else BOT_DIRECTIONS | |
neighbor_count = int(pixel == FULL or pixel == (BOT if is_top else TOP)) | |
for direction, targets, full in DIRECTIONS: | |
x, y = pt + direction | |
if ( | |
0 <= x < width | |
and 0 <= y < height | |
and (neighbor := pixels[y][x]) in targets | |
): | |
neighbor_count += 2 if full and neighbor == FULL else 1 | |
return neighbor_count | |
@on(Tick) | |
def play_game(self, event: Tick) -> None: | |
width, height = self.INIT_SIZE | |
get_pixel = self.get_pixel | |
find_neighbors = self._find_alive_neighbors | |
is_alive = self.is_alive | |
pixels = [[get_pixel(x, y)[0] for x in range(width)] for y in range(height)] | |
top, bot, full, clear = [], [], [], [] | |
add_top = top.append | |
add_bot = bot.append | |
add_full = full.append | |
remove = clear.append | |
for y in range(height): | |
row = pixels[y] | |
for x in range(width): | |
pixel = row[x] | |
top_neighbors = find_neighbors(Point(x, y), pixel, pixels, is_top=True) | |
top_alive = is_alive(pixel, top_neighbors, is_top=True) | |
bot_neighbors = find_neighbors(Point(x, y), pixel, pixels, is_top=False) | |
bot_alive = is_alive(pixel, bot_neighbors, is_top=False) | |
if top_alive and bot_alive: | |
if pixel != FULL: | |
add_full((x, y)) | |
elif top_alive: | |
if pixel != TOP: | |
add_top((x, y)) | |
elif bot_alive: | |
if pixel != BOT: | |
add_bot((x, y)) | |
elif pixel != EMPTY: | |
remove((x, y)) | |
self.set_pixels(top, TOP) | |
self.set_pixels(bot, BOT) | |
self.set_pixels(full, FULL) | |
self.set_pixels(clear, EMPTY) | |
self.border_subtitle = f"Iteration {event.count}" | |
def is_alive(self, pixel: HalfBlock, neighbors: int, *, is_top: bool) -> bool: | |
if ( | |
(pixel == EMPTY) | |
or (is_top and pixel == BOT) | |
or (not is_top and pixel == TOP) | |
): | |
return neighbors == 3 | |
return 1 < neighbors < 4 | |
CHOICES: ClassVar[tuple[str, ...]] = (TOP, BOT, FULL) | |
def setup_game( | |
self, | |
seed: str | None = None, | |
weight: int | None = None, | |
) -> None: | |
random.seed(seed) | |
weight = weight or self.DEFAULT_WEIGHT | |
if weight == 100: | |
self.reset(self.INIT_SIZE) | |
return | |
width, height = self.INIT_SIZE | |
top, bot, full = set(), set(), set() | |
revive_top = top.add | |
revive_bot = bot.add | |
revive_all = full.add | |
for y in range(height): | |
for x in range(width): | |
if random.random() > weight: | |
coord = (x, y) | |
create = random.choice(self.CHOICES) | |
if create == TOP: | |
revive_top(coord) | |
elif create == BOT: | |
revive_bot(coord) | |
else: | |
revive_all(coord) | |
self.set_pixels(top, TOP) | |
self.set_pixels(bot, BOT) | |
self.set_pixels(full, FULL) | |
def _click_pixel(self, event: MouseEvent) -> None: | |
if event.get_content_offset(self): | |
x, y = event.x - 1, event.y - 1 | |
is_top = (event.pointer_y - event.y) <= 0.5 | |
pixel = self.get_pixel(x, y)[0] | |
if event.ctrl: | |
if pixel == TOP: | |
self.set_pixel(x, y, EMPTY if is_top else TOP) | |
elif pixel == BOT: | |
self.set_pixel(x, y, BOT if is_top else EMPTY) | |
elif pixel == FULL: | |
self.set_pixel(x, y, BOT if is_top else TOP) | |
elif pixel == EMPTY: | |
self.set_pixel(x, y, TOP if is_top else BOT) | |
elif pixel == TOP: | |
self.set_pixel(x, y, TOP if is_top else FULL) | |
elif pixel == BOT: | |
self.set_pixel(x, y, FULL if is_top else BOT) | |
def on_mouse_down(self, event: MouseDown) -> None: | |
self.capture_mouse() | |
self._click_pixel(event) | |
def on_mouse_move(self, event: MouseMove) -> None: | |
if self.app.mouse_captured == self: | |
self._click_pixel(event) | |
def on_mouse_up(self) -> None: | |
self.capture_mouse(False) | |
def reset_timer(self) -> None: | |
self.reset(self.INIT_SIZE) | |
self.timer.reset() | |
self.timer.pause() | |
self.border_subtitle = "Iteration 1" | |
def get_content_height(self, container: Size, viewport: Size, width: int) -> int: | |
return self._canvas_size.height | |
def get_content_width(self, container: Size, viewport: Size) -> int: | |
return self._canvas_size.width | |
class ConwayApp(App[None]): | |
CSS = """\ | |
Center { | |
height: 1fr; | |
} | |
HorizontalGroup { | |
height: 1; | |
& > Widget { | |
margin: 0 2; | |
} | |
Input { | |
width: 1fr; | |
padding: 0 2; | |
} | |
Label { | |
text-style: bold; | |
padding: 0 2; | |
margin: 0; | |
} | |
Switch { | |
margin-left: 1; | |
border: none; | |
} | |
} | |
""" | |
BINDINGS = [("space", "play")] | |
def compose(self) -> ComposeResult: | |
with Center(), Middle(): | |
yield ConwayCanvas() | |
with HorizontalGroup(id="Controls"): | |
yield Label("Toggle Life[i]\[space][/]", variant="secondary") | |
yield Switch(value=True, tooltip="Toggle Life") | |
yield Button("Clear Canvas", "error", id="clear", compact=True) | |
yield Input("", "Seed", valid_empty=True, compact=True, id="seed") | |
yield Input( | |
"50", | |
"Empty Percentage", | |
validate_on=["changed"], | |
valid_empty=False, | |
validators=Integer(1, 100, "Must be an integer between 1 and 100"), | |
compact=True, | |
id="weight", | |
tooltip="How much area should be initially empty.", | |
) | |
yield Input( | |
"30", | |
"Frame Rate", | |
validate_on=["changed"], | |
valid_empty=False, | |
validators=Integer(1, 60, "Must be an integer between 1 and 60"), | |
compact=True, | |
id="fps", | |
tooltip="Refreshes per second.", | |
) | |
def on_button_pressed(self) -> None: | |
self.query_one(ConwayCanvas).reset_timer() | |
with (switch := self.query_one(Switch)).prevent(Switch.Changed): | |
switch.value = False | |
def on_switch_changed(self, event: Switch.Changed) -> None: | |
timer = self.query_one(ConwayCanvas).timer | |
if event.value: | |
timer.resume() | |
else: | |
timer.pause() | |
@on(Input.Changed, "#seed") | |
def update_seed(self, event: Input.Submitted) -> None: | |
weight = int(self.query_one("#weight", Input).value) / 100 | |
canvas = self.query_one(ConwayCanvas) | |
canvas.timer.reset() | |
canvas.setup_game(event.value, weight) | |
with (switch := self.query_one(Switch)).prevent(Switch.Changed): | |
switch.value = True | |
@on(Input.Changed, "#fps") | |
def update_frame_rate(self, event: Input.Submitted) -> None: | |
if not event.input.is_valid and event.validation_result: | |
self.notify( | |
str(event.validation_result.failure_descriptions[0]), | |
severity="warning", | |
) | |
return | |
canvas = self.query_one(ConwayCanvas) | |
canvas.timer.stop() | |
canvas.timer = canvas.set_interval(1 / int(event.value)) | |
@on(Input.Changed, "#weight") | |
def update_weight(self, event: Input.Changed) -> None: | |
if not event.input.is_valid and event.validation_result: | |
self.notify( | |
str(event.validation_result.failure_descriptions[0]), | |
severity="warning", | |
) | |
return | |
seed = self.query_one("#seed", Input).value | |
canvas = self.query_one(ConwayCanvas) | |
canvas.timer.reset() | |
canvas.setup_game(seed, int(event.value) / 100) | |
with (switch := self.query_one(Switch)).prevent(Switch.Changed): | |
switch.value = True | |
def action_play(self) -> None: | |
switch = self.query_one(Switch) | |
switch.value = not switch.value | |
if __name__ == "__main__": | |
ConwayApp().run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment