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
Tirsvad 31ae445f5c Resolve pending commit link of RC-023 2026-10-14 21:39:22 +08:00
Tirsvad cc24b7d890 MIL-001: project foundation
pyproject.toml (Python >=3.13, no runtime dependencies, speech and dev extras),
Python .gitignore, layer skeleton with constants.py, Doxyfile, README with the
.venv instructions, the Malay to Danish word list and review record RC-023.

Task: MIL-001#1
Task: MIL-001#2
Task: MIL-001#3
Task: MIL-001#4
Task: MIL-001#5
Task: MIL-001#6
2026-10-14 21:39:14 +08:00
Tirsvad 934d7e4902 Merge pull request 'Plan baseline: business case, stakeholders, plan, design, milestones' (#1) from baseline-plan into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
2026-10-14 15:34:07 +02:00
34 changed files with 2389 additions and 2 deletions
+77
View File
@@ -0,0 +1,77 @@
# Local secrets: tokens are for personal use and are never part of the project
.env
.env.*
# Learner's own progress (created by the program on the first "known" answer)
data/words_to_learn.csv
# Virtual environments
.venv/
venv/
env/
# Generated documentation (doxygen Doxyfile)
docs/doxygen/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
# Type checkers and linters
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
.ruff_cache/
.import_linter_cache/
# Jupyter, IDEs and operating systems
.ipynb_checkpoints
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
+36
View File
@@ -0,0 +1,36 @@
# Doxygen configuration for the flash card program.
# Build: doxygen Doxyfile (output: docs/doxygen/html/index.html)
# Only the settings that differ from the Doxygen defaults are listed.
PROJECT_NAME = "Flash Card"
PROJECT_BRIEF = "Malay to Danish flash cards (Udemy 100 Days of Code, day 31)"
PROJECT_NUMBER = 0.1.0
OUTPUT_DIRECTORY = docs/doxygen
CREATE_SUBDIRS = NO
INPUT = src
FILE_PATTERNS = *.py
RECURSIVE = YES
EXCLUDE_PATTERNS = */__pycache__/* */.venv/*
# Python
OPTIMIZE_OUTPUT_JAVA = YES
PYTHON_DOCSTRING = NO
EXTRACT_ALL = NO
EXTRACT_PRIVATE = NO
EXTRACT_STATIC = YES
JAVADOC_AUTOBRIEF = YES
AUTOLINK_SUPPORT = YES
MARKDOWN_SUPPORT = YES
# Diagrams need Graphviz; leave them off so the build works everywhere.
HAVE_DOT = NO
# Output
GENERATE_HTML = YES
GENERATE_LATEX = NO
SOURCE_BROWSER = YES
QUIET = YES
WARN_IF_UNDOCUMENTED = YES
WARN_IF_DOC_ERROR = YES
WARN_NO_PARAMDOC = YES
+131 -1
View File
@@ -1,2 +1,132 @@
# 031-flash-card
# 🚀 Flash Card
A flash card program for learning the most frequent Malay words with their Danish translations. It is the solution of day 31 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written for course participants who want to compare solutions and for visitors who browse the repository for ideas.
## 📚 Table of Contents
- [Overview](#-overview)
- [Requirements](#-requirements)
- [Setup](#-setup)
- [Run](#-run)
- [Tests](#-tests)
- [License](#-license)
- [Links](#-links)
- [Appendix: Project documents](#-appendix-project-documents)
## 🧭 Overview
The program shows a Malay word on a card. After three seconds the card turns over and shows the Danish translation. You press the green tick when you know the word and the red cross when you do not. A known word is removed from the words to learn, and the words still to learn are saved, so the next start continues where you stopped.
| Part | What it is for |
| --- | --- |
| `src/flash_card/` | The program, in four layers: `domain`, `application`, `infrastructure`, `presentation` |
| `tests/` | `pytest` tests: `unit`, `integration`, `e2e` |
| `data/` | The word list `ms-da.csv` and, after the first known word, your own `words_to_learn.csv` |
| `docs/` | Project documents (business case, plan, design, review records) |
| `Doxyfile` | Doxygen configuration for the source code documentation |
The core program uses only the Python standard library. Spoken Malay words are an optional extra.
## 📋 Requirements
| Need | Why |
| --- | --- |
| Python 3.13 or later, with Tkinter | The program and its window (Tkinter is part of the standard Python installers for Windows and macOS; on Debian or Ubuntu install `python3-tk`) |
| `git` | To clone the repository |
| Doxygen (optional) | To build the source code documentation |
| `gTTS` and `playsound==1.2.2` (optional, extra `speech`) | To hear the Malay word; needs a network connection |
## 🛠️ Setup
Create a virtual environment in `.venv`, activate it, upgrade pip and install the project. Run the commands in the repository folder.
Windows (PowerShell):
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Linux and macOS:
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
- Use `python -m pip install -e .` if you only want to run the program; the `dev` extra adds `pytest`, `ruff`, `mypy` and `import-linter`.
- Add the spoken word with `python -m pip install -e ".[speech]"`.
- If PowerShell refuses to run `Activate.ps1`, run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once, or use `.\.venv\Scripts\activate.bat` in `cmd`.
- Leave the environment with `deactivate`. Delete the `.venv` folder to start over.
## ▶️ Run
```bash
python -m flash_card
```
The program is run from the activated `.venv`. The window opens with the first card; the program is built step by step and the pull requests and milestones show each step.
## 🧪 Tests
```bash
python -m pytest
```
Quality checks used by the project:
```bash
python -m ruff check src tests
python -m mypy
lint-imports
```
The window test in `tests/e2e/` is skipped when no display is available.
## 📄 License
GNU Affero General Public License v3.0 or later. See [LICENSE][license].
## 🔗 Links
- [Repository][repository]
- [Documentation][documentation]
- [Issue tracker][issues]
## 📎 Appendix: Project documents
The documents a newcomer needs, in reading order.
| Document | What it tells you |
| --- | --- |
| [AGENTS.md][agents] | The rules AI agents follow in this repository |
| [Business Case][BC-001] | Why the project exists, its scope and success criteria |
| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers |
| [Project Plan][PP-001] | The gateways and their schedule |
| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks |
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
| [Domain dictionary][DICT-001] | The PO terms and the IT terms |
| [Traceability matrix][TM-001] | How the artifacts link and when each was last reviewed |
| [Framework README][framework-readme] | The framework this project uses |
| [Framework changelog][framework-changelog] | What changed in each framework release |
---
[license]: ./LICENSE
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/issues
[agents]: ./AGENTS.md
[BC-001]: ./docs/business-case.md
[SA-001]: ./docs/stakeholder-analysis.md
[PP-001]: ./docs/project-plan.md
[milestones]: ./docs/milestones/
[registry]: ./docs/artifact-registry.md
[DICT-001]: ./docs/dictionary.md
[TM-001]: ./docs/sqa/traceability-matrix.md
[framework-readme]: ./framework/README.md
[framework-changelog]: ./framework/CHANGELOG.md
+21
View File
@@ -0,0 +1,21 @@
# Data
| File | Content | In git |
| --- | --- | --- |
| `ms-da.csv` | The word list: 1000 of the most frequent Malay words with a Danish translation | yes |
| `words_to_learn.csv` | The words you have not marked as known; created by the program | no (`.gitignore`) |
## Format
- UTF-8 text, one card per line, cells separated by a semicolon (`;`).
- The first line is a header with the two language codes: `ms;da` (Malay, Danish).
- Blank lines are ignored.
- `words_to_learn.csv` has the same format, so it can be used as a word list.
## Origin
The Malay words are a frequency list built from film and series subtitles (the *FrequencyWords* lists by the GitHub user `hermitdave`). The Danish column was filled with machine translation in a spreadsheet, as the course suggests. A word can therefore have a translation that does not fit its use; edit the file if you find one.
## Start again
Delete `words_to_learn.csv`. The next start uses `ms-da.csv`.
+1001
View File
File diff suppressed because it is too large Load Diff
+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 |
| 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 | 023 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 025 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
## Languages
+64
View File
@@ -0,0 +1,64 @@
# RC-023: Review of PY-MIL-001
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-023 |
| CrossReference | [PY-MIL-001], [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 | [cc24b7d] |
---
## Artifact Under Review
- Instance reviewed: [PY-MIL-001]
- 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, functions and constants use snake_case and UPPER_SNAKE; ruff rule set N passes. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`FLIP_DELAY_MS`, `DEFAULT_DATA_DIR`); no abbreviations. |
| 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 function has annotated parameters and return type; `mypy --strict` passes on 9 files. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling exists yet; no bare except. |
| 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 | N-A | No file or connection is opened yet. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Every module and function has a Doxygen docstring with `@file`/`@brief`/`@param`/`@return`; `doxygen Doxyfile` ends with no warning. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print`; no logging needed yet. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `app.main` and `app.run` are the `app.main` of DCD-001; constants have no design class. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | `tests/unit/test_constants.py` and `test_app.py` (7 tests) name the behaviour and use no network. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; no `Any` is used. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` is empty; extras are declared in `pyproject.toml` with version ranges, and `playsound` is pinned. |
| 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 | Every constant is defined once in `constants.py`. |
| 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 the contract `Layers point inward` kept. |
| 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 | Modules are small and single-purpose; nothing to extend yet. |
| 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 | `domain`, `application`, `infrastructure`, `presentation` packages exist under `src/flash_card/`, and `tests/unit`, `integration`, `e2e` exist. |
| 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 lower snake_case with no space. |
## Overall Verdict
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
Milestone MIL-001 Go criteria: 1 `pip install -e ".[dev]"` succeeded in a fresh `.venv`; 2 `git check-ignore` reports `.venv/`, `.env`, `data/words_to_learn.csv`, `docs/doxygen/`; 3 packages and test folders exist; 4 `doxygen Doxyfile` exit 0 with no warning; 5 `check-readme.sh` exit 0; 6 `pytest` 7 passed. All six Go.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-001]: ../../../src/flash_card
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[cc24b7d]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/cc24b7d890dbef41c303ad14aea4119cd505c51d
+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
+6
View File
@@ -45,6 +45,8 @@ updated whenever an artifact instance is created or reviewed.
| MIL-005 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-005 | [RC-020] |
| 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
@@ -76,6 +78,7 @@ updated whenever an artifact instance is created or reviewed.
[MIL-005]: ../milestones/mil-005-step4-save-progress.md
[MIL-006]: ../milestones/mil-006-malay-speech.md
[MIL-007]: ../milestones/mil-007-documentation-and-publication.md
[PY-MIL-001]: ../../src/flash_card
[RC-001]: ./reviews/rc-001-business-case.md
[RC-002]: ./reviews/rc-002-stakeholder-analysis.md
[RC-003]: ./reviews/rc-003-project-plan.md
@@ -98,4 +101,7 @@ updated whenever an artifact instance is created or reviewed.
[RC-020]: ./reviews/rc-020-mil-005.md
[RC-021]: ./reviews/rc-021-mil-006.md
[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
+75
View File
@@ -0,0 +1,75 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "flash-card"
version = "0.1.0"
description = "Flash card program for learning Malay words with Danish translations (Udemy 100 Days of Code, day 31)"
readme = "README.md"
requires-python = ">=3.13"
license = "AGPL-3.0-or-later"
license-files = ["LICENSE"]
authors = [{ name = "Jens Tirsvad Nielsen" }]
keywords = ["flashcards", "tkinter", "language-learning", "malay", "danish", "udemy"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
"Environment :: X11 Applications",
"Topic :: Education",
]
# The core program uses the Python standard library only (ADR-0001).
dependencies = []
[project.optional-dependencies]
# Spoken Malay word (text to speech). Optional; the program works without it.
speech = ["gTTS>=2.5", "playsound==1.2.2"]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "import-linter>=2.0"]
[project.scripts]
flash-card = "flash_card.presentation.app:run"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
flash_card = ["presentation/assets/*.png"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra"
[tool.ruff]
line-length = 88
target-version = "py313"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "N", "D"]
ignore = ["D203", "D213"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D"]
[tool.ruff.lint.pydocstyle]
convention = "pep257"
[tool.mypy]
python_version = "3.13"
strict = true
mypy_path = ["src"]
packages = ["flash_card"]
[tool.importlinter]
root_package = "flash_card"
[[tool.importlinter.contracts]]
name = "Layers point inward"
type = "layers"
layers = [
"flash_card.presentation",
"flash_card.infrastructure",
"flash_card.application",
"flash_card.domain",
]
+7
View File
@@ -0,0 +1,7 @@
"""Flash card program that teaches Malay words with Danish translations.
@file __init__.py
@brief Package root of the flash card program.
"""
__version__ = "0.1.0"
+10
View File
@@ -0,0 +1,10 @@
"""Run the flash card program with ``python -m flash_card``.
@file __main__.py
@brief Module entry point that calls the presentation layer.
"""
from flash_card.presentation.app import run
if __name__ == "__main__":
run()
+5
View File
@@ -0,0 +1,5 @@
"""Application layer: use cases and ports.
@file __init__.py
@brief Holds use cases and the ports they need; may import only the domain layer.
"""
+74
View File
@@ -0,0 +1,74 @@
"""All constants of the flash card program, defined once.
@file constants.py
@brief Window, card, font, language, file and asset constants.
Imports only the standard library.
"""
from collections.abc import Mapping
from pathlib import Path
from typing import Final
## @brief Window background colour.
BACKGROUND_COLOR: Final[str] = "#B1DDC6"
## @brief Window title.
WINDOW_TITLE: Final[str] = "Flashy"
## @brief Padding around the card in pixels.
WINDOW_PADDING: Final[int] = 50
## @brief Card width in pixels.
CARD_WIDTH: Final[int] = 800
## @brief Card height in pixels.
CARD_HEIGHT: Final[int] = 526
## @brief Horizontal centre of the card in pixels.
CARD_CENTER_X: Final[int] = 400
## @brief Vertical position of the language text in pixels.
LANGUAGE_TEXT_Y: Final[int] = 150
## @brief Vertical position of the word text in pixels.
WORD_TEXT_Y: Final[int] = 263
## @brief Delay before the card flips, in milliseconds.
FLIP_DELAY_MS: Final[int] = 3000
## @brief Font of the language text: family, size, style.
LANGUAGE_FONT: Final[tuple[str, int, str]] = ("Arial", 40, "italic")
## @brief Font of the word text: family, size, style.
WORD_FONT: Final[tuple[str, int, str]] = ("Arial", 60, "bold")
## @brief Text colour on the card front.
FRONT_TEXT_COLOR: Final[str] = "black"
## @brief Text colour on the card back.
BACK_TEXT_COLOR: Final[str] = "white"
## @brief Display names by language code; the caller shows unknown codes in upper case.
LANGUAGE_NAMES: Final[Mapping[str, str]] = {"ms": "Malay", "da": "Danish"}
## @brief Message shown when no word is left to learn.
FINISHED_MESSAGE: Final[str] = "Nothing left to learn"
## @brief Language name shown before the first card.
PLACEHOLDER_LANGUAGE_NAME: Final[str] = "Malay"
## @brief Word shown before the first card.
PLACEHOLDER_WORD: Final[str] = "kata"
## @brief Delimiter of the CSV files.
CSV_DELIMITER: Final[str] = ";"
## @brief Encoding of the CSV files.
CSV_ENCODING: Final[str] = "utf-8"
## @brief File name of the vocabulary.
VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
## @brief File name of the words still to learn.
PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv"
## @brief Language code used for speech.
SPEECH_LANGUAGE_CODE: Final[str] = "ms"
## @brief Root folder of the project.
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
## @brief Default folder of the data files.
DEFAULT_DATA_DIR: Final[Path] = PROJECT_ROOT / "data"
## @brief Folder of the image assets.
ASSETS_DIR: Final[Path] = Path(__file__).resolve().parent / "presentation" / "assets"
## @brief File name of the card front image.
CARD_FRONT_IMAGE_NAME: Final[str] = "card_front.png"
## @brief File name of the card back image.
CARD_BACK_IMAGE_NAME: Final[str] = "card_back.png"
## @brief File name of the right button image.
RIGHT_IMAGE_NAME: Final[str] = "right.png"
## @brief File name of the wrong button image.
WRONG_IMAGE_NAME: Final[str] = "wrong.png"
+6
View File
@@ -0,0 +1,6 @@
"""Domain layer: entities and rules of the flash card program.
@file __init__.py
@brief Holds entities, value objects and rules.
May import nothing outside the standard library.
"""
@@ -0,0 +1,5 @@
"""Infrastructure layer: adapters for files, speech and other outside systems.
@file __init__.py
@brief Holds implementations of ports; may import the application and domain layers.
"""
+5
View File
@@ -0,0 +1,5 @@
"""Presentation layer: views, view models and the entry point.
@file __init__.py
@brief Holds the user interface and the composition root; may import every inner layer.
"""
+44
View File
@@ -0,0 +1,44 @@
"""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())
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
View File
+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"))
View File
+27
View File
@@ -0,0 +1,27 @@
"""Tests of the entry point.
@file test_app.py
@brief Checks main of flash_card.presentation.app without opening a window.
"""
import pytest
from flash_card.presentation import app
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
+33
View File
@@ -0,0 +1,33 @@
"""Tests of the constants module.
@file test_constants.py
@brief Checks the key values of flash_card.constants.
"""
from flash_card import constants
def test_flip_delay_ms_is_three_seconds() -> None:
assert constants.FLIP_DELAY_MS == 3000
def test_background_color_is_green() -> None:
assert constants.BACKGROUND_COLOR == "#B1DDC6"
def test_default_data_dir_ends_with_data() -> None:
assert constants.DEFAULT_DATA_DIR.name == "data"
def test_file_names_are_csv() -> None:
assert constants.VOCABULARY_FILE_NAME.endswith(".csv")
assert constants.PROGRESS_FILE_NAME.endswith(".csv")
def test_language_names_cover_ms_and_da() -> None:
assert "ms" in constants.LANGUAGE_NAMES
assert "da" in constants.LANGUAGE_NAMES
def test_project_root_contains_pyproject() -> None:
assert (constants.PROJECT_ROOT / "pyproject.toml").is_file()
+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())