MIL-008: Choose level

Review record RC-041 (Go, autonomous mode, not independent).

Task: MIL-008#1
Task: MIL-008#2
Task: MIL-008#3
Task: MIL-008#4
Task: MIL-008#5
Task: MIL-008#6
Task: MIL-008#7
Task: MIL-008#8
This commit is contained in:
2026-10-14 23:58:31 +08:00
parent ee9c531ea6
commit f45d6036e4
40 changed files with 1483 additions and 102 deletions
@@ -12,6 +12,7 @@ from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.repositories.progress_repository import ProgressRepository
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
from flash_card.domain.value_objects.level import Level
def open_study_session(
@@ -19,18 +20,24 @@ def open_study_session(
progress_repository: ProgressRepository,
speech: SpeechPort,
choose: Callable[[Sequence[Card]], Card],
level: Level,
) -> StudySession:
"""Build a session over the saved words to learn, else the whole word list.
The word list is always loaded: its order defines which words a level holds.
@param vocabulary_repository Source of the delivered word list.
@param progress_repository Store of the words still to learn.
@param speech Speaks the front text of each drawn card.
@param choose Picks one card from a non-empty sequence.
@param level The level: the first words of the list that may be drawn.
@return The new session.
@throws VocabularyError When the saved progress, or the word list when no
progress exists, cannot be loaded.
@throws VocabularyError When the word list or the saved progress cannot be
loaded.
"""
vocabulary = vocabulary_repository.load()
deck = progress_repository.load()
if deck is None:
deck = Deck.from_vocabulary(vocabulary_repository.load())
return StudySession(deck, choose, progress_repository, speech)
deck = Deck.from_vocabulary(vocabulary)
level_cards = level.first(vocabulary.cards)
return StudySession(deck, choose, progress_repository, speech, level_cards)
@@ -22,6 +22,7 @@ class StudySession:
choose: Callable[[Sequence[Card]], Card],
progress: ProgressRepository,
speech: SpeechPort,
level_cards: Sequence[Card] | None = None,
) -> None:
"""Create a session.
@@ -29,7 +30,10 @@ class StudySession:
@param choose Picks one card from a non-empty sequence.
@param progress Store of the words still to learn.
@param speech Speaks the front text of each drawn card.
@param level_cards The cards of the chosen level, or None for no limit.
"""
## @brief The cards of the chosen level, or None for no limit.
self._level_cards = None if level_cards is None else tuple(level_cards)
## @brief Speaks the front text of each drawn card.
self._speech = speech
## @brief Store of the words still to learn.
@@ -51,27 +55,36 @@ class StudySession:
@property
def remaining(self) -> int:
"""Return the number of cards still to learn.
"""Return the number of cards still to learn, of every level.
@return The card count.
"""
return len(self._deck)
@property
def remaining_in_level(self) -> int:
"""Return the number of cards still to learn in the chosen level.
@return The card count.
"""
return len(self._candidates())
def next_card(self) -> CardDto | None:
"""Draw the next card, different from the current one when possible.
@return The new card, or None when the deck is empty.
@return The new card, or None when no card of the level is left.
"""
if self._deck.is_empty:
candidates = self._candidates()
if not candidates:
self._current = None
return None
card = self._deck.draw(self._choose, exclude=self._current)
card = self._deck.draw(self._choose, self._current, candidates)
self._current = card
self._speech.speak(card.front, self._deck.language_pair.front_code)
return self._to_dto(card)
def mark_known(self) -> None:
"""Remove the current card from the deck and save the remaining cards.
"""Remove the current card from the whole deck and save the whole deck.
Does nothing and saves nothing when no card is shown.
@@ -83,6 +96,15 @@ class StudySession:
self._progress.save(self._deck)
self._current = None
def _candidates(self) -> tuple[Card, ...]:
"""Return the cards that may be drawn.
@return The deck cards of the level, or all deck cards without a level.
"""
if self._level_cards is None:
return self._deck.cards
return self._deck.within(self._level_cards)
def _to_dto(self, card: Card | None) -> CardDto | None:
"""Map a card to its DTO.
+25
View File
@@ -20,6 +20,8 @@ WINDOW_PADDING: Final[int] = 50
CARD_WIDTH: Final[int] = 800
## @brief Card height in pixels.
CARD_HEIGHT: Final[int] = 526
## @brief Level screen height in pixels: card plus the card window button row.
LEVEL_SCREEN_HEIGHT: Final[int] = 526 + 100
## @brief Horizontal centre of the card in pixels.
CARD_CENTER_X: Final[int] = 400
## @brief Vertical position of the language text in pixels.
@@ -43,6 +45,29 @@ 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 Title of the level screen.
LEVEL_TITLE: Final[str] = "Choose your level"
## @brief Level choices in display order: label and word limit (None = all words).
LEVEL_CHOICES: Final[tuple[tuple[str, int | None], ...]] = (
("100 words", 100),
("1000 words", 1000),
("All words", None),
)
## @brief Values of the --level option and the word limit each stands for.
LEVEL_ARGUMENT_VALUES: Final[Mapping[str, int | None]] = {
"100": 100,
"1000": 1000,
"all": None,
}
## @brief Font of the level screen title: family, size, style.
LEVEL_TITLE_FONT: Final[tuple[str, int, str]] = ("Arial", 40, "italic")
## @brief Font of the level buttons: family, size, style.
LEVEL_BUTTON_FONT: Final[tuple[str, int, str]] = ("Arial", 24, "bold")
## @brief Width of every level button in characters.
LEVEL_BUTTON_WIDTH: Final[int] = 14
## @brief Background colour of the level buttons.
LEVEL_BUTTON_COLOR: Final[str] = "white"
## @brief Delimiter of the CSV files.
CSV_DELIMITER: Final[str] = ";"
## @brief Encoding of the CSV files; drops a leading byte order mark.
+25 -5
View File
@@ -4,6 +4,7 @@
@brief The Deck entity: holds cards, draws one and removes one.
"""
from collections import Counter
from collections.abc import Callable, Iterable, Sequence
from flash_card.domain.entities.card import Card
@@ -66,24 +67,43 @@ class Deck:
"""
return len(self._cards)
def within(self, cards: Sequence[Card]) -> tuple[Card, ...]:
"""Select the cards of the deck that also appear in the given cards.
A card counts as often as it appears in both.
@param cards The cards to match against, for example a level.
@return The matching cards in deck order.
"""
allowed = Counter(cards)
selected: list[Card] = []
for card in self._cards:
if allowed[card] > 0:
allowed[card] -= 1
selected.append(card)
return tuple(selected)
def draw(
self,
choose: Callable[[Sequence[Card]], Card],
exclude: Card | None = None,
among: Sequence[Card] | None = None,
) -> Card:
"""Pick a card without removing it.
The pick avoids the excluded card when another card exists.
The pick avoids the excluded card when another candidate exists.
@param choose Picks one card from a non-empty sequence.
@param exclude A card to avoid, usually the one shown now.
@param among The candidates, or None for every card of the deck.
@return The picked card.
@throws EmptyDeckError When the deck has no card.
@throws EmptyDeckError When there is no candidate.
"""
if not self._cards:
candidates = self._cards if among is None else list(among)
if not candidates:
raise EmptyDeckError("cannot draw from an empty deck")
others = [card for card in self._cards if card != exclude]
return choose(others or self._cards)
others = [card for card in candidates if card != exclude]
return choose(others or candidates)
def remove(self, card: Card) -> None:
"""Remove the first card equal to the given one.
@@ -0,0 +1,38 @@
"""The level of a study session.
@file level.py
@brief The Level value object: how many of the first words a session uses.
"""
from collections.abc import Sequence
from dataclasses import dataclass
from flash_card.domain.entities.card import Card
@dataclass(frozen=True, slots=True)
class Level:
"""A limit on the words of the list, counted from the most frequent."""
## @brief Number of first words used, or None for all words.
limit: int | None
def __post_init__(self) -> None:
"""Check the limit.
@return Nothing.
@throws ValueError When the limit is set and not above zero.
"""
if self.limit is not None and self.limit <= 0:
raise ValueError(f"level limit must be above zero, got {self.limit}")
def first(self, cards: Sequence[Card]) -> tuple[Card, ...]:
"""Select the cards of this level.
@param cards The word list in order from the most frequent word.
@return The first limit cards, or all cards when there is no limit or
the list is shorter.
"""
if self.limit is None:
return tuple(cards)
return tuple(cards[: self.limit])
+145 -16
View File
@@ -1,7 +1,7 @@
"""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.
@brief Creates the window, shows the level screen and then the card screen.
"""
import argparse
@@ -9,7 +9,7 @@ import importlib.util
import logging
import random
import tkinter as tk
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import NoReturn
@@ -17,10 +17,15 @@ from flash_card.application.ports.speech_port import SpeechPort
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.constants import (
DEFAULT_DATA_DIR,
LEVEL_ARGUMENT_VALUES,
PROGRESS_FILE_NAME,
VOCABULARY_FILE_NAME,
)
from flash_card.domain.entities.card import Card
from flash_card.domain.errors.vocabulary_errors import VocabularyError
from flash_card.domain.repositories.progress_repository import ProgressRepository
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
from flash_card.domain.value_objects.level import Level
from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
)
@@ -33,6 +38,8 @@ from flash_card.presentation.features.flash_card.flash_card_view import FlashCar
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
)
from flash_card.presentation.features.level.level_view import LevelView
from flash_card.presentation.features.level.level_view_model import LevelViewModel
## @brief Logger of the entry point.
_logger = logging.getLogger(__name__)
@@ -55,26 +62,141 @@ def build_speech(enabled: bool) -> SpeechPort:
return NullSpeech()
class Application:
"""The running program: one window that shows the level screen, then the card."""
def __init__(
self,
root: tk.Tk,
vocabulary_repository: VocabularyRepository,
progress_repository: ProgressRepository,
speech: SpeechPort,
choose: Callable[[Sequence[Card]], Card] = random.choice,
) -> None:
"""Create the application on a window; no screen is shown yet.
@param root The Tk root window.
@param vocabulary_repository Source of the delivered word list.
@param progress_repository Store of the words still to learn.
@param speech Speaks the front text of each drawn card.
@param choose Picks one card from a non-empty sequence.
"""
## @brief The Tk root window.
self.root = root
## @brief Source of the delivered word list.
self._vocabulary_repository = vocabulary_repository
## @brief Store of the words still to learn.
self._progress_repository = progress_repository
## @brief Speaks the front text of each drawn card.
self._speech = speech
## @brief Picks one card from a non-empty sequence.
self._choose = choose
## @brief The screen shown now, or None before the first one.
self._view: LevelView | FlashCardView | None = None
## @brief The exit code of the program.
self._exit_code = 0
@property
def view(self) -> LevelView | FlashCardView | None:
"""Return the screen shown now.
@return The level screen, the card screen, or None before the first.
"""
return self._view
@property
def exit_code(self) -> int:
"""Return the exit code the program ends with.
@return 0, or 1 when a session could not be started after a choice.
"""
return self._exit_code
def show_level_screen(self) -> None:
"""Show the level screen; a choice starts the session.
@return Nothing.
"""
self._view = LevelView(self.root, LevelViewModel(), self._choose_level)
def start(self, level: Level) -> None:
"""Open the session of a level and show the card screen on the window.
@param level The level the user chose.
@return Nothing.
@throws VocabularyError When the word list or saved progress is unusable.
"""
session = open_study_session(
self._vocabulary_repository,
self._progress_repository,
self._speech,
self._choose,
level,
)
if isinstance(self._view, LevelView):
self._view.destroy()
self._view = FlashCardView(self.root, FlashCardViewModel(session))
def _choose_level(self, level: Level) -> None:
"""Start the session after a click; close the window when it fails.
@param level The level the user chose.
@return Nothing.
"""
try:
self.start(level)
except VocabularyError as error:
_logger.error("%s", error)
self._exit_code = 1
self.root.destroy()
def create_application(
data_dir: Path | None = None,
speech_enabled: bool = True,
) -> tuple[tk.Tk, FlashCardView]:
"""Create the root window and the flash card view.
level: Level | None = None,
) -> Application:
"""Create the window and show the level screen, or start at the given level.
@param data_dir Folder of the data files, or None for the default folder.
@param speech_enabled False to run without spoken words.
@return The root window and the view built on it.
@throws VocabularyError When the word list or saved progress is unusable.
@param level The level to start at, or None to show the level screen.
@return The application, its window in the root attribute.
@throws VocabularyError When a level is given and the word list or saved
progress is unusable.
"""
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME)
speech = build_speech(speech_enabled)
session = open_study_session(repository, progress, speech, random.choice)
view_model = FlashCardViewModel(session)
root = tk.Tk()
view = FlashCardView(root, view_model)
return root, view
application = Application(
root,
CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME),
CsvProgressRepository(folder / PROGRESS_FILE_NAME),
build_speech(speech_enabled),
)
if level is None:
application.show_level_screen()
return application
try:
application.start(level)
except VocabularyError:
root.destroy()
raise
return application
def _level_argument(value: str) -> Level:
"""Convert a --level value to a level.
@param value One of the keys of LEVEL_ARGUMENT_VALUES.
@return The level.
@throws argparse.ArgumentTypeError When the value is unknown.
"""
if value not in LEVEL_ARGUMENT_VALUES:
choices = ", ".join(LEVEL_ARGUMENT_VALUES)
raise argparse.ArgumentTypeError(
f"invalid level {value!r} (choose from {choices})"
)
return Level(LEVEL_ARGUMENT_VALUES[value])
def main(argv: Sequence[str] | None = None) -> int:
@@ -95,14 +217,21 @@ def main(argv: Sequence[str] | None = None) -> int:
action="store_true",
help="do not speak the words",
)
parser.add_argument(
"--level",
type=_level_argument,
default=None,
metavar="{" + ",".join(LEVEL_ARGUMENT_VALUES) + "}",
help="start at a level (100, 1000 or all words) and skip the level screen",
)
args = parser.parse_args(argv)
try:
root, _view = create_application(args.data_dir, not args.no_speech)
application = create_application(args.data_dir, not args.no_speech, args.level)
except VocabularyError as error:
_logger.error("%s", error)
return 1
root.mainloop()
return 0
application.root.mainloop()
return application.exit_code
def run() -> NoReturn:
@@ -20,8 +20,6 @@ from flash_card.constants import (
LANGUAGE_FONT,
LANGUAGE_TEXT_Y,
RIGHT_IMAGE_NAME,
WINDOW_PADDING,
WINDOW_TITLE,
WORD_FONT,
WORD_TEXT_Y,
WRONG_IMAGE_NAME,
@@ -30,6 +28,7 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
Side,
)
from flash_card.presentation.shared.window import configure_window
def _load_image(root: tk.Tk, name: str) -> tk.PhotoImage:
@@ -60,8 +59,7 @@ class FlashCardView:
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)
configure_window(root)
# Kept as attributes: Tkinter drops an image nothing refers to.
## @brief Card front image, kept so Tk does not drop it.
@@ -0,0 +1 @@
"""The level feature: its view and view model."""
@@ -0,0 +1,97 @@
"""View of the level screen.
@file level_view.py
@brief Builds the level screen of the wireframe: a title and one button per level.
"""
import tkinter as tk
from collections.abc import Callable
from flash_card.constants import (
BACKGROUND_COLOR,
CARD_WIDTH,
LEVEL_BUTTON_COLOR,
LEVEL_BUTTON_FONT,
LEVEL_BUTTON_WIDTH,
LEVEL_SCREEN_HEIGHT,
LEVEL_TITLE_FONT,
)
from flash_card.domain.value_objects.level import Level
from flash_card.presentation.features.level.level_view_model import (
LevelChoice,
LevelViewModel,
)
class LevelView:
"""The level screen: a title and a button for each level."""
def __init__(
self,
root: tk.Tk,
view_model: LevelViewModel,
on_choose: Callable[[Level], None],
) -> None:
"""Build the screen on a root.
@param root The Tk root window.
@param view_model The data to show.
@param on_choose Called with the level the user picked.
"""
## @brief Called with the level the user picked.
self._on_choose = on_choose
## @brief Frame that holds the whole screen.
self.frame = tk.Frame(
root, bg=BACKGROUND_COLOR, width=CARD_WIDTH, height=LEVEL_SCREEN_HEIGHT
)
self.frame.grid(row=0, column=0)
self.frame.grid_propagate(False)
self.frame.grid_columnconfigure(0, weight=1)
for row in range(len(view_model.choices) + 1):
self.frame.grid_rowconfigure(row, weight=1)
## @brief The title text.
self.title_label = tk.Label(
self.frame,
text=view_model.title,
font=LEVEL_TITLE_FONT,
bg=BACKGROUND_COLOR,
)
self.title_label.grid(row=0, column=0)
## @brief One button per choice, in display order.
self.buttons = tuple(
self._build_button(row, choice)
for row, choice in enumerate(view_model.choices, start=1)
)
def choose(self, level: Level) -> None:
"""Forward the chosen level.
@param level The level the user picked.
@return Nothing.
"""
self._on_choose(level)
def destroy(self) -> None:
"""Remove the screen from the window.
@return Nothing.
"""
self.frame.destroy()
def _build_button(self, row: int, choice: LevelChoice) -> tk.Button:
"""Create the button of one choice.
@param row Grid row of the button.
@param choice The choice the button stands for.
@return The button, placed in the frame.
"""
button = tk.Button(
self.frame,
text=choice.label,
font=LEVEL_BUTTON_FONT,
width=LEVEL_BUTTON_WIDTH,
bg=LEVEL_BUTTON_COLOR,
command=lambda: self.choose(choice.level),
)
button.grid(row=row, column=0)
return button
@@ -0,0 +1,53 @@
"""View model of the level screen.
@file level_view_model.py
@brief Exposes the title and the level choices; free of any Tkinter import.
"""
from collections.abc import Sequence
from dataclasses import dataclass
from flash_card.constants import LEVEL_CHOICES, LEVEL_TITLE
from flash_card.domain.value_objects.level import Level
@dataclass(frozen=True, slots=True)
class LevelChoice:
"""One choice on the level screen."""
## @brief Text of the choice.
label: str
## @brief The level the choice stands for.
level: Level
class LevelViewModel:
"""Data the level view shows."""
def __init__(self, choices: Sequence[tuple[str, int | None]] | None = None) -> None:
"""Create the view model.
@param choices Label and word limit of each choice, or None for the
choices of the program.
"""
source = LEVEL_CHOICES if choices is None else choices
## @brief The choices in display order.
self._choices = tuple(
LevelChoice(label, Level(limit)) for label, limit in source
)
@property
def title(self) -> str:
"""Return the title of the screen.
@return The title text.
"""
return LEVEL_TITLE
@property
def choices(self) -> tuple[LevelChoice, ...]:
"""Return the choices.
@return The choices in display order.
"""
return self._choices
@@ -0,0 +1 @@
"""Code shared by the screens of the presentation layer."""
@@ -0,0 +1,19 @@
"""Window set-up shared by the screens.
@file window.py
@brief Gives the root window its title, padding and background.
"""
import tkinter as tk
from flash_card.constants import BACKGROUND_COLOR, WINDOW_PADDING, WINDOW_TITLE
def configure_window(root: tk.Tk) -> None:
"""Set the title, padding and background of the window.
@param root The Tk root window.
@return Nothing.
"""
root.title(WINDOW_TITLE)
root.config(padx=WINDOW_PADDING, pady=WINDOW_PADDING, bg=BACKGROUND_COLOR)