Files
031-flash-card/src/flash_card/presentation/app.py
T
Tirsvad f45d6036e4 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
2026-10-14 23:58:31 +08:00

244 lines
8.2 KiB
Python

"""Entry point and composition root of the flash card program.
@file app.py
@brief Creates the window, shows the level screen and then the card screen.
"""
import argparse
import importlib.util
import logging
import random
import tkinter as tk
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import NoReturn
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,
)
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
from flash_card.infrastructure.speech.null_speech import NullSpeech
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,
)
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__)
## @brief Optional packages the spoken words need.
_SPEECH_PACKAGES = ("gtts", "playsound")
def build_speech(enabled: bool) -> SpeechPort:
"""Choose the speech adapter.
@param enabled False when the user switched speech off.
@return GttsSpeech when enabled and gtts and playsound are installed,
else NullSpeech.
"""
if enabled and all(
importlib.util.find_spec(name) is not None for name in _SPEECH_PACKAGES
):
return GttsSpeech()
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,
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.
@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
root = tk.Tk()
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:
"""Run the program and return its exit code.
@param argv Command line arguments, or None for those of the process.
@return The exit code, 0 on success and 1 when the word list is unusable.
"""
parser = argparse.ArgumentParser(description="Flash card program.")
parser.add_argument(
"--data-dir",
type=Path,
default=DEFAULT_DATA_DIR,
help="folder of the data files",
)
parser.add_argument(
"--no-speech",
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:
application = create_application(args.data_dir, not args.no_speech, args.level)
except VocabularyError as error:
_logger.error("%s", error)
return 1
application.root.mainloop()
return application.exit_code
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())