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:
@@ -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
|
||||
Reference in New Issue
Block a user