diff --git a/main.py b/main.py index abe1c15..fce11a5 100644 --- a/main.py +++ b/main.py @@ -418,9 +418,10 @@ def sync_board_state(): header_label.set_text(CLOSED_HEADER_TEXT) header_label.update() - # Hide all board views + # Show closed message in all board views for view_key, (container, _) in board_views.items(): - container.style("display: none;") + container.clear() + build_closed_message(container) container.update() # Make sure controls row is showing only the Start New Game button @@ -915,9 +916,39 @@ def generate_new_board(): reset_board() +def build_closed_message(parent): + """ + Build a message indicating the game is closed, to be displayed in place of the board. + + Args: + parent: The parent UI element to build the message in + """ + with parent: + with ui.element("div").classes(GRID_CONTAINER_CLASS): + with ui.element("div").classes( + "flex justify-center items-center h-full w-full" + ): + ui.label("GAME CLOSED").classes("text-center fit-header").style( + f"font-family: {HEADER_FONT_FAMILY}; color: {FREE_SPACE_TEXT_COLOR}; font-size: 6rem;" + ) + + # Run JavaScript to ensure text is resized properly + try: + js_code = """ + setTimeout(function() { + if (typeof fitty !== 'undefined') { + fitty('.fit-header', { multiLine: true, minSize: 10, maxSize: 2000 }); + } + }, 50); + """ + ui.run_javascript(js_code) + except Exception as e: + logging.debug(f"JavaScript execution failed: {e}") + + def close_game(): """ - Close the game - hide the board and update the header text. + Close the game - show closed message instead of the board and update the header text. This function is called when the close button is clicked. """ global is_game_closed, header_label @@ -928,9 +959,10 @@ def close_game(): header_label.set_text(CLOSED_HEADER_TEXT) header_label.update() - # Hide all board views (both home and stream) + # Show closed message in all board views for view_key, (container, tile_buttons_local) in board_views.items(): - container.style("display: none;") + container.clear() + build_closed_message(container) container.update() # Modify the controls row to only show the New Board button diff --git a/poetry.lock b/poetry.lock index d37c574..b7e0c41 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1166,6 +1166,59 @@ files = [ {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, ] +[[package]] +name = "mypy" +version = "1.15.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, + {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, + {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, + {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, + {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, + {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, + {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, + {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, + {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, + {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, + {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, + {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, + {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -2519,4 +2572,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "4377469668b11d516a06d64aaf4418b1c2d606eb21a383eeb40ebaa45c5d14a0" +content-hash = "ea0dbf94f60bfaba1db5ac89c63c3c8ce3510f60659ba6c778c19c55dd29531f" diff --git a/pyproject.toml b/pyproject.toml index 47ff759..a28c0ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ flake8 = "^7.0.0" black = "^24.2.0" isort = "^5.13.2" python-semantic-release = "^9.1.1" +mypy = "^1.15.0" [build-system] requires = ["poetry-core>=1.8"] diff --git a/src/config/constants.py b/src/config/constants.py index 6d668cb..5853f97 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -2,40 +2,53 @@ Configuration constants for the Bingo application. """ +from typing import Final, Literal + +# Type definitions for CSS properties +CssColor = str # Hex color code like "#123456" or named color like "red" +CssFontFamily = str # Font family names like "'Font Name', sans-serif" +CssFontWeight = str # Font weight like "400", "700", etc. +CssFontStyle = Literal["normal", "italic", "oblique"] +CssClass = str # CSS class name or space-separated class names + # Header text and display settings -HEADER_TEXT = "COMMIT !BINGO" -HEADER_TEXT_COLOR = "#0CB2B3" -CLOSED_HEADER_TEXT = "Bingo Is Closed" +HEADER_TEXT: Final[str] = "COMMIT !BINGO" +HEADER_TEXT_COLOR: Final[CssColor] = "#0CB2B3" +CLOSED_HEADER_TEXT: Final[str] = "Bingo Is Closed" +CLOSED_MESSAGE_TEXT: Final[str] = "GAME CLOSED" +CLOSED_MESSAGE_COLOR: Final[CssColor] = "#FF7f33" # Free space settings -FREE_SPACE_TEXT = "FREE MEAT" -FREE_SPACE_TEXT_COLOR = "#FF7f33" +FREE_SPACE_TEXT: Final[str] = "FREE MEAT" +FREE_SPACE_TEXT_COLOR: Final[CssColor] = "#FF7f33" # Tile appearance settings -TILE_CLICKED_BG_COLOR = "#100079" -TILE_CLICKED_TEXT_COLOR = "#1BEFF5" -TILE_UNCLICKED_BG_COLOR = "#1BEFF5" -TILE_UNCLICKED_TEXT_COLOR = "#100079" +TILE_CLICKED_BG_COLOR: Final[CssColor] = "#100079" +TILE_CLICKED_TEXT_COLOR: Final[CssColor] = "#1BEFF5" +TILE_UNCLICKED_BG_COLOR: Final[CssColor] = "#1BEFF5" +TILE_UNCLICKED_TEXT_COLOR: Final[CssColor] = "#100079" # Page backgrounds -HOME_BG_COLOR = "#100079" -STREAM_BG_COLOR = "#00FF00" +HOME_BG_COLOR: Final[CssColor] = "#100079" +STREAM_BG_COLOR: Final[CssColor] = "#00FF00" # Font settings -HEADER_FONT_FAMILY = "'Super Carnival', sans-serif" -BOARD_TILE_FONT = "Inter" -BOARD_TILE_FONT_WEIGHT = "700" -BOARD_TILE_FONT_STYLE = "normal" +HEADER_FONT_FAMILY: Final[CssFontFamily] = "'Super Carnival', sans-serif" +BOARD_TILE_FONT: Final[str] = "Inter" +BOARD_TILE_FONT_WEIGHT: Final[CssFontWeight] = "700" +BOARD_TILE_FONT_STYLE: Final[CssFontStyle] = "normal" # UI Class Constants -BOARD_CONTAINER_CLASS = "flex justify-center items-center w-full" -HEADER_CONTAINER_CLASS = "w-full" -CARD_CLASSES = ( +BOARD_CONTAINER_CLASS: Final[CssClass] = "flex justify-center items-center w-full" +HEADER_CONTAINER_CLASS: Final[CssClass] = "w-full" +CARD_CLASSES: Final[CssClass] = ( "relative p-2 rounded-xl shadow-8 w-full h-full flex items-center justify-center" ) -COLUMN_CLASSES = "flex flex-col items-center justify-center gap-0 w-full" -GRID_CONTAINER_CLASS = "w-full aspect-square p-4" -GRID_CLASSES = "gap-2 h-full grid-rows-5" -ROW_CLASSES = "w-full" -LABEL_SMALL_CLASSES = "fit-text-small text-center select-none" -LABEL_CLASSES = "fit-text text-center select-none" +COLUMN_CLASSES: Final[CssClass] = ( + "flex flex-col items-center justify-center gap-0 w-full" +) +GRID_CONTAINER_CLASS: Final[CssClass] = "w-full aspect-square p-4" +GRID_CLASSES: Final[CssClass] = "gap-2 h-full grid-rows-5" +ROW_CLASSES: Final[CssClass] = "w-full" +LABEL_SMALL_CLASSES: Final[CssClass] = "fit-text-small text-center select-none" +LABEL_CLASSES: Final[CssClass] = "fit-text text-center select-none" diff --git a/src/core/game_logic.py b/src/core/game_logic.py index a751d0f..7f30927 100644 --- a/src/core/game_logic.py +++ b/src/core/game_logic.py @@ -5,6 +5,7 @@ import datetime import logging import random +from typing import List, Optional, Set, cast from nicegui import ui @@ -18,27 +19,46 @@ TILE_UNCLICKED_BG_COLOR, TILE_UNCLICKED_TEXT_COLOR, ) +from src.types.ui_types import ( + BingoPattern, + BingoPatterns, + BoardType, + BoardViews, + ClickedTiles, + Coordinate, + TileButtonsDict, + TileLabelInfo, +) from src.utils.text_processing import get_line_style_for_lines, split_phrase_into_lines # Global variables for game state -board = [] # 2D array of phrases -clicked_tiles = set() # Set of (row, col) tuples that are clicked -bingo_patterns = set() # Set of winning patterns found -board_iteration = 1 -is_game_closed = False -today_seed = None +board: BoardType = [] # 2D array of phrases +clicked_tiles: ClickedTiles = set() # Set of (row, col) tuples that are clicked +bingo_patterns: BingoPatterns = set() # Set of winning patterns found +board_iteration: int = 1 +is_game_closed: bool = False +today_seed: Optional[str] = None # Global variables for UI references (initialized in the UI module) -header_label = None -controls_row = None -seed_label = None -board_views = {} # Dictionary mapping view name to (container, tile_buttons) tuple +header_label: Optional[ui.label] = None +controls_row: Optional[ui.row] = None +seed_label: Optional[ui.label] = None +board_views: BoardViews = ( + {} +) # Dictionary mapping view name to (container, tile_buttons) tuple -def generate_board(seed_val: int, phrases): +def generate_board(seed_val: int, phrases: List[str]) -> BoardType: """ Generate a new board using the provided seed value. Also resets the clicked_tiles (ensuring the FREE SPACE is clicked) and sets the global today_seed. + + Args: + seed_val: Integer used to seed the random generator + phrases: List of phrases to use in the board + + Returns: + The generated board as a 2D array of phrases """ global board, today_seed, clicked_tiles @@ -61,10 +81,14 @@ def generate_board(seed_val: int, phrases): return board -def toggle_tile(row, col): +def toggle_tile(row: int, col: int) -> None: """ Toggle the state of a tile (clicked/unclicked). Updates the UI and checks for winner. + + Args: + row: Row index of the tile to toggle + col: Column index of the tile to toggle """ global clicked_tiles @@ -72,7 +96,7 @@ def toggle_tile(row, col): if (row, col) == (2, 2): return - key = (row, col) + key: Coordinate = (row, col) if key in clicked_tiles: clicked_tiles.remove(key) else: @@ -90,18 +114,22 @@ def toggle_tile(row, col): new_card_style = f"background-color: {TILE_UNCLICKED_BG_COLOR}; color: {TILE_UNCLICKED_TEXT_COLOR}; border: none;" new_label_color = TILE_UNCLICKED_TEXT_COLOR - tile["card"].style(new_card_style) + card = cast(ui.card, tile["card"]) + card.style(new_card_style) + lines = split_phrase_into_lines(phrase) line_count = len(lines) new_label_style = get_line_style_for_lines(line_count, new_label_color) - for label_info in tile["labels"]: - lbl = label_info["ref"] - lbl.classes(label_info["base_classes"]) + label_list = cast(List[TileLabelInfo], tile["labels"]) + for label_info in label_list: + lbl = cast(ui.label, label_info["ref"]) + base_classes = cast(str, label_info["base_classes"]) + lbl.classes(base_classes) lbl.style(new_label_style) lbl.update() - tile["card"].update() + card.update() container.update() @@ -119,12 +147,12 @@ def toggle_tile(row, col): logging.debug(f"JavaScript execution failed: {e}") -def check_winner(): +def check_winner() -> None: """ Check for Bingo win condition and update the UI accordingly. """ global bingo_patterns - new_patterns = [] + new_patterns: List[BingoPattern] = [] # Check rows and columns. for i in range(5): @@ -158,7 +186,9 @@ def check_winner(): new_patterns.append("four_corners") # Plus shape: complete center row and center column. - plus_cells = {(2, c) for c in range(5)} | {(r, 2) for r in range(5)} + plus_cells: Set[Coordinate] = {(2, c) for c in range(5)} | { + (r, 2) for r in range(5) + } if all(cell in clicked_tiles for cell in plus_cells): if "plus" not in bingo_patterns: new_patterns.append("plus") @@ -171,7 +201,7 @@ def check_winner(): new_patterns.append("x_shape") # Outside edges (perimeter): all border cells clicked. - perimeter_cells = ( + perimeter_cells: Set[Coordinate] = ( {(0, c) for c in range(5)} | {(4, c) for c in range(5)} | {(r, 0) for r in range(5)} @@ -183,15 +213,24 @@ def check_winner(): if new_patterns: # Separate new win patterns into standard and special ones. - special_set = {"blackout", "four_corners", "plus", "x_shape", "perimeter"} - standard_new = [p for p in new_patterns if p not in special_set] - special_new = [p for p in new_patterns if p in special_set] + special_set: Set[str] = { + "blackout", + "four_corners", + "plus", + "x_shape", + "perimeter", + } + standard_new: List[BingoPattern] = [ + p for p in new_patterns if p not in special_set + ] + special_new: List[BingoPattern] = [p for p in new_patterns if p in special_set] # Process standard win conditions (rows, columns, diagonals). if standard_new: for pattern in standard_new: bingo_patterns.add(pattern) - standard_total = sum(1 for p in bingo_patterns if p not in special_set) + standard_total: int = sum(1 for p in bingo_patterns if p not in special_set) + message: str if standard_total == 1: message = "BINGO!" elif standard_total == 2: @@ -210,11 +249,11 @@ def check_winner(): for sp in special_new: bingo_patterns.add(sp) # Format the name to title-case and append "Bingo!" - sp_message = sp.replace("_", " ").title() + " Bingo!" + sp_message: str = sp.replace("_", " ").title() + " Bingo!" ui.notify(sp_message, color="blue", duration=5) -def reset_board(): +def reset_board() -> None: """ Reset the board by clearing all clicked states, clearing winning patterns, and re-adding the FREE SPACE. @@ -228,9 +267,12 @@ def reset_board(): clicked_tiles.add((r, c)) -def generate_new_board(phrases): +def generate_new_board(phrases: List[str]) -> None: """ Generate a new board with an incremented iteration seed and update all board views. + + Args: + phrases: List of phrases to use for the board """ global board_iteration board_iteration += 1 @@ -246,33 +288,38 @@ def generate_new_board(phrases): container.update() # Update the seed label if available - if "seed_label" in globals() and seed_label: + if seed_label is not None: seed_label.set_text(f"Seed: {today_seed}") seed_label.update() reset_board() -def close_game(): +def close_game() -> None: """ - Close the game - hide the board and update the header text. + Close the game - show closed message instead of the board and update the header text. This function is called when the close button is clicked. """ global is_game_closed, header_label is_game_closed = True # Update header text on the current view - if header_label: + if header_label is not None: header_label.set_text(CLOSED_HEADER_TEXT) header_label.update() - # Hide all board views (both home and stream) + # Show closed message in board containers + from src.config.constants import CLOSED_MESSAGE_COLOR, CLOSED_MESSAGE_TEXT + from src.ui.board_builder import build_closed_message + + # Replace board with closed message in all views for view_key, (container, tile_buttons_local) in board_views.items(): - container.style("display: none;") + container.clear() + build_closed_message(container) container.update() # Modify the controls row to only show the New Board button - if controls_row: + if controls_row is not None: controls_row.clear() with controls_row: with ui.button("", icon="autorenew", on_click=reopen_game).classes( @@ -293,7 +340,7 @@ def close_game(): ui.notify("Game has been closed", color="red", duration=3) -def reopen_game(): +def reopen_game() -> None: """ Reopen the game after it has been closed. This regenerates a new board and resets the UI. @@ -304,14 +351,14 @@ def reopen_game(): is_game_closed = False # Update header text back to original for the current view - if header_label: + if header_label is not None: header_label.set_text(HEADER_TEXT) header_label.update() # Generate a new board from src.utils.file_operations import read_phrases_file - phrases = read_phrases_file() + phrases: List[str] = read_phrases_file() board_iteration += 1 generate_board(board_iteration, phrases) @@ -319,7 +366,7 @@ def reopen_game(): # Rebuild the controls row with all buttons from src.ui.controls import rebuild_controls_row - if controls_row: + if controls_row is not None: rebuild_controls_row(controls_row) # Recreate and show all board views diff --git a/src/types/__init__.py b/src/types/__init__.py new file mode 100644 index 0000000..7771ab9 --- /dev/null +++ b/src/types/__init__.py @@ -0,0 +1,3 @@ +""" +Type definitions for the Bingo application. +""" diff --git a/src/types/ui_types.py b/src/types/ui_types.py new file mode 100644 index 0000000..fc2d990 --- /dev/null +++ b/src/types/ui_types.py @@ -0,0 +1,21 @@ +""" +UI Type definitions for the Bingo application. +""" + +from typing import Dict, List, Set, Tuple, Union + +from nicegui import ui + +# Basic types +Coordinate = Tuple[int, int] +BoardType = List[List[str]] +ClickedTiles = Set[Coordinate] +BingoPattern = str +BingoPatterns = Set[BingoPattern] + +# UI Element types +TileLabelInfo = Dict[str, Union[ui.label, str]] +TileInfo = Dict[str, Union[ui.card, List[TileLabelInfo]]] +TileButtonsDict = Dict[Coordinate, TileInfo] +BoardViewTuple = Tuple[ui.element, TileButtonsDict] +BoardViews = Dict[str, BoardViewTuple] diff --git a/src/ui/board_builder.py b/src/ui/board_builder.py index a932188..dfbe569 100644 --- a/src/ui/board_builder.py +++ b/src/ui/board_builder.py @@ -2,6 +2,8 @@ Board builder UI component for the Bingo application. """ +from typing import Callable, cast + from nicegui import ui from src.config.constants import ( @@ -9,10 +11,13 @@ BOARD_TILE_FONT_STYLE, BOARD_TILE_FONT_WEIGHT, CARD_CLASSES, + CLOSED_MESSAGE_COLOR, + CLOSED_MESSAGE_TEXT, FREE_SPACE_TEXT, FREE_SPACE_TEXT_COLOR, GRID_CLASSES, GRID_CONTAINER_CLASS, + HEADER_FONT_FAMILY, LABEL_CLASSES, LABEL_SMALL_CLASSES, TILE_CLICKED_BG_COLOR, @@ -20,10 +25,49 @@ TILE_UNCLICKED_BG_COLOR, TILE_UNCLICKED_TEXT_COLOR, ) +from src.types.ui_types import BoardType, ClickedTiles, Coordinate, TileButtonsDict from src.utils.text_processing import get_line_style_for_lines, split_phrase_into_lines -def build_board(parent, tile_buttons_dict: dict, on_tile_click, board, clicked_tiles): +def build_closed_message(parent: ui.element) -> None: + """ + Build a message indicating the game is closed, to be displayed in place of the board. + + Args: + parent: The parent UI element to build the message in + """ + import logging + + with parent: + with ui.element("div").classes(GRID_CONTAINER_CLASS): + with ui.element("div").classes( + "flex justify-center items-center h-full w-full" + ): + ui.label(CLOSED_MESSAGE_TEXT).classes("text-center fit-header").style( + f"font-family: {HEADER_FONT_FAMILY}; color: {CLOSED_MESSAGE_COLOR}; font-size: 6rem;" + ) + + # Run JavaScript to ensure text is resized properly + try: + js_code = """ + setTimeout(function() { + if (typeof fitty !== 'undefined') { + fitty('.fit-header', { multiLine: true, minSize: 10, maxSize: 2000 }); + } + }, 50); + """ + ui.run_javascript(js_code) + except Exception as e: + logging.debug(f"JavaScript execution failed: {e}") + + +def build_board( + parent: ui.element, + tile_buttons_dict: TileButtonsDict, + on_tile_click: Callable[[int, int], None], + board: BoardType, + clicked_tiles: ClickedTiles, +) -> TileButtonsDict: """ Build the common Bingo board in the given parent element. The resulting tile UI elements are added to tile_buttons_dict. @@ -34,6 +78,9 @@ def build_board(parent, tile_buttons_dict: dict, on_tile_click, board, clicked_t on_tile_click: Callback function when a tile is clicked board: 2D array of phrases clicked_tiles: Set of (row, col) tuples that are clicked + + Returns: + The updated tile_buttons_dict with UI elements """ with parent: with ui.element("div").classes(GRID_CONTAINER_CLASS): @@ -104,7 +151,7 @@ def build_board(parent, tile_buttons_dict: dict, on_tile_click, board, clicked_t return tile_buttons_dict -def create_board_view(background_color: str, is_global: bool): +def create_board_view(background_color: str, is_global: bool) -> None: """ Creates a board page view based on the background color and a flag. If is_global is True, the board uses global variables (home page) @@ -151,7 +198,7 @@ def on_phrases_change(phrases): generate_new_board(phrases) # Build the home view with controls - tile_buttons = {} # Start with an empty dictionary. + tile_buttons: TileButtonsDict = {} # Start with an empty dictionary build_board(container, tile_buttons, toggle_tile, board, clicked_tiles) board_views["home"] = (container, tile_buttons) @@ -168,6 +215,6 @@ def on_phrases_change(phrases): else: # Build the stream view (no controls) - local_tile_buttons = {} + local_tile_buttons: TileButtonsDict = {} build_board(container, local_tile_buttons, toggle_tile, board, clicked_tiles) board_views["stream"] = (container, local_tile_buttons) diff --git a/src/ui/sync.py b/src/ui/sync.py index cde5be1..87e80cc 100644 --- a/src/ui/sync.py +++ b/src/ui/sync.py @@ -24,9 +24,12 @@ def sync_board_state(): header_label.set_text(CLOSED_HEADER_TEXT) header_label.update() - # Hide all board views + # Show closed message in all board views + from src.ui.board_builder import build_closed_message + for view_key, (container, _) in board_views.items(): - container.style("display: none;") + container.clear() + build_closed_message(container) container.update() # Make sure controls row is showing only the Start New Game button diff --git a/tests/test_ui_functions.py b/tests/test_ui_functions.py index ffa0c3c..dafea8d 100644 --- a/tests/test_ui_functions.py +++ b/tests/test_ui_functions.py @@ -143,7 +143,8 @@ def test_update_tile_styles(self, mock_run_js): @patch("src.core.game_logic.ui") @patch("src.core.game_logic.header_label") - def test_close_game(self, mock_header_label, mock_ui): + @patch("src.ui.board_builder.build_closed_message") + def test_close_game(self, mock_build_closed_message, mock_header_label, mock_ui): """Test closing the game functionality""" from src.config.constants import CLOSED_HEADER_TEXT from src.core.game_logic import board_views, close_game, is_game_closed @@ -192,10 +193,10 @@ def test_close_game(self, mock_header_label, mock_ui): mock_header_label.set_text.assert_called_once_with(CLOSED_HEADER_TEXT) mock_header_label.update.assert_called_once() - # Verify containers are hidden - mock_container1.style.assert_called_once_with("display: none;") + # Verify containers are cleared and the closed message is built + mock_container1.clear.assert_called_once() mock_container1.update.assert_called_once() - mock_container2.style.assert_called_once_with("display: none;") + mock_container2.clear.assert_called_once() mock_container2.update.assert_called_once() # Note: In the new structure, the controls_row clear might not be called directly @@ -216,7 +217,10 @@ def test_close_game(self, mock_header_label, mock_ui): globals()["is_game_closed"] = original_is_game_closed @patch("main.ui.run_javascript") - def test_sync_board_state_when_game_closed(self, mock_run_js): + @patch("main.build_closed_message") + def test_sync_board_state_when_game_closed( + self, mock_build_closed_message, mock_run_js + ): """Test sync_board_state behavior when game is closed""" import main @@ -251,10 +255,10 @@ def test_sync_board_state_when_game_closed(self, mock_run_js): main.header_label.set_text.assert_called_once_with(main.CLOSED_HEADER_TEXT) main.header_label.update.assert_called_once() - # Verify containers are hidden - mock_container1.style.assert_called_once_with("display: none;") + # Verify containers are cleared and closed message is built + mock_container1.clear.assert_called_once() mock_container1.update.assert_called_once() - mock_container2.style.assert_called_once_with("display: none;") + mock_container2.clear.assert_called_once() mock_container2.update.assert_called_once() # Verify controls_row is modified