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
+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)