Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
736729c99f | ||
|
|
019565635d | ||
|
|
aecf7ad43c |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
"""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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Sequence
|
import tkinter as tk
|
||||||
from typing import NoReturn
|
from collections.abc import Sequence
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> int:
|
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||||
"""Run the program and return its exit code.
|
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||||
|
FlashCardViewModel,
|
||||||
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.
|
def create_application() -> tuple[tk.Tk, FlashCardView]:
|
||||||
"""
|
"""Create the root window and the flash card view.
|
||||||
return 0
|
|
||||||
|
@return The root window and the view built on it.
|
||||||
|
"""
|
||||||
def run() -> NoReturn:
|
root = tk.Tk()
|
||||||
"""Run the program and exit the interpreter with its exit code.
|
view = FlashCardView(root, FlashCardViewModel())
|
||||||
|
return root, view
|
||||||
@return Never returns; the function always raises SystemExit.
|
|
||||||
@throws SystemExit Always, with the exit code of main.
|
|
||||||
"""
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
raise SystemExit(main())
|
"""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())
|
||||||
|
|||||||
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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user