From 06d1865accaf5fa26e068b097598f6dffcf5683d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 19 Oct 2022 16:47:25 +0100 Subject: [PATCH 01/18] Add 5x5 as an example *evil grin* --- examples/five_by_five.css | 84 +++++++++++++ examples/five_by_five.md | 17 +++ examples/five_by_five.py | 254 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 examples/five_by_five.css create mode 100644 examples/five_by_five.md create mode 100644 examples/five_by_five.py diff --git a/examples/five_by_five.css b/examples/five_by_five.css new file mode 100644 index 0000000000..6bebda34d4 --- /dev/null +++ b/examples/five_by_five.css @@ -0,0 +1,84 @@ +Game { + align: center middle; + layers: gameplay messages; +} + +GameGrid { + layout: grid; + grid-size: 5 5; + layer: gameplay; +} + +GameHeader { + background: $primary-background; + color: $text; + height: 1; + dock: top; + layer: gameplay; +} + +GameHeader #app-title { + width: 60%; +} + +GameHeader #moves { + width: 20%; +} + +GameHeader #progress { + width: 20%; +} + +Footer { + height: 1; + dock: bottom; + layer: gameplay; +} + +GameCell { + width: 100%; + height: 100%; + background: $surface; + border: round $surface-darken-1; +} + +GameCell:hover { + background: $panel-lighten-1; + border: round $panel; +} + +GameCell.on { + background: $secondary; + border: round $secondary-darken-1; +} + +GameCell.on:hover { + background: $secondary-lighten-1; + border: round $secondary; +} + +WinnerMessage { + width: 50%; + height: 25%; + layer: messages; + visibility: hidden; + content-align: center middle; + text-align: center; + background: $success; + color: $text; + border: round; + padding: 2; +} + +.visible { + visibility: visible; +} + +Help { + background: $primary; + color: $text; + border: round $primary-lighten-3; + padding: 2; +} + +/* five_by_five.css ends here */ diff --git a/examples/five_by_five.md b/examples/five_by_five.md new file mode 100644 index 0000000000..6fcc887bba --- /dev/null +++ b/examples/five_by_five.md @@ -0,0 +1,17 @@ +# 5x5 + +## Introduction + +An annoying puzzle for the terminal, built with +[Textual](https://www.textualize.io/). + +## Objective + +The object of the game is to fill all of the squares. When you click on a +square, it, and the squares above, below and to the sides will be toggled. + +It is possible to solve the puzzle in as few as 14 moves. + +Good luck! + +[//]: # (README.md ends here) diff --git a/examples/five_by_five.py b/examples/five_by_five.py new file mode 100644 index 0000000000..f00bec9896 --- /dev/null +++ b/examples/five_by_five.py @@ -0,0 +1,254 @@ +"""Simple version of 5x5, developed for/with Textual.""" + +from pathlib import Path +from typing import Final, cast + +from textual.containers import Horizontal +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widget import Widget +from textual.widgets import Footer, Button, Static +from textual.css.query import DOMQuery +from textual.reactive import reactive + +from rich.markdown import Markdown + + +class Help(Screen): + """The help screen for the application.""" + + #: Bindings for the help screen. + BINDINGS = [("esc,space,q,h,question_mark", "app.pop_screen", "Close")] + + def compose(self) -> ComposeResult: + """Compose the game's help.""" + yield Static(Markdown(Path(__file__).with_suffix(".md").read_text())) + + +class WinnerMessage(Static): + """Widget to tell the user they have won.""" + + #: The minimum number of moves you can solve the puzzle in. + MIN_MOVES: Final = 14 + + @staticmethod + def _plural(value: int) -> str: + return "" if value == 1 else "s" + + def show(self, moves: int) -> None: + """Show the winner message.""" + self.update( + "W I N N E R !\n\n\n" + f"You solved the puzzle in {moves} move{self._plural(moves)}." + + ( + ( + f" It is possible to solve the puzzle in {self.MIN_MOVES}, " + f"you were {moves - self.MIN_MOVES} move{self._plural(moves - self.MIN_MOVES)} over." + ) + if moves > self.MIN_MOVES + else " Well done! That's the minimum number of moves to solve the puzzle!" + ) + ) + self.add_class("visible") + + def hide(self) -> None: + """Hide the winner message.""" + self.remove_class("visible") + + +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``). + """ + + #: Keep track of how many moves the player has made. + moves = reactive(0) + + #: Keep track of how many cells are turned on. + on = reactive(0) + + def compose(self) -> ComposeResult: + """Compose the game header.""" + yield Horizontal( + Static(self.app.title, id="app-title"), + Static(id="moves"), + Static(id="progress"), + ) + + def watch_moves(self, moves: int): + """Watch the moves reactive and update when it changes.""" + self.query_one("#moves", Static).update(f"Moves: {moves}") + + def watch_on(self, on: int): + """Watch the on-count reactive and update when it changes.""" + self.query_one("#progress", Static).update(f"On: {on}") + + +class GameCell(Button): + """Individual playable cell in the game.""" + + @staticmethod + def at(row: int, col: int) -> str: + return f"cell-{row}-{col}" + + def __init__(self, row: int, col: int) -> None: + """Initialise the game cell.""" + super().__init__("", id=self.at(row, col)) + + +class GameGrid(Widget): + """The main playable grid of game cells.""" + + def compose(self) -> ComposeResult: + """Compose the game grid.""" + for row in range(Game.SIZE): + for col in range(Game.SIZE): + yield GameCell(row, col) + + +class Game(Screen): + """Main 5x5 game grid screen.""" + + #: The size of the game grid. Clue's in the name really. + SIZE = 5 + + #: The bindings for the main game grid. + BINDINGS = [ + ("n", "reset", "New Game"), + ("h,question_mark", "app.push_screen('help')", "Help"), + ("q", "quit", "Quit"), + ] + + @property + def on_cells(self) -> DOMQuery[GameCell]: + """The collection of cells that are currently turned on. + + :type: DOMQuery[GameCell] + """ + return cast(DOMQuery[GameCell], self.query("GameCell.on")) + + @property + def on_count(self) -> int: + """The number of cells that are turned on. + + :type: int + """ + return len(self.on_cells) + + @property + def all_on(self) -> bool: + """Are all the cells turned on? + + :type: bool + """ + return self.on_count == self.SIZE * self.SIZE + + def game_playable(self, playable: bool) -> None: + """Mark the game as playable, or not. + + :param bool playable: Should the game currently be playable? + """ + for cell in self.query(GameCell): + cell.disabled = not playable + + def new_game(self) -> None: + """Start a new game.""" + self.query_one(GameHeader).moves = 0 + self.on_cells.remove_class("on") + self.query_one(WinnerMessage).hide() + self.game_playable(True) + self.toggle_cells( + self.query_one(f"#{GameCell.at(self.SIZE // 2,self.SIZE // 2 )}", GameCell) + ) + + def compose(self) -> ComposeResult: + """Compose the application screen.""" + yield GameHeader() + yield GameGrid() + yield Footer() + yield WinnerMessage() + + 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"#{GameCell.at(row, col)}", GameCell).toggle_class("on") + + def toggle_cells(self, cell: GameCell) -> None: + """Toggle a 5x5 pattern around the given cell. + + :param GameCell cell: The cell to toggle the cells 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) + self.query_one(GameHeader).on = self.on_count + + def make_move_on(self, cell: GameCell) -> None: + """Make a move on the given cell. + + All relevant cells around the given cell are toggled as per the + game's rules. + """ + self.toggle_cells(cell) + self.query_one(GameHeader).moves += 1 + if self.all_on: + self.query_one(WinnerMessage).show(self.query_one(GameHeader).moves) + self.game_playable(False) + + def on_button_pressed(self, event: GameCell.Pressed) -> None: + """React to a press of a button on the game grid.""" + self.make_move_on(cast(GameCell, event.button)) + + def action_reset(self) -> None: + """Reset the game.""" + self.new_game() + + def on_mount(self) -> None: + """Get the game started when we first mount.""" + self.new_game() + + +class FiveByFive(App[None]): + """Main 5x5 application class.""" + + #: The name of the stylesheet for the app. + CSS_PATH = Path(__file__).with_suffix(".css") + + #: The pre-loaded screens for the application. + SCREENS = {"help": Help()} + + #: App-level bindings. + BINDINGS = [("d", "app.toggle_dark", "Toggle Dark Mode")] + + def __init__(self) -> None: + """Constructor.""" + super().__init__(title="5x5 -- A little annoying puzzle") + + def on_mount(self) -> None: + """Set up the application on startup.""" + self.push_screen(Game()) + + +if __name__ == "__main__": + FiveByFive().run() From 41bf93abf4da619cb2a35f2a61937363b724bf84 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 19 Oct 2022 20:10:08 +0100 Subject: [PATCH 02/18] Correct binding to the escape key --- examples/five_by_five.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index f00bec9896..403483c759 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -18,7 +18,7 @@ class Help(Screen): """The help screen for the application.""" #: Bindings for the help screen. - BINDINGS = [("esc,space,q,h,question_mark", "app.pop_screen", "Close")] + BINDINGS = [("escape,space,q,h,question_mark", "app.pop_screen", "Close")] def compose(self) -> ComposeResult: """Compose the game's help.""" From 056fb70e2a0847fed87c8e29842922e1dca0ea8a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 19 Oct 2022 20:16:28 +0100 Subject: [PATCH 03/18] Stop overloading the cell IDs as cargo/data Originally I was doing everything in the DOM, using just the primitive widgets. Given that I recently created an actual GameCell widget (which simply inherits from a Button, but still...) it makes sense to now have row/col properties as part of that. --- examples/five_by_five.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 403483c759..ba7660a46d 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -96,6 +96,8 @@ def at(row: int, col: int) -> str: def __init__(self, row: int, col: int) -> None: """Initialise the game cell.""" super().__init__("", id=self.at(row, col)) + self.row = row + self.col = col class GameGrid(Widget): @@ -188,21 +190,12 @@ def toggle_cells(self, cell: GameCell) -> None: :param GameCell cell: The cell to toggle the cells 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) - self.query_one(GameHeader).on = self.on_count + self.toggle_cell(cell.row - 1, cell.col) + self.toggle_cell(cell.row + 1, cell.col) + self.toggle_cell(cell.row, cell.col) + self.toggle_cell(cell.row, cell.col - 1) + self.toggle_cell(cell.row, cell.col + 1) + self.query_one(GameHeader).on = self.on_count def make_move_on(self, cell: GameCell) -> None: """Make a move on the given cell. From eb11984442b0d43ebf5048d09b432d184555c2bf Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 19 Oct 2022 20:42:25 +0100 Subject: [PATCH 04/18] Simplify toggle_cells Rather than repeat the same code over a number of lines, use a loop. --- examples/five_by_five.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index ba7660a46d..0264565e6e 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -185,16 +185,15 @@ def toggle_cell(self, row: int, col: int) -> None: if 0 <= row <= (self.SIZE - 1) and 0 <= col <= (self.SIZE - 1): self.query_one(f"#{GameCell.at(row, col)}", GameCell).toggle_class("on") + _PATTERN: Final = (-1, 1, 0, 0, 0) + def toggle_cells(self, cell: GameCell) -> None: """Toggle a 5x5 pattern around the given cell. :param GameCell cell: The cell to toggle the cells around. """ - self.toggle_cell(cell.row - 1, cell.col) - self.toggle_cell(cell.row + 1, cell.col) - self.toggle_cell(cell.row, cell.col) - self.toggle_cell(cell.row, cell.col - 1) - self.toggle_cell(cell.row, cell.col + 1) + for row, col in zip(self._PATTERN, reversed(self._PATTERN)): + self.toggle_cell(cell.row + row, cell.col + col) self.query_one(GameHeader).on = self.on_count def make_move_on(self, cell: GameCell) -> None: From 851a759e6790ee735acfde784c34cfe82c3aace3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 19 Oct 2022 20:50:19 +0100 Subject: [PATCH 05/18] Add a central method for getting a cell Also settle focus on the middle cell at the start of a game -- this is the start of adding keyboard navigation. --- examples/five_by_five.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 0264565e6e..7abb4a7b03 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -155,15 +155,25 @@ def game_playable(self, playable: bool) -> None: for cell in self.query(GameCell): cell.disabled = not playable + def cell(self, row: int, col: int) -> GameCell: + """Get the cell at a given location. + + :param int row: The row of the cell to get. + :param int col: The column of the cell to get. + :returns: The cell at that location. + :rtype: GameCell + """ + return self.query_one(f"#{GameCell.at(row,col)}", GameCell) + def new_game(self) -> None: """Start a new game.""" self.query_one(GameHeader).moves = 0 self.on_cells.remove_class("on") self.query_one(WinnerMessage).hide() + middle = self.cell(self.SIZE // 2, self.SIZE // 2) + self.toggle_cells(middle) + self.set_focus(middle) self.game_playable(True) - self.toggle_cells( - self.query_one(f"#{GameCell.at(self.SIZE // 2,self.SIZE // 2 )}", GameCell) - ) def compose(self) -> ComposeResult: """Compose the application screen.""" @@ -183,7 +193,7 @@ def toggle_cell(self, row: int, col: int) -> None: it with an invalid cell coordinate. """ if 0 <= row <= (self.SIZE - 1) and 0 <= col <= (self.SIZE - 1): - self.query_one(f"#{GameCell.at(row, col)}", GameCell).toggle_class("on") + self.cell(row, col).toggle_class("on") _PATTERN: Final = (-1, 1, 0, 0, 0) From be997409e028e36e7d1e0d6427ba7c2a8d97cde0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 19 Oct 2022 21:13:41 +0100 Subject: [PATCH 06/18] Add keyboard navigation Uses arrow keys or WASD. Also note moving the dark mode toggle off 'd' and onto 'D'. --- examples/five_by_five.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 7abb4a7b03..5c4d7cce88 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -121,6 +121,11 @@ class Game(Screen): ("n", "reset", "New Game"), ("h,question_mark", "app.push_screen('help')", "Help"), ("q", "quit", "Quit"), + ("up,w", "navigate(-1,0)", "Move Up"), + ("down,s", "navigate(1,0)", "Move Down"), + ("left,a", "navigate(0,-1)", "Move Left"), + ("right,d", "navigate(0,1)", "Move Right"), + ("space", "move", "Toggle"), ] @property @@ -226,6 +231,21 @@ def action_reset(self) -> None: """Reset the game.""" self.new_game() + def action_navigate(self, row: int, col: int) -> None: + """Navigate to a new cell by the given offsets.""" + if self.focused and isinstance(self.focused, GameCell): + self.set_focus( + self.cell( + (self.focused.row + row) % self.SIZE, + (self.focused.col + col) % self.SIZE, + ) + ) + + def action_move(self) -> None: + """Make a move on the current cell.""" + if self.focused and isinstance(self.focused, GameCell): + self.focused.press() + def on_mount(self) -> None: """Get the game started when we first mount.""" self.new_game() @@ -241,7 +261,7 @@ class FiveByFive(App[None]): SCREENS = {"help": Help()} #: App-level bindings. - BINDINGS = [("d", "app.toggle_dark", "Toggle Dark Mode")] + BINDINGS = [("D", "app.toggle_dark", "Toggle Dark Mode")] def __init__(self) -> None: """Constructor.""" From aa4e4fe2cc8e6d6a48ac89d89da38a07633c3382 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 19 Oct 2022 21:18:21 +0100 Subject: [PATCH 07/18] Move all the new game logic into the new game action --- examples/five_by_five.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 5c4d7cce88..9c4afae2f1 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -118,7 +118,7 @@ class Game(Screen): #: The bindings for the main game grid. BINDINGS = [ - ("n", "reset", "New Game"), + ("n", "new_game", "New Game"), ("h,question_mark", "app.push_screen('help')", "Help"), ("q", "quit", "Quit"), ("up,w", "navigate(-1,0)", "Move Up"), @@ -170,16 +170,6 @@ def cell(self, row: int, col: int) -> GameCell: """ return self.query_one(f"#{GameCell.at(row,col)}", GameCell) - def new_game(self) -> None: - """Start a new game.""" - self.query_one(GameHeader).moves = 0 - self.on_cells.remove_class("on") - self.query_one(WinnerMessage).hide() - middle = self.cell(self.SIZE // 2, self.SIZE // 2) - self.toggle_cells(middle) - self.set_focus(middle) - self.game_playable(True) - def compose(self) -> ComposeResult: """Compose the application screen.""" yield GameHeader() @@ -227,9 +217,15 @@ def on_button_pressed(self, event: GameCell.Pressed) -> None: """React to a press of a button on the game grid.""" self.make_move_on(cast(GameCell, event.button)) - def action_reset(self) -> None: - """Reset the game.""" - self.new_game() + def action_new_game(self) -> None: + """Start a new game.""" + self.query_one(GameHeader).moves = 0 + self.on_cells.remove_class("on") + self.query_one(WinnerMessage).hide() + middle = self.cell(self.SIZE // 2, self.SIZE // 2) + self.toggle_cells(middle) + self.set_focus(middle) + self.game_playable(True) def action_navigate(self, row: int, col: int) -> None: """Navigate to a new cell by the given offsets.""" @@ -248,7 +244,7 @@ def action_move(self) -> None: def on_mount(self) -> None: """Get the game started when we first mount.""" - self.new_game() + self.action_new_game() class FiveByFive(App[None]): From 59fb6f1ec5cc07be37003196841a01de8c00c880 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 19 Oct 2022 21:23:17 +0100 Subject: [PATCH 08/18] Declutter the status line --- examples/five_by_five.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 9c4afae2f1..4a0333b36c 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -10,6 +10,7 @@ from textual.widgets import Footer, Button, Static from textual.css.query import DOMQuery from textual.reactive import reactive +from textual.binding import Binding from rich.markdown import Markdown @@ -118,14 +119,14 @@ class Game(Screen): #: The bindings for the main game grid. BINDINGS = [ - ("n", "new_game", "New Game"), - ("h,question_mark", "app.push_screen('help')", "Help"), - ("q", "quit", "Quit"), - ("up,w", "navigate(-1,0)", "Move Up"), - ("down,s", "navigate(1,0)", "Move Down"), - ("left,a", "navigate(0,-1)", "Move Left"), - ("right,d", "navigate(0,1)", "Move Right"), - ("space", "move", "Toggle"), + Binding("n", "new_game", "New Game"), + Binding("h,question_mark", "app.push_screen('help')", "Help"), + Binding("q", "quit", "Quit"), + Binding("up,w", "navigate(-1,0)", "Move Up", False), + Binding("down,s", "navigate(1,0)", "Move Down", False), + Binding("left,a", "navigate(0,-1)", "Move Left", False), + Binding("right,d", "navigate(0,1)", "Move Right", False), + Binding("space", "move", "Toggle", False), ] @property From b3d8ebb2f44839c8143015b37721b456768fed89 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 19 Oct 2022 21:28:50 +0100 Subject: [PATCH 09/18] Simplify the tests for a focused game cell --- examples/five_by_five.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 4a0333b36c..7b3b50203e 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -230,7 +230,7 @@ def action_new_game(self) -> None: def action_navigate(self, row: int, col: int) -> None: """Navigate to a new cell by the given offsets.""" - if self.focused and isinstance(self.focused, GameCell): + if isinstance(self.focused, GameCell): self.set_focus( self.cell( (self.focused.row + row) % self.SIZE, @@ -240,7 +240,7 @@ def action_navigate(self, row: int, col: int) -> None: def action_move(self) -> None: """Make a move on the current cell.""" - if self.focused and isinstance(self.focused, GameCell): + if isinstance(self.focused, GameCell): self.focused.press() def on_mount(self) -> None: From 4ab660a02a3186417446915048c765d73f8b3ed3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 20 Oct 2022 08:37:51 +0100 Subject: [PATCH 10/18] Drop 'h' as a help key01 I'm going to repurpos.e it --- examples/five_by_five.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 7b3b50203e..92a5a7a988 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -19,7 +19,7 @@ class Help(Screen): """The help screen for the application.""" #: Bindings for the help screen. - BINDINGS = [("escape,space,q,h,question_mark", "app.pop_screen", "Close")] + BINDINGS = [("escape,space,q,question_mark", "app.pop_screen", "Close")] def compose(self) -> ComposeResult: """Compose the game's help.""" @@ -120,7 +120,7 @@ class Game(Screen): #: The bindings for the main game grid. BINDINGS = [ Binding("n", "new_game", "New Game"), - Binding("h,question_mark", "app.push_screen('help')", "Help"), + Binding("question_mark", "app.push_screen('help')", "Help", key_display="?"), Binding("q", "quit", "Quit"), Binding("up,w", "navigate(-1,0)", "Move Up", False), Binding("down,s", "navigate(1,0)", "Move Down", False), From c629826940f10840c6dc76192840f64db50273b1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 20 Oct 2022 08:42:38 +0100 Subject: [PATCH 11/18] Be nice to the vi(m) crowd --- examples/five_by_five.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 92a5a7a988..bb79ac2bf9 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -122,10 +122,10 @@ class Game(Screen): Binding("n", "new_game", "New Game"), Binding("question_mark", "app.push_screen('help')", "Help", key_display="?"), Binding("q", "quit", "Quit"), - Binding("up,w", "navigate(-1,0)", "Move Up", False), - Binding("down,s", "navigate(1,0)", "Move Down", False), - Binding("left,a", "navigate(0,-1)", "Move Left", False), - Binding("right,d", "navigate(0,1)", "Move Right", False), + Binding("up,w,k", "navigate(-1,0)", "Move Up", False), + Binding("down,s,j", "navigate(1,0)", "Move Down", False), + Binding("left,a,h", "navigate(0,-1)", "Move Left", False), + Binding("right,d,l", "navigate(0,1)", "Move Right", False), Binding("space", "move", "Toggle", False), ] From 685f13cfcd49f7d92327db444c38c6b9eacfdb49 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 20 Oct 2022 08:43:57 +0100 Subject: [PATCH 12/18] Drop the app namespace from some binding actions It had been suggested to me that these would be needed, but in testing here I'm not seeing that. So, until I find out otherwise, let's simplify things and drop that. --- examples/five_by_five.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index bb79ac2bf9..d354d51cf0 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -19,7 +19,7 @@ class Help(Screen): """The help screen for the application.""" #: Bindings for the help screen. - BINDINGS = [("escape,space,q,question_mark", "app.pop_screen", "Close")] + BINDINGS = [("escape,space,q,question_mark", "pop_screen", "Close")] def compose(self) -> ComposeResult: """Compose the game's help.""" @@ -120,7 +120,7 @@ class Game(Screen): #: The bindings for the main game grid. BINDINGS = [ Binding("n", "new_game", "New Game"), - Binding("question_mark", "app.push_screen('help')", "Help", key_display="?"), + Binding("question_mark", "push_screen('help')", "Help", key_display="?"), Binding("q", "quit", "Quit"), Binding("up,w,k", "navigate(-1,0)", "Move Up", False), Binding("down,s,j", "navigate(1,0)", "Move Down", False), @@ -258,7 +258,7 @@ class FiveByFive(App[None]): SCREENS = {"help": Help()} #: App-level bindings. - BINDINGS = [("D", "app.toggle_dark", "Toggle Dark Mode")] + BINDINGS = [("D", "toggle_dark", "Toggle Dark Mode")] def __init__(self) -> None: """Constructor.""" From 3e9b30ee2ee821ffb70917ae12aa0c29860256c2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 20 Oct 2022 14:04:14 +0100 Subject: [PATCH 13/18] Handle importing of Final for Python 3.7 See https://github.com/Textualize/textual/pull/963#pullrequestreview-1149139158 --- examples/five_by_five.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index d354d51cf0..fea71f42af 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -1,7 +1,13 @@ """Simple version of 5x5, developed for/with Textual.""" from pathlib import Path -from typing import Final, cast +from typing import cast +import sys + +if sys.version_info >= (3, 8): + from typing import Final +else: + from typing_extensions import Final from textual.containers import Horizontal from textual.app import App, ComposeResult From b19144abfe49c0bf1a4723eb364b7c49cfca92b4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 20 Oct 2022 14:11:00 +0100 Subject: [PATCH 14/18] Rename "on" things to "filled" things Because Textual uses on_ for event handlers there was the danger of a name clash; so to keep things as clear as possible this renames anything to do with "on" (method names, properties, style classes) so that it talks about "filled" instead. See https://github.com/Textualize/textual/pull/963#discussion_r1000544563 --- examples/five_by_five.css | 4 ++-- examples/five_by_five.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/five_by_five.css b/examples/five_by_five.css index 6bebda34d4..330953247f 100644 --- a/examples/five_by_five.css +++ b/examples/five_by_five.css @@ -47,12 +47,12 @@ GameCell:hover { border: round $panel; } -GameCell.on { +GameCell.filled { background: $secondary; border: round $secondary-darken-1; } -GameCell.on:hover { +GameCell.filled:hover { background: $secondary-lighten-1; border: round $secondary; } diff --git a/examples/five_by_five.py b/examples/five_by_five.py index fea71f42af..882d1178bb 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -136,20 +136,20 @@ class Game(Screen): ] @property - def on_cells(self) -> DOMQuery[GameCell]: + def filled_cells(self) -> DOMQuery[GameCell]: """The collection of cells that are currently turned on. :type: DOMQuery[GameCell] """ - return cast(DOMQuery[GameCell], self.query("GameCell.on")) + return cast(DOMQuery[GameCell], self.query("GameCell.filled")) @property - def on_count(self) -> int: + def filled_count(self) -> int: """The number of cells that are turned on. :type: int """ - return len(self.on_cells) + return len(self.filled_cells) @property def all_on(self) -> bool: @@ -157,7 +157,7 @@ def all_on(self) -> bool: :type: bool """ - return self.on_count == self.SIZE * self.SIZE + return self.filled_count == self.SIZE * self.SIZE def game_playable(self, playable: bool) -> None: """Mark the game as playable, or not. @@ -195,7 +195,7 @@ def toggle_cell(self, row: int, col: int) -> None: it with an invalid cell coordinate. """ if 0 <= row <= (self.SIZE - 1) and 0 <= col <= (self.SIZE - 1): - self.cell(row, col).toggle_class("on") + self.cell(row, col).toggle_class("filled") _PATTERN: Final = (-1, 1, 0, 0, 0) @@ -206,7 +206,7 @@ def toggle_cells(self, cell: GameCell) -> None: """ for row, col in zip(self._PATTERN, reversed(self._PATTERN)): self.toggle_cell(cell.row + row, cell.col + col) - self.query_one(GameHeader).on = self.on_count + self.query_one(GameHeader).on = self.filled_count def make_move_on(self, cell: GameCell) -> None: """Make a move on the given cell. @@ -227,7 +227,7 @@ def on_button_pressed(self, event: GameCell.Pressed) -> None: def action_new_game(self) -> None: """Start a new game.""" self.query_one(GameHeader).moves = 0 - self.on_cells.remove_class("on") + self.filled_cells.remove_class("filled") self.query_one(WinnerMessage).hide() middle = self.cell(self.SIZE // 2, self.SIZE // 2) self.toggle_cells(middle) From 615a1997b92598a3cb4cd474794eb535f7d07fc8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 20 Oct 2022 14:26:28 +0100 Subject: [PATCH 15/18] Drop using __file__ to work out the name of the CSS file See https://github.com/Textualize/textual/pull/963#discussion_r1000546514 Personally I prefer the approach I was using in that it's one less bit of hard-coded metadata. On the other hand I can appreciate that reducing the number of possibly-confusing things in an example plays better with people who may be both new to Textual *and* to Python. --- examples/five_by_five.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 882d1178bb..d872854d84 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -258,7 +258,7 @@ class FiveByFive(App[None]): """Main 5x5 application class.""" #: The name of the stylesheet for the app. - CSS_PATH = Path(__file__).with_suffix(".css") + CSS_PATH = "five_by_five.css" #: The pre-loaded screens for the application. SCREENS = {"help": Help()} From db976348cc59105ae9496e991281da012a1ac0b3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 20 Oct 2022 14:46:18 +0100 Subject: [PATCH 16/18] Swap the docstrings away from Sphinx style to Google style See https://github.com/Textualize/textual/pull/963#discussion_r1000547282 --- examples/five_by_five.py | 115 ++++++++++++++++++++++++++++----------- 1 file changed, 83 insertions(+), 32 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index d872854d84..08b648e0e9 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -28,7 +28,11 @@ class Help(Screen): BINDINGS = [("escape,space,q,question_mark", "pop_screen", "Close")] def compose(self) -> ComposeResult: - """Compose the game's help.""" + """Compose the game's help. + + Returns: + ComposeResult: The result of composing the help screen. + """ yield Static(Markdown(Path(__file__).with_suffix(".md").read_text())) @@ -43,7 +47,11 @@ def _plural(value: int) -> str: return "" if value == 1 else "s" def show(self, moves: int) -> None: - """Show the winner message.""" + """Show the winner message. + + Args: + moves (int): The number of moves required to win. + """ self.update( "W I N N E R !\n\n\n" f"You solved the puzzle in {moves} move{self._plural(moves)}." @@ -77,7 +85,11 @@ class GameHeader(Widget): on = reactive(0) def compose(self) -> ComposeResult: - """Compose the game header.""" + """Compose the game header. + + Returns: + ComposeResult: The result of composing the game header. + """ yield Horizontal( Static(self.app.title, id="app-title"), Static(id="moves"), @@ -85,11 +97,19 @@ def compose(self) -> ComposeResult: ) def watch_moves(self, moves: int): - """Watch the moves reactive and update when it changes.""" + """Watch the moves reactive and update when it changes. + + Args: + moves (int): The number of moves made. + """ self.query_one("#moves", Static).update(f"Moves: {moves}") def watch_on(self, on: int): - """Watch the on-count reactive and update when it changes.""" + """Watch the on-count reactive and update when it changes. + + Args: + on (int): The number of cells that are currently on. + """ self.query_one("#progress", Static).update(f"On: {on}") @@ -98,10 +118,25 @@ class GameCell(Button): @staticmethod def at(row: int, col: int) -> str: + """Get the ID of the cell at the given location. + + Args: + row (int): The row of the cell. + col (int): The column of the cell. + + Returns: + str: A string ID for the cell. + """ return f"cell-{row}-{col}" def __init__(self, row: int, col: int) -> None: - """Initialise the game cell.""" + """Initialise the game cell. + + Args: + row (int): The row of the cell. + col (int): The column of the cell. + + """ super().__init__("", id=self.at(row, col)) self.row = row self.col = col @@ -111,7 +146,11 @@ class GameGrid(Widget): """The main playable grid of game cells.""" def compose(self) -> ComposeResult: - """Compose the game grid.""" + """Compose the game grid. + + Returns: + ComposeResult: The result of composing the game grid. + """ for row in range(Game.SIZE): for col in range(Game.SIZE): yield GameCell(row, col) @@ -137,32 +176,24 @@ class Game(Screen): @property def filled_cells(self) -> DOMQuery[GameCell]: - """The collection of cells that are currently turned on. - - :type: DOMQuery[GameCell] - """ + """DOMQuery[GameCell]: The collection of cells that are currently turned on.""" return cast(DOMQuery[GameCell], self.query("GameCell.filled")) @property def filled_count(self) -> int: - """The number of cells that are turned on. - - :type: int - """ + """int: The number of cells that are currently filled.""" return len(self.filled_cells) @property def all_on(self) -> bool: - """Are all the cells turned on? - - :type: bool - """ + """bool: Are all the cells turned on?""" return self.filled_count == self.SIZE * self.SIZE def game_playable(self, playable: bool) -> None: """Mark the game as playable, or not. - :param bool playable: Should the game currently be playable? + Args: + playable (bool): Should the game currently be playable? """ for cell in self.query(GameCell): cell.disabled = not playable @@ -170,29 +201,36 @@ def game_playable(self, playable: bool) -> None: def cell(self, row: int, col: int) -> GameCell: """Get the cell at a given location. - :param int row: The row of the cell to get. - :param int col: The column of the cell to get. - :returns: The cell at that location. - :rtype: GameCell + Args: + row (int): The row of the cell to get. + col (int): The column of the cell to get. + + Returns: + GameCell: The cell at that location. """ return self.query_one(f"#{GameCell.at(row,col)}", GameCell) def compose(self) -> ComposeResult: - """Compose the application screen.""" + """Compose the game screen. + + Returns: + ComposeResult: The result of composing the game screen. + """ yield GameHeader() yield GameGrid() yield Footer() yield WinnerMessage() 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. + """Toggle an individual cell, but only if it's in bounds. 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. + + Args: + row (int): The row of the cell to toggle. + col (int): The column of the cell to toggle. """ if 0 <= row <= (self.SIZE - 1) and 0 <= col <= (self.SIZE - 1): self.cell(row, col).toggle_class("filled") @@ -202,7 +240,8 @@ def toggle_cell(self, row: int, col: int) -> None: def toggle_cells(self, cell: GameCell) -> None: """Toggle a 5x5 pattern around the given cell. - :param GameCell cell: The cell to toggle the cells around. + Args: + cell (GameCell): The cell to toggle the cells around. """ for row, col in zip(self._PATTERN, reversed(self._PATTERN)): self.toggle_cell(cell.row + row, cell.col + col) @@ -213,6 +252,9 @@ def make_move_on(self, cell: GameCell) -> None: All relevant cells around the given cell are toggled as per the game's rules. + + Args: + cell (GameCell): The cell to make a move on """ self.toggle_cells(cell) self.query_one(GameHeader).moves += 1 @@ -221,7 +263,11 @@ def make_move_on(self, cell: GameCell) -> None: self.game_playable(False) def on_button_pressed(self, event: GameCell.Pressed) -> None: - """React to a press of a button on the game grid.""" + """React to a press of a button on the game grid. + + Args: + event (GameCell.Pressed): The event to react to. + """ self.make_move_on(cast(GameCell, event.button)) def action_new_game(self) -> None: @@ -235,7 +281,12 @@ def action_new_game(self) -> None: self.game_playable(True) def action_navigate(self, row: int, col: int) -> None: - """Navigate to a new cell by the given offsets.""" + """Navigate to a new cell by the given offsets. + + Args: + row (int): The row of the cell to navigate to. + col (int): The column of the cell to navigate to. + """ if isinstance(self.focused, GameCell): self.set_focus( self.cell( From e0cea53d2f10ccbb63b44ea7442f5e09fd2c234b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 20 Oct 2022 14:49:30 +0100 Subject: [PATCH 17/18] Sweep up some on->filled naming changes --- examples/five_by_five.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 08b648e0e9..4c6004aef9 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -81,8 +81,8 @@ class GameHeader(Widget): #: Keep track of how many moves the player has made. moves = reactive(0) - #: Keep track of how many cells are turned on. - on = reactive(0) + #: Keep track of how many cells are filled. + filled = reactive(0) def compose(self) -> ComposeResult: """Compose the game header. @@ -104,13 +104,13 @@ def watch_moves(self, moves: int): """ self.query_one("#moves", Static).update(f"Moves: {moves}") - def watch_on(self, on: int): + def watch_filled(self, filled: int): """Watch the on-count reactive and update when it changes. Args: - on (int): The number of cells that are currently on. + filled (int): The number of cells that are currently on. """ - self.query_one("#progress", Static).update(f"On: {on}") + self.query_one("#progress", Static).update(f"Filled: {filled}") class GameCell(Button): @@ -185,8 +185,8 @@ def filled_count(self) -> int: return len(self.filled_cells) @property - def all_on(self) -> bool: - """bool: Are all the cells turned on?""" + def all_filled(self) -> bool: + """bool: Are all the cells filled?""" return self.filled_count == self.SIZE * self.SIZE def game_playable(self, playable: bool) -> None: @@ -245,7 +245,7 @@ def toggle_cells(self, cell: GameCell) -> None: """ for row, col in zip(self._PATTERN, reversed(self._PATTERN)): self.toggle_cell(cell.row + row, cell.col + col) - self.query_one(GameHeader).on = self.filled_count + self.query_one(GameHeader).filled = self.filled_count def make_move_on(self, cell: GameCell) -> None: """Make a move on the given cell. @@ -258,7 +258,7 @@ def make_move_on(self, cell: GameCell) -> None: """ self.toggle_cells(cell) self.query_one(GameHeader).moves += 1 - if self.all_on: + if self.all_filled: self.query_one(WinnerMessage).show(self.query_one(GameHeader).moves) self.game_playable(False) From 8e1dcbd2e1975872c67682e444786196a0c245b5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 20 Oct 2022 15:55:49 +0100 Subject: [PATCH 18/18] Add a wee bit of animation --- examples/five_by_five.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/five_by_five.css b/examples/five_by_five.css index 330953247f..8901d777af 100644 --- a/examples/five_by_five.css +++ b/examples/five_by_five.css @@ -1,3 +1,6 @@ +$animation-type: linear; +$animatin-speed: 175ms; + Game { align: center middle; layers: gameplay messages; @@ -40,6 +43,7 @@ GameCell { height: 100%; background: $surface; border: round $surface-darken-1; + transition: background $animatin-speed $animation-type, color $animatin-speed $animation-type; } GameCell:hover {