Skip to content

Instantly share code, notes, and snippets.

@ddkasa
Last active May 10, 2025 07:59
Show Gist options
  • Save ddkasa/7dd108d1f49817b17bb75663c63751b4 to your computer and use it in GitHub Desktop.
Save ddkasa/7dd108d1f49817b17bb75663c63751b4 to your computer and use it in GitHub Desktop.
Conway's Game of Life in Textual
"""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