feat: Tkinter window with MVVM view model and composition root
Task: MIL-002#5
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
"""@brief Entry point for `python -m password_manager`.
|
||||||
|
|
||||||
|
@file __main__.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from password_manager.main import main
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""@brief Composition root: wires the layers together and runs the window.
|
||||||
|
|
||||||
|
@file main.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
from password_manager import constants
|
||||||
|
from password_manager.application.use_cases import (
|
||||||
|
FindPasswordEntry,
|
||||||
|
GeneratePassword,
|
||||||
|
SavePasswordEntry,
|
||||||
|
)
|
||||||
|
from password_manager.infrastructure.persistence.json_password_repository import (
|
||||||
|
JsonPasswordRepository,
|
||||||
|
)
|
||||||
|
from password_manager.presentation.features.password_manager import (
|
||||||
|
password_manager_view as view_module,
|
||||||
|
)
|
||||||
|
from password_manager.presentation.features.password_manager import (
|
||||||
|
password_manager_view_model as view_model_module,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""@brief Build the application and run the Tk main loop."""
|
||||||
|
repository = JsonPasswordRepository(constants.DATA_FILE_PATH)
|
||||||
|
view_model = view_model_module.PasswordManagerViewModel(
|
||||||
|
SavePasswordEntry(repository),
|
||||||
|
FindPasswordEntry(repository),
|
||||||
|
GeneratePassword(),
|
||||||
|
)
|
||||||
|
root = tk.Tk()
|
||||||
|
view_module.PasswordManagerView(root, view_model)
|
||||||
|
root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""@brief Package marker."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""@brief Package marker."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""@brief Package marker."""
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""@brief Tkinter view of the password manager screen.
|
||||||
|
|
||||||
|
@file password_manager_view.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
from password_manager import constants
|
||||||
|
from password_manager.presentation.features.password_manager import (
|
||||||
|
password_manager_view_model as view_model_module,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordManagerView:
|
||||||
|
"""@brief The window: shows the fields and raises events to the view model."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, root: tk.Tk, view_model: view_model_module.PasswordManagerViewModel
|
||||||
|
) -> None:
|
||||||
|
"""@brief Build the widgets on a root window.
|
||||||
|
|
||||||
|
@param root The Tk root window.
|
||||||
|
@param view_model The view model the events are forwarded to.
|
||||||
|
"""
|
||||||
|
self._root = root
|
||||||
|
self._view_model = view_model
|
||||||
|
self._view_model.bind_ui(self._copy_to_clipboard, self._confirm_save)
|
||||||
|
root.title(constants.WINDOW_TITLE)
|
||||||
|
root.config(padx=constants.WINDOW_PADDING, pady=constants.WINDOW_PADDING)
|
||||||
|
self._build_logo()
|
||||||
|
self._build_form()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def website_entry(self) -> tk.Entry:
|
||||||
|
"""@return The website input."""
|
||||||
|
return self._website_entry
|
||||||
|
|
||||||
|
@property
|
||||||
|
def email_entry(self) -> tk.Entry:
|
||||||
|
"""@return The e-mail input."""
|
||||||
|
return self._email_entry
|
||||||
|
|
||||||
|
@property
|
||||||
|
def password_entry(self) -> tk.Entry:
|
||||||
|
"""@return The password input."""
|
||||||
|
return self._password_entry
|
||||||
|
|
||||||
|
def generate_password(self) -> None:
|
||||||
|
"""@brief Fill the password field with a new password (and copy it)."""
|
||||||
|
password = self._view_model.generate_password()
|
||||||
|
self._password_entry.delete(0, tk.END)
|
||||||
|
self._password_entry.insert(0, password)
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""@brief Ask the view model to store the entered details."""
|
||||||
|
result = self._view_model.save(
|
||||||
|
self._website_entry.get(),
|
||||||
|
self._email_entry.get(),
|
||||||
|
self._password_entry.get(),
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
self._show(result)
|
||||||
|
if result.clear_fields:
|
||||||
|
self._clear_fields()
|
||||||
|
|
||||||
|
def find_password(self) -> None:
|
||||||
|
"""@brief Show the stored details of the entered website."""
|
||||||
|
self._show(self._view_model.find_password(self._website_entry.get()))
|
||||||
|
|
||||||
|
def _build_logo(self) -> None:
|
||||||
|
size = constants.LOGO_SIZE
|
||||||
|
canvas = tk.Canvas(
|
||||||
|
self._root,
|
||||||
|
width=size,
|
||||||
|
height=size,
|
||||||
|
bg=constants.LOGO_BACKGROUND,
|
||||||
|
highlightthickness=0,
|
||||||
|
)
|
||||||
|
canvas.create_arc(
|
||||||
|
*constants.LOGO_SHACKLE_BOX,
|
||||||
|
start=0,
|
||||||
|
extent=180,
|
||||||
|
style=tk.ARC,
|
||||||
|
outline=constants.LOGO_SHACKLE_COLOR,
|
||||||
|
width=constants.LOGO_SHACKLE_WIDTH,
|
||||||
|
)
|
||||||
|
canvas.create_rectangle(
|
||||||
|
*constants.LOGO_BODY_BOX,
|
||||||
|
fill=constants.LOGO_BODY_COLOR,
|
||||||
|
outline=constants.LOGO_BODY_COLOR,
|
||||||
|
)
|
||||||
|
canvas.create_oval(
|
||||||
|
*constants.LOGO_KEYHOLE_BOX,
|
||||||
|
fill=constants.LOGO_KEYHOLE_COLOR,
|
||||||
|
outline=constants.LOGO_KEYHOLE_COLOR,
|
||||||
|
)
|
||||||
|
canvas.create_rectangle(
|
||||||
|
*constants.LOGO_KEYHOLE_SLOT,
|
||||||
|
fill=constants.LOGO_KEYHOLE_COLOR,
|
||||||
|
outline=constants.LOGO_KEYHOLE_COLOR,
|
||||||
|
)
|
||||||
|
canvas.grid(row=0, column=1)
|
||||||
|
|
||||||
|
def _build_form(self) -> None:
|
||||||
|
root = self._root
|
||||||
|
tk.Label(root, text=constants.LABEL_WEBSITE).grid(row=1, column=0)
|
||||||
|
tk.Label(root, text=constants.LABEL_EMAIL).grid(row=2, column=0)
|
||||||
|
tk.Label(root, text=constants.LABEL_PASSWORD).grid(row=3, column=0)
|
||||||
|
|
||||||
|
self._website_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_NARROW)
|
||||||
|
self._website_entry.grid(row=1, column=1, sticky="EW")
|
||||||
|
self._website_entry.focus()
|
||||||
|
self._email_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_WIDE)
|
||||||
|
self._email_entry.grid(row=2, column=1, columnspan=2, sticky="EW")
|
||||||
|
self._email_entry.insert(0, constants.DEFAULT_EMAIL)
|
||||||
|
self._password_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_NARROW)
|
||||||
|
self._password_entry.grid(row=3, column=1, sticky="EW")
|
||||||
|
|
||||||
|
self._search_button = tk.Button(
|
||||||
|
root, text=constants.BUTTON_SEARCH, command=self.find_password
|
||||||
|
)
|
||||||
|
self._search_button.grid(row=1, column=2, sticky="EW")
|
||||||
|
self._generate_button = tk.Button(
|
||||||
|
root, text=constants.BUTTON_GENERATE, command=self.generate_password
|
||||||
|
)
|
||||||
|
self._generate_button.grid(row=3, column=2, sticky="EW")
|
||||||
|
self._add_button = tk.Button(
|
||||||
|
root,
|
||||||
|
text=constants.BUTTON_ADD,
|
||||||
|
width=constants.ENTRY_WIDTH_WIDE,
|
||||||
|
command=self.save,
|
||||||
|
)
|
||||||
|
self._add_button.grid(row=4, column=1, columnspan=2, sticky="EW")
|
||||||
|
|
||||||
|
def _copy_to_clipboard(self, text: str) -> None:
|
||||||
|
self._root.clipboard_clear()
|
||||||
|
self._root.clipboard_append(text)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _confirm_save(message: str) -> bool:
|
||||||
|
return bool(
|
||||||
|
messagebox.askokcancel(title=constants.WINDOW_TITLE, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _show(result: view_model_module.ActionResult) -> None:
|
||||||
|
if result.is_error:
|
||||||
|
messagebox.showwarning(title=result.title, message=result.message)
|
||||||
|
else:
|
||||||
|
messagebox.showinfo(title=result.title, message=result.message)
|
||||||
|
|
||||||
|
def _clear_fields(self) -> None:
|
||||||
|
self._website_entry.delete(0, tk.END)
|
||||||
|
self._password_entry.delete(0, tk.END)
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
"""@brief View model of the password manager screen (no tkinter here).
|
||||||
|
|
||||||
|
@file password_manager_view_model.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from password_manager import constants
|
||||||
|
from password_manager.application.use_cases import (
|
||||||
|
FindPasswordEntry,
|
||||||
|
GeneratePassword,
|
||||||
|
SavePasswordEntry,
|
||||||
|
)
|
||||||
|
from password_manager.domain.errors import (
|
||||||
|
DataFileNotFoundError,
|
||||||
|
EmptyFieldError,
|
||||||
|
EntryNotFoundError,
|
||||||
|
InvalidDataFileError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ActionResult:
|
||||||
|
"""@brief What the view shows after an action."""
|
||||||
|
|
||||||
|
title: str ##< Title of the message box.
|
||||||
|
message: str ##< Text of the message box.
|
||||||
|
is_error: bool ##< True when the box is a warning rather than information.
|
||||||
|
clear_fields: bool = False ##< True when the view clears website and password.
|
||||||
|
|
||||||
|
|
||||||
|
def _ignore_clipboard(text: str) -> None:
|
||||||
|
"""@brief Default clipboard: discards the text until the view binds one."""
|
||||||
|
|
||||||
|
|
||||||
|
def _decline_save(message: str) -> bool:
|
||||||
|
"""@brief Default confirmation: declines until the view binds a dialog."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordManagerViewModel:
|
||||||
|
"""@brief Data and commands of the password manager screen."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
save_entry: SavePasswordEntry,
|
||||||
|
find_entry: FindPasswordEntry,
|
||||||
|
generate: GeneratePassword,
|
||||||
|
copy_to_clipboard: Callable[[str], None] = _ignore_clipboard,
|
||||||
|
confirm_save: Callable[[str], bool] = _decline_save,
|
||||||
|
) -> None:
|
||||||
|
"""@brief Create the view model.
|
||||||
|
|
||||||
|
@param save_entry Use case that stores an entry.
|
||||||
|
@param find_entry Use case that looks an entry up.
|
||||||
|
@param generate Use case that generates a password.
|
||||||
|
@param copy_to_clipboard Puts text on the clipboard.
|
||||||
|
@param confirm_save Asks the user to confirm a save; True means ok.
|
||||||
|
"""
|
||||||
|
self._save_entry = save_entry
|
||||||
|
self._find_entry = find_entry
|
||||||
|
self._generate = generate
|
||||||
|
self._copy_to_clipboard = copy_to_clipboard
|
||||||
|
self._confirm_save = confirm_save
|
||||||
|
|
||||||
|
def bind_ui(
|
||||||
|
self,
|
||||||
|
copy_to_clipboard: Callable[[str], None],
|
||||||
|
confirm_save: Callable[[str], bool],
|
||||||
|
) -> None:
|
||||||
|
"""@brief Hand the view's clipboard and confirmation dialog to the model.
|
||||||
|
|
||||||
|
@param copy_to_clipboard Puts text on the clipboard.
|
||||||
|
@param confirm_save Asks the user to confirm a save; True means ok.
|
||||||
|
"""
|
||||||
|
self._copy_to_clipboard = copy_to_clipboard
|
||||||
|
self._confirm_save = confirm_save
|
||||||
|
|
||||||
|
def generate_password(self) -> str:
|
||||||
|
"""@brief Generate a password and copy it to the clipboard.
|
||||||
|
|
||||||
|
@return The generated password.
|
||||||
|
"""
|
||||||
|
password = self._generate.execute()
|
||||||
|
self._copy_to_clipboard(password)
|
||||||
|
return password
|
||||||
|
|
||||||
|
def save(self, website: str, email: str, password: str) -> ActionResult | None:
|
||||||
|
"""@brief Confirm and store the entry.
|
||||||
|
|
||||||
|
@param website Website field.
|
||||||
|
@param email E-mail field.
|
||||||
|
@param password Password field.
|
||||||
|
@return A result to show, or None when the user cancelled.
|
||||||
|
"""
|
||||||
|
if not (website.strip() and email.strip() and password.strip()):
|
||||||
|
return ActionResult(
|
||||||
|
constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True
|
||||||
|
)
|
||||||
|
question = constants.MESSAGE_CONFIRM_SAVE.format(email=email, password=password)
|
||||||
|
if not self._confirm_save(question):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
self._save_entry.execute(website, email, password)
|
||||||
|
except EmptyFieldError:
|
||||||
|
return ActionResult(
|
||||||
|
constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True
|
||||||
|
)
|
||||||
|
except InvalidDataFileError:
|
||||||
|
return ActionResult(
|
||||||
|
constants.TITLE_OOPS, constants.MESSAGE_INVALID_DATA_FILE, True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return ActionResult(
|
||||||
|
constants.TITLE_SAVED,
|
||||||
|
constants.MESSAGE_SAVED.format(website=website.strip()),
|
||||||
|
False,
|
||||||
|
clear_fields=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def find_password(self, website: str) -> ActionResult:
|
||||||
|
"""@brief Look up the credentials of a website.
|
||||||
|
|
||||||
|
@param website Website field.
|
||||||
|
@return The result to show.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
entry = self._find_entry.execute(website)
|
||||||
|
except EmptyFieldError:
|
||||||
|
return ActionResult(
|
||||||
|
constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True
|
||||||
|
)
|
||||||
|
except DataFileNotFoundError:
|
||||||
|
return ActionResult(
|
||||||
|
constants.TITLE_OOPS, constants.MESSAGE_NO_DATA_FILE, True
|
||||||
|
)
|
||||||
|
except EntryNotFoundError as error:
|
||||||
|
return ActionResult(
|
||||||
|
constants.TITLE_OOPS,
|
||||||
|
constants.MESSAGE_NO_DETAILS.format(website=error.website),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
except InvalidDataFileError:
|
||||||
|
return ActionResult(
|
||||||
|
constants.TITLE_OOPS, constants.MESSAGE_INVALID_DATA_FILE, True
|
||||||
|
)
|
||||||
|
return ActionResult(
|
||||||
|
entry.website,
|
||||||
|
constants.MESSAGE_DETAILS.format(
|
||||||
|
email=entry.email, password=entry.password
|
||||||
|
),
|
||||||
|
False,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user