From 019565635df17077c937b9e54685cd3d7bdb1e3e Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 14 Oct 2026 21:53:07 +0800 Subject: [PATCH 1/2] MIL-002: Step 1 - User interface Review record RC-024 (Go, autonomous mode, not independent). Task: MIL-002#1 Task: MIL-002#2 Task: MIL-002#3 Task: MIL-002#4 --- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-024-py-mil-002.md | 63 +++ docs/sqa/traceability-matrix.md | 3 + src/flash_card/presentation/app.py | 72 ++-- src/flash_card/presentation/assets/.gitkeep | 0 .../presentation/assets/card_back.png | Bin 0 -> 8293 bytes .../presentation/assets/card_front.png | Bin 0 -> 8430 bytes src/flash_card/presentation/assets/right.png | Bin 0 -> 1810 bytes src/flash_card/presentation/assets/wrong.png | Bin 0 -> 1569 bytes .../presentation/features/__init__.py | 1 + .../features/flash_card/__init__.py | 1 + .../features/flash_card/flash_card_view.py | 94 +++++ .../flash_card/flash_card_view_model.py | 27 ++ tests/e2e/test_flash_card_window.py | 105 +++++ tests/unit/test_app.py | 24 +- tests/unit/test_flash_card_view_model.py | 18 + tests/unit/test_generate_images.py | 58 +++ tools/generate_images.py | 393 ++++++++++++++++++ 18 files changed, 828 insertions(+), 33 deletions(-) create mode 100644 docs/sqa/reviews/rc-024-py-mil-002.md delete mode 100644 src/flash_card/presentation/assets/.gitkeep create mode 100644 src/flash_card/presentation/assets/card_back.png create mode 100644 src/flash_card/presentation/assets/card_front.png create mode 100644 src/flash_card/presentation/assets/right.png create mode 100644 src/flash_card/presentation/assets/wrong.png create mode 100644 src/flash_card/presentation/features/__init__.py create mode 100644 src/flash_card/presentation/features/flash_card/__init__.py create mode 100644 src/flash_card/presentation/features/flash_card/flash_card_view.py create mode 100644 src/flash_card/presentation/features/flash_card/flash_card_view_model.py create mode 100644 tests/e2e/test_flash_card_window.py create mode 100644 tests/unit/test_flash_card_view_model.py create mode 100644 tests/unit/test_generate_images.py create mode 100644 tools/generate_images.py 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 0000000000000000000000000000000000000000..6deeae43020c817bb0da2459ee5fb21bfedb3324 GIT binary patch literal 8293 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..72a652fc8411b03ade6ec36460d19af1ce2938a5 GIT binary patch literal 8430 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 LW4)rEeu?-G^Xqjb literal 0 HcmV?d00001 diff --git a/src/flash_card/presentation/assets/right.png b/src/flash_card/presentation/assets/right.png new file mode 100644 index 0000000000000000000000000000000000000000..1c7af3a4ec90e0110bba77ff39d0433211136a47 GIT binary patch literal 1810 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 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()) From 736729c99fc3fae97e4fea9359f2011f3e77c548 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 14 Oct 2026 21:53:12 +0800 Subject: [PATCH 2/2] Resolve pending commit links of MIL-002 --- docs/sqa/reviews/rc-024-py-mil-002.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-024-py-mil-002.md b/docs/sqa/reviews/rc-024-py-mil-002.md index ac04e7e..3a171dd 100644 --- a/docs/sqa/reviews/rc-024-py-mil-002.md +++ b/docs/sqa/reviews/rc-024-py-mil-002.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [0195656] | --- @@ -61,3 +61,4 @@ Milestone MIL-002 Go criteria: 1 title, colour and padding asserted by the e2e t [PY-MIL-002]: ../../../src/flash_card/presentation [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[0195656]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/019565635df17077c937b9e54685cd3d7bdb1e3e