Skip to content

Instantly share code, notes, and snippets.

@davep
Created October 6, 2022 20:10
Show Gist options
  • Save davep/37143f5cb913c1992aa16d6eff6f6ad0 to your computer and use it in GitHub Desktop.
Save davep/37143f5cb913c1992aa16d6eff6f6ad0 to your computer and use it in GitHub Desktop.
Screen {
layout: vertical;
background: #444444
}
Horizontal {
height: 20%
}
GameHeader {
background: red;
color: white;
height: 1;
dock: top;
}
GameHeader #app-title {
width: 60%;
}
GameHeader #moves {
width: 20%;
}
GameHeader #progress {
width: 20%;
}
Footer {
height: 1;
dock: bottom;
}
Button {
width: 20%;
height: 100%;
background: #440000;
border: none
}
Button:hover {
background: #550000
}
Button.on {
background: #bb0000
}
Button.on:hover {
background: #dd0000
}
/* five_by_five.css ends here */
"""Simple version of 5x5, developed for/with Textual.
5x5 is one of my little go-to problems to help test new development
environments and tools, especially those that are very visual. See
http://5x5.surge.sh/ as an example of the game. Versions I've written
include:
https://github.com/davep/5x5.xml
https://github.com/davep/Chrome-5x5
https://github.com/davep/5x5-Palm
https://github.com/davep/5x5.el
https://github.com/davep/5x5-react
amongst others (they're just the ones that I still have code for and which
are on GitHub).
NOTE: For the moment docstrings and type annotations will be lacking or
possibly wrong. In part this is because, as of the time of writing, I've got
no docs to go on yet as to the full extent of the API or the types involved.
ALSO NOTE: The choices of widget, colour, styling, etc, are likely to not be
my ideal or final choice. Again, guesswork and example-reading are the guide
here; a lot of this will change when documentation is available.
"""
from pathlib import Path
from textual.containers import Horizontal
from textual.app import App, ComposeResult
from textual.widget import Widget
from textual.widgets import Footer, Button, Static
from textual.css.query import DOMQuery
class GameHeader(Widget):
"""Header for the game.
Comprises of the title (``#app-title``), the number of moves ``#moves``
and the count of how many cells are turned on (``#progress``).
"""
def compose(self) -> ComposeResult:
yield Horizontal(
Static(self.app.title, id="app-title"),
Static(id="moves"),
Static(id="progress"),
)
class FiveByFive(App[None]):
"""Main 5x5 application class."""
SIZE = 5
def on_load(self) -> None:
self.bind("r", "reset", description="Reset")
self.bind("q", "quit", description="Quit")
@property
def on_cells(self) -> DOMQuery:
return self.query("Button.on")
@property
def on_count(self) -> int:
return len(self.on_cells)
def new_game(self) -> None:
self.moves = 0
self.on_cells.remove_class("on")
self.toggle_cells(
self.query_one(f"#cell-{ self.SIZE // 2 }-{ self.SIZE // 2}", Button)
)
def compose(self) -> ComposeResult:
yield GameHeader()
for row in range(self.SIZE):
yield Horizontal(
*[Button("", id=f"cell-{row}-{col}") for col in range(self.SIZE)]
)
yield Footer()
# TODO: I suspect there's a problem here in that I should not be
# trying to tinker with the DOM inside compose. The setting up of a
# new game (and so flipping classes on the buttons) seems to work,
# but updating the game header doesn't because Textual claims it
# can't find the #moves Static yet. Presumably there's a hook/event
# that is "the DOM is ready, go have fun" but I've not found it yet.
self.new_game()
# ...hence this is commented out for now, and the above is suspect.
# self.refresh_state_of_play()
def refresh_state_of_play(self) -> None:
self.query_one("#moves", Static).update(f"Moves: {self.moves}")
self.query_one("#progress", Static).update(
"Winner!" if self.on_count == (self.SIZE**2) else f"On: {self.on_count}"
)
def toggle_cell(self, row: int, col: int) -> None:
"""Toggle an individual cell, but only if it's on bounds.
:param int row: The row of the cell to toggle.
:param int col: The column of the cell to toggle.
If the row and column would place the cell out of bounds for the
game grid, this function call is a no-op. That is, it's safe to call
it with an invalid cell coordinate.
"""
if 0 <= row <= (self.SIZE - 1) and 0 <= col <= (self.SIZE - 1):
self.query_one(f"Button#cell-{row}-{col}", Button).toggle_class("on")
def toggle_cells(self, cell: Button) -> None:
"""Toggle a 5x5 pattern around the given cell.
:param Button cell: The cell to toggle the buttons around.
"""
# Abusing the ID as a data- attribute too (or a cargo instance
# variable if you're old enough to have worked with Clipper).
# Textual doesn't have anything like it at the moment:
#
# https://twitter.com/davepdotorg/status/1555822341170597888
#
# but given the reply it may do at some point.
if cell.id:
row, col = map(int, cell.id.split("-")[1:])
self.toggle_cell(row - 1, col)
self.toggle_cell(row + 1, col)
self.toggle_cell(row, col)
self.toggle_cell(row, col - 1)
self.toggle_cell(row, col + 1)
def make_move_on(self, cell: Button) -> None:
self.toggle_cells(cell)
self.moves += 1
self.refresh_state_of_play()
def on_button_pressed(self, event: Button.Pressed) -> None:
self.make_move_on(event.button)
def action_reset(self) -> None:
self.new_game()
self.refresh_state_of_play()
if __name__ == "__main__":
FiveByFive(
title="5x5 -- A little annoying puzzle",
css_path=Path(__file__).with_suffix(".css"),
).run()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment