MIL-011: Menu bar

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

Task: MIL-011#1
Task: MIL-011#2
Task: MIL-011#3
Task: MIL-011#4
Task: MIL-011#5
Task: MIL-011#6
Task: MIL-011#7
Task: MIL-011#8
This commit is contained in:
2026-10-15 09:06:47 +08:00
parent 5d626dc890
commit ce4aacb4bc
28 changed files with 1450 additions and 35 deletions
+120 -17
View File
@@ -1,7 +1,7 @@
"""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.
@brief Creates the window and menu bar, shows the level screen and then the card.
"""
import argparse
@@ -19,7 +19,6 @@ from flash_card.constants import (
BUNDLED_VOCABULARY_PATH,
DEFAULT_DATA_DIR,
LEVEL_ARGUMENT_VALUES,
PROGRESS_FILE_NAME,
VOCABULARY_FILE_NAME,
)
from flash_card.domain.entities.card import Card
@@ -33,6 +32,9 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.persistence.progress_file_name import (
progress_file_name,
)
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
@@ -41,6 +43,8 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
)
from flash_card.presentation.features.level.level_view import LevelView
from flash_card.presentation.features.level.level_view_model import LevelViewModel
from flash_card.presentation.features.menu.menu_view import MenuView
from flash_card.presentation.features.menu.menu_view_model import MenuViewModel
## @brief Logger of the entry point.
_logger = logging.getLogger(__name__)
@@ -64,34 +68,42 @@ def build_speech(enabled: bool) -> SpeechPort:
class Application:
"""The running program: one window that shows the level screen, then the card."""
"""The running program: one window with a menu, the level screen and the card."""
def __init__(
self,
root: tk.Tk,
vocabulary_repository: VocabularyRepository,
progress_repository: ProgressRepository,
speech: SpeechPort,
choose: Callable[[Sequence[Card]], Card] = random.choice,
choose: Callable[[Sequence[Card]], Card],
repositories_for: Callable[
[Path], tuple[VocabularyRepository, ProgressRepository]
],
list_path: Path,
) -> 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.
@param repositories_for Builds the word list and progress repositories of
a word list file; the composition root supplies it.
@param list_path The word list file in use.
"""
## @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 Builds the repositories of a word list file.
self._repositories_for = repositories_for
## @brief The word list file in use.
self._list_path = list_path
vocabulary_repository, progress_repository = repositories_for(list_path)
## @brief Source of the word list in use.
self._vocabulary_repository = vocabulary_repository
## @brief Store of the words still to learn of the word list in use.
self._progress_repository = progress_repository
## @brief The screen shown now, or None before the first one.
self._view: LevelView | FlashCardView | None = None
## @brief The exit code of the program.
@@ -113,6 +125,22 @@ class Application:
"""
return self._exit_code
@property
def word_list_name(self) -> str:
"""Return the file name of the word list in use.
@return The file name.
"""
return self._list_path.name
@property
def word_list_folder(self) -> Path:
"""Return the folder of the word list in use.
@return The folder that holds the word list file.
"""
return self._list_path.parent
def show_level_screen(self) -> None:
"""Show the level screen; a choice starts the session.
@@ -134,9 +162,60 @@ class Application:
self._choose,
level,
)
self._close_screen()
self._view = FlashCardView(self.root, FlashCardViewModel(session))
def open_word_list(self, path: Path) -> None:
"""Switch to another word list and show the level screen.
The file is loaded first; nothing changes when it is unusable.
@param path The CSV word list file.
@return Nothing.
@throws VocabularyError When the file is missing or malformed.
"""
vocabulary_repository, progress_repository = self._repositories_for(path)
vocabulary_repository.load()
self._vocabulary_repository = vocabulary_repository
self._progress_repository = progress_repository
self._list_path = path
self.change_level()
def change_level(self) -> None:
"""Drop the current screen and its timer and show the level screen.
@return Nothing.
"""
self._close_screen()
self.show_level_screen()
def reset_word_list(self) -> None:
"""Forget the progress of the word list in use and show the level screen.
@return Nothing.
@throws OSError When the progress cannot be removed.
"""
self._progress_repository.reset()
self.change_level()
def exit_application(self) -> None:
"""Drop the current screen and its timer and close the window.
@return Nothing.
"""
self._close_screen()
self.root.destroy()
def _close_screen(self) -> None:
"""Remove the screen shown now, if any.
@return Nothing.
"""
if isinstance(self._view, LevelView):
self._view.destroy()
self._view = FlashCardView(self.root, FlashCardViewModel(session))
elif isinstance(self._view, FlashCardView):
self._view.close()
self._view = None
def _choose_level(self, level: Level) -> None:
"""Start the session after a click; close the window when it fails.
@@ -163,20 +242,42 @@ def resolve_data_files(data_dir: Path | None) -> tuple[Path, Path]:
vocabulary = folder / VOCABULARY_FILE_NAME
if data_dir is None and not vocabulary.exists():
vocabulary = BUNDLED_VOCABULARY_PATH
return vocabulary, folder / PROGRESS_FILE_NAME
return vocabulary, folder / progress_file_name(vocabulary)
def _repository_builder(
folder: Path,
) -> Callable[[Path], tuple[VocabularyRepository, ProgressRepository]]:
"""Make the function that builds the CSV repositories of a word list.
@param folder The data folder that holds every progress file.
@return A function from a word list path to its repositories; the progress
file is in the data folder, named after the list.
"""
def build(list_path: Path) -> tuple[VocabularyRepository, ProgressRepository]:
return (
CsvVocabularyRepository(list_path),
CsvProgressRepository(folder / progress_file_name(list_path)),
)
return build
def create_application(
data_dir: Path | None = None,
speech_enabled: bool = True,
level: Level | None = None,
word_list: Path | None = None,
) -> Application:
"""Create the window and show the level screen, or start at the given level.
"""Create the window with its menu bar and show the level screen, or start.
@param data_dir Folder of the data files, or None for the default folder
with the delivered word list as fallback.
@param speech_enabled False to run without spoken words.
@param level The level to start at, or None to show the level screen.
@param word_list A word list file to use instead of the one of the data
folder, or None; its progress file is still in the data folder.
@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.
@@ -185,10 +286,12 @@ def create_application(
root = tk.Tk()
application = Application(
root,
CsvVocabularyRepository(vocabulary_path),
CsvProgressRepository(progress_path),
build_speech(speech_enabled),
random.choice,
_repository_builder(progress_path.parent),
vocabulary_path if word_list is None else word_list,
)
MenuView(root, MenuViewModel(), application)
if level is None:
application.show_level_screen()
return application
@@ -146,6 +146,15 @@ class FlashCardView:
"""
return self._card_back_image
def close(self) -> None:
"""Cancel the flip timer and remove the widgets of the screen.
@return Nothing.
"""
self._cancel_flip_timer()
for widget in (self.canvas, self.wrong_button, self.right_button):
widget.destroy()
def next_card(self) -> None:
"""Show the next card front and restart the flip timer.
@@ -0,0 +1 @@
"""Menu bar feature."""
@@ -0,0 +1,241 @@
"""View of the menu bar.
@file menu_view.py
@brief Builds the menu bar of the wireframe and asks the dialogs of its commands.
"""
import logging
import tkinter as tk
import webbrowser
from collections.abc import Callable
from pathlib import Path
from tkinter import filedialog, messagebox
from typing import Protocol
from flash_card.constants import (
MENU_ABOUT_UNDERLINE,
MENU_COMMAND_CHANGE_LEVEL,
MENU_COMMAND_EXIT,
MENU_COMMAND_OPEN,
MENU_COMMAND_RESET,
MENU_CONTACT_UNDERLINE,
MENU_FILE_UNDERLINE,
OPEN_DIALOG_TITLE,
OPEN_ERROR_TEMPLATE,
OPEN_ERROR_TITLE,
OPEN_FILE_TYPES,
RESET_CONFIRM_TEMPLATE,
RESET_CONFIRM_TITLE,
RESET_ERROR_TEMPLATE,
RESET_ERROR_TITLE,
)
from flash_card.domain.errors.vocabulary_errors import VocabularyError
from flash_card.presentation.features.menu.menu_view_model import MenuViewModel
## @brief Logger of the menu view.
_logger = logging.getLogger(__name__)
class MenuApplication(Protocol):
"""What the menu view needs from the application."""
@property
def word_list_name(self) -> str:
"""Return the file name of the word list in use.
@return The file name.
"""
...
@property
def word_list_folder(self) -> Path:
"""Return the folder of the word list in use.
@return The folder.
"""
...
def open_word_list(self, path: Path) -> None:
"""Switch to the word list in a file.
@param path The CSV file.
@return Nothing.
"""
...
def change_level(self) -> None:
"""Show the level screen.
@return Nothing.
"""
...
def reset_word_list(self) -> None:
"""Forget the progress of the word list in use.
@return Nothing.
"""
...
def exit_application(self) -> None:
"""Close the window.
@return Nothing.
"""
...
class MenuView:
"""The menu bar of the window: File and About."""
def __init__(
self,
root: tk.Tk,
view_model: MenuViewModel,
application: MenuApplication,
*,
ask_open_file: Callable[..., str] = filedialog.askopenfilename,
confirm: Callable[..., bool] = messagebox.askyesno,
show_error: Callable[..., str] = messagebox.showerror,
open_url: Callable[[str], bool] = webbrowser.open,
) -> None:
"""Build the menu bar on a root.
@param root The Tk root window.
@param view_model The labels and the contact data.
@param application The commands the entries run.
@param ask_open_file Shows the file dialog and returns the chosen path,
or an empty value when cancelled.
@param confirm Asks a yes or no question.
@param show_error Shows an error message.
@param open_url Opens an address in the web browser.
"""
## @brief The Tk root window.
self._root = root
## @brief The labels and the contact data.
self._view_model = view_model
## @brief The commands the entries run.
self._application = application
## @brief Shows the file dialog.
self._ask_open_file = ask_open_file
## @brief Asks a yes or no question.
self._confirm = confirm
## @brief Shows an error message.
self._show_error = show_error
## @brief Opens an address in the web browser.
self._open_url = open_url
## @brief The menu bar of the window.
self.menubar = tk.Menu(root, tearoff=False)
## @brief The File menu.
self.file_menu = tk.Menu(self.menubar, tearoff=False)
## @brief The About menu.
self.about_menu = tk.Menu(self.menubar, tearoff=False)
## @brief The Contact submenu of the About menu.
self.contact_menu = tk.Menu(self.about_menu, tearoff=False)
self._build()
root.config(menu=self.menubar)
def open_word_list(self) -> None:
"""Ask for a CSV file and switch to it; a refused file shows a message.
@return Nothing.
"""
chosen = self._ask_open_file(
parent=self._root,
title=OPEN_DIALOG_TITLE,
initialdir=str(self._application.word_list_folder),
filetypes=OPEN_FILE_TYPES,
)
if not chosen:
return
path = Path(chosen)
try:
self._application.open_word_list(path)
except VocabularyError as error:
_logger.error("%s", error)
self._show_error(
OPEN_ERROR_TITLE,
OPEN_ERROR_TEMPLATE.format(name=path.name, reason=error),
parent=self._root,
)
def change_level(self) -> None:
"""Show the level screen again.
@return Nothing.
"""
self._application.change_level()
def reset_word_list(self) -> None:
"""Ask to confirm, then start the word list in use from the beginning.
@return Nothing.
"""
name = self._application.word_list_name
question = RESET_CONFIRM_TEMPLATE.format(name=name)
if not self._confirm(RESET_CONFIRM_TITLE, question, parent=self._root):
return
try:
self._application.reset_word_list()
except OSError as error:
_logger.error("%s", error)
self._show_error(
RESET_ERROR_TITLE,
RESET_ERROR_TEMPLATE.format(name=name, reason=error),
parent=self._root,
)
def show_contact(self) -> None:
"""Open the LinkedIn page of the author in the web browser.
@return Nothing.
"""
try:
self._open_url(self._view_model.contact_url)
except (webbrowser.Error, OSError) as error:
_logger.error("%s", error)
def exit_program(self) -> None:
"""Close the window.
@return Nothing.
"""
self._application.exit_application()
def _build(self) -> None:
"""Fill the menus and add them to the menu bar.
@return Nothing.
"""
commands = {
MENU_COMMAND_OPEN: self.open_word_list,
MENU_COMMAND_CHANGE_LEVEL: self.change_level,
MENU_COMMAND_RESET: self.reset_word_list,
MENU_COMMAND_EXIT: self.exit_program,
}
for entry in self._view_model.file_entries:
if entry.command == MENU_COMMAND_EXIT:
self.file_menu.add_separator()
self.file_menu.add_command(
label=entry.label,
underline=entry.underline,
command=commands[entry.command],
)
self.contact_menu.add_command(
label=self._view_model.contact_label, command=self.show_contact
)
self.about_menu.add_cascade(
label=self._view_model.contact_menu_label,
underline=MENU_CONTACT_UNDERLINE,
menu=self.contact_menu,
)
self.menubar.add_cascade(
label=self._view_model.file_label,
underline=MENU_FILE_UNDERLINE,
menu=self.file_menu,
)
self.menubar.add_cascade(
label=self._view_model.about_label,
underline=MENU_ABOUT_UNDERLINE,
menu=self.about_menu,
)
@@ -0,0 +1,102 @@
"""View model of the menu bar.
@file menu_view_model.py
@brief Exposes the labels, the order and the contact data; free of Tkinter.
"""
from dataclasses import dataclass
from flash_card.constants import (
CONTACT_LABEL_TEMPLATE,
CONTACT_NAME,
CONTACT_TITLE,
CONTACT_URL,
MENU_ABOUT_LABEL,
MENU_CHANGE_LEVEL_LABEL,
MENU_CHANGE_LEVEL_UNDERLINE,
MENU_COMMAND_CHANGE_LEVEL,
MENU_COMMAND_EXIT,
MENU_COMMAND_OPEN,
MENU_COMMAND_RESET,
MENU_CONTACT_LABEL,
MENU_EXIT_LABEL,
MENU_EXIT_UNDERLINE,
MENU_FILE_LABEL,
MENU_OPEN_LABEL,
MENU_OPEN_UNDERLINE,
MENU_RESET_LABEL,
MENU_RESET_UNDERLINE,
)
@dataclass(frozen=True, slots=True)
class MenuEntry:
"""One entry of the File menu."""
## @brief Text of the entry.
label: str
## @brief Name of the command of the menu view the entry runs.
command: str
## @brief Index of the mnemonic letter in the label.
underline: int
class MenuViewModel:
"""Data the menu view shows."""
@property
def file_label(self) -> str:
"""Return the label of the File menu.
@return The label.
"""
return MENU_FILE_LABEL
@property
def file_entries(self) -> tuple[MenuEntry, ...]:
"""Return the entries of the File menu.
@return Open, Change level, Reset word list and Exit, in this order.
"""
return (
MenuEntry(MENU_OPEN_LABEL, MENU_COMMAND_OPEN, MENU_OPEN_UNDERLINE),
MenuEntry(
MENU_CHANGE_LEVEL_LABEL,
MENU_COMMAND_CHANGE_LEVEL,
MENU_CHANGE_LEVEL_UNDERLINE,
),
MenuEntry(MENU_RESET_LABEL, MENU_COMMAND_RESET, MENU_RESET_UNDERLINE),
MenuEntry(MENU_EXIT_LABEL, MENU_COMMAND_EXIT, MENU_EXIT_UNDERLINE),
)
@property
def about_label(self) -> str:
"""Return the label of the About menu.
@return The label.
"""
return MENU_ABOUT_LABEL
@property
def contact_menu_label(self) -> str:
"""Return the label of the Contact submenu.
@return The label.
"""
return MENU_CONTACT_LABEL
@property
def contact_label(self) -> str:
"""Return the text of the contact entry.
@return The name and the title of the author.
"""
return CONTACT_LABEL_TEMPLATE.format(name=CONTACT_NAME, title=CONTACT_TITLE)
@property
def contact_url(self) -> str:
"""Return the address the contact entry opens.
@return The LinkedIn page of the author.
"""
return CONTACT_URL