Compare commits

..
Author SHA1 Message Date
Tirsvad 736729c99f Resolve pending commit links of MIL-002 2026-10-14 21:53:12 +08:00
Tirsvad 019565635d 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
2026-10-14 21:53:07 +08:00
Tirsvad aecf7ad43c Merge pull request 'MIL-001: Project foundation' (#38) from mil-001-foundation into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-14 15:39:32 +02:00
18 changed files with 829 additions and 33 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| DCD | Design Class Diagram | docs/dcd.md | 002 | | DCD | Design Class Diagram | docs/dcd.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | | 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 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
## Languages ## Languages
+64
View File
@@ -0,0 +1,64 @@
# 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 | [0195656] |
---
## 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
[0195656]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/019565635df17077c937b9e54685cd3d7bdb1e3e
+3
View File
@@ -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-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] | | 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-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] |
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] |
## Coverage Notes ## Coverage Notes
@@ -102,3 +103,5 @@ updated whenever an artifact instance is created or reviewed.
[RC-022]: ./reviews/rc-022-mil-007.md [RC-022]: ./reviews/rc-022-mil-007.md
[RC-023]: ./reviews/rc-023-py-mil-001.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 [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
+20 -4
View File
@@ -1,21 +1,37 @@
"""Entry point of the flash card program. """Entry point and composition root of the flash card program.
@file app.py @file app.py
@brief Starts the program; the window arrives in MIL-002. @brief Creates the window, its view model and view, and runs the main loop.
""" """
import tkinter as tk
from collections.abc import Sequence from collections.abc import Sequence
from typing import NoReturn 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: def main(argv: Sequence[str] | None = None) -> int:
"""Run the program and return its exit code. """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. @param argv Command line arguments, or None for none.
@return The exit code, 0 on success. @return The exit code, 0 on success.
""" """
root, _view = create_application()
root.mainloop()
return 0 return 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1 @@
"""Features of the presentation layer, one folder each."""
@@ -0,0 +1 @@
"""The flash card feature: its view and view model."""
@@ -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)
@@ -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
+105
View File
@@ -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"))
+20 -4
View File
@@ -1,11 +1,27 @@
"""Tests of the entry point. """Tests of the entry point.
@file test_app.py @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: class _FakeRoot:
assert main([]) == 0 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
+18
View File
@@ -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
+58
View File
@@ -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)
+393
View File
@@ -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())