From 927c4af80121da934ac6771c37028361291963d8 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Salazar Date: Wed, 12 Nov 2025 22:57:45 -0500 Subject: [PATCH] Add modern styling and rounded buttons to GUI components - Updated the main window and dialog styles for a more cohesive look. - Introduced a custom RoundedButton class for improved button aesthetics. - Enhanced the password manager dialogs (ask passphrase, initial config, and password entry) with modern styling and rounded buttons. - Refactored treeview handling to support sorting and searching functionality. - Added Pillow as a dependency for image handling in buttons. --- pwmanager/gui/dialogs/ask_passphrase.py | 49 ++- pwmanager/gui/dialogs/initial_config.py | 87 ++--- pwmanager/gui/dialogs/password_dialog.py | 84 ++++- pwmanager/gui/main_window.py | 177 ++++++---- pwmanager/gui/rounded_button.py | 220 ++++++++++++ pwmanager/gui/styles.py | 423 +++++++++++++++++++++++ requirements.txt | 1 + 7 files changed, 911 insertions(+), 130 deletions(-) create mode 100644 pwmanager/gui/rounded_button.py create mode 100644 pwmanager/gui/styles.py diff --git a/pwmanager/gui/dialogs/ask_passphrase.py b/pwmanager/gui/dialogs/ask_passphrase.py index 03a1d9e..b837262 100644 --- a/pwmanager/gui/dialogs/ask_passphrase.py +++ b/pwmanager/gui/dialogs/ask_passphrase.py @@ -5,6 +5,12 @@ """ import tkinter as tk +import tkinter.ttk as ttk + +from pwmanager.gui.styles import ( + configure_dialog_styles, get_entry_style_config, StylingColors +) +from pwmanager.gui.rounded_button import RoundedButton class AskPassphrase: @@ -14,17 +20,42 @@ class AskPassphrase: def __init__(self, parent): dialog_window = self.top = tk.Toplevel(parent) + dialog_window.title('Unlock Password Manager') dialog_window.resizable(width=False, height=False) - tk.Label(dialog_window, text="Enter passphrase:").grid( - row=0, - column=0, - padx=2, - pady=2 + + # Apply modern styling + style = ttk.Style() + configure_dialog_styles(dialog_window, style) + entry_style = get_entry_style_config() + + # Override label font size for this dialog + style.configure('Dialog.TLabel', font=('Segoe UI', 11)) + + # Main frame + main_frame = ttk.Frame(dialog_window, style='Dialog.TFrame', padding=30) + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + ttk.Label(main_frame, text="Enter passphrase:", style='Dialog.TLabel').grid( + row=0, column=0, padx=5, pady=(0, 10), sticky=tk.W + ) + passphrase_entry = self.passphrase_entry = tk.Entry(main_frame, width=35, show="*", **entry_style) + passphrase_entry.grid(row=1, column=0, padx=5, pady=10, sticky=(tk.W, tk.E)) + ok_button = self.ok_button = RoundedButton( + main_frame, + text="OK", + command=self.ok, + bg_color=StylingColors.ACCENT.value, + hover_color=StylingColors.HOVER.value, + canvas_bg=StylingColors.BG.value, + width=100 ) - passphrase_entry = self.passphrase_entry = tk.Entry(dialog_window, width=32, show="*") - passphrase_entry.grid(row=1, column=0, padx=2, pady=2) - ok_button = self.ok_button = tk.Button(dialog_window, text="OK", command=self.ok) - ok_button.grid(row=4, column=0, columnspan=2, padx=2, pady=2) + ok_button.grid(row=2, column=0, padx=5, pady=(10, 0)) + + # Configure grid weights + main_frame.columnconfigure(0, weight=1) + dialog_window.columnconfigure(0, weight=1) + dialog_window.rowconfigure(0, weight=1) + self.__passphrase = None passphrase_entry.focus_set() passphrase_entry.bind('', self.ok) diff --git a/pwmanager/gui/dialogs/initial_config.py b/pwmanager/gui/dialogs/initial_config.py index 3e2f343..9ae8d0f 100644 --- a/pwmanager/gui/dialogs/initial_config.py +++ b/pwmanager/gui/dialogs/initial_config.py @@ -5,8 +5,13 @@ """ import tkinter as tk +import tkinter.ttk as ttk from pwmanager.datastore import initialize_datastore +from pwmanager.gui.styles import ( + configure_dialog_styles, get_entry_style_config, StylingColors +) +from pwmanager.gui.rounded_button import RoundedButton class InitialConfig: @@ -17,54 +22,54 @@ class InitialConfig: def __init__(self, parent, store_path=None): self.store_path = store_path if store_path else './data/store.pws' dialog_window = self.top = tk.Toplevel(parent) + dialog_window.title('Initialize Password Manager') dialog_window.resizable(width=False, height=False) - tk.Label(dialog_window, text='Datastore must be initialized and locked').grid( - row=0, - column=0, - columnspan=2, - padx=2, - pady=2 - ) - tk.Label(dialog_window, text="Enter passphrase:").grid( - row=1, - column=0, - padx=2, - sticky=tk.W + + # Apply modern styling + style = ttk.Style() + configure_dialog_styles(dialog_window, style) + entry_style = get_entry_style_config() + + # Main frame + main_frame = ttk.Frame(dialog_window, style='Dialog.TFrame', padding=30) + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + ttk.Label(main_frame, text='Datastore must be initialized and locked', + style='Dialog.TLabel', font=('Segoe UI', 11, 'bold')).grid( + row=0, column=0, columnspan=2, padx=5, pady=(0, 20), sticky=tk.W ) - passphrase_entry1 = self.passphrase_entry1 = tk.Entry(dialog_window, width=32, show="*") - passphrase_entry1.grid( - row=1, - column=1, - sticky=tk.E + ttk.Label(main_frame, text="Enter passphrase:", style='Dialog.TLabel').grid( + row=1, column=0, padx=5, pady=8, sticky=tk.W ) + passphrase_entry1 = self.passphrase_entry1 = tk.Entry(main_frame, width=35, show="*", **entry_style) + passphrase_entry1.grid(row=1, column=1, padx=5, pady=8, sticky=(tk.W, tk.E)) passphrase_entry1.bind('', self.verify) - tk.Label(dialog_window, text="Re-enter passphrase:").grid( - row=2, - column=0, - padx=2, - sticky=tk.W - ) - passphrase_entry2 = self.passphrase_entry2 = tk.Entry(dialog_window, width=32, show="*") - passphrase_entry2.grid( - row=2, - column=1, - sticky=tk.E + ttk.Label(main_frame, text="Re-enter passphrase:", style='Dialog.TLabel').grid( + row=2, column=0, padx=5, pady=8, sticky=tk.W ) + passphrase_entry2 = self.passphrase_entry2 = tk.Entry(main_frame, width=35, show="*", **entry_style) + passphrase_entry2.grid(row=2, column=1, padx=5, pady=8, sticky=(tk.W, tk.E)) passphrase_entry2.bind('', self.verify) - status_label = self.status_label = tk.Label(dialog_window, text='', fg='red') - status_label.grid( - row=3, - column=0, - columnspan=2 - ) - ok_button = self.ok_button = tk.Button(dialog_window, text="OK", command=self.ok, state=tk.DISABLED) - ok_button.grid( - row=4, - column=0, - columnspan=2, - padx=2, - pady=2 + status_label = self.status_label = tk.Label(main_frame, text='', fg=StylingColors.ERROR.value, + bg=StylingColors.BG.value, font=('Segoe UI', 9)) + status_label.grid(row=3, column=0, columnspan=2, padx=5, pady=5, sticky=tk.W) + ok_button = self.ok_button = RoundedButton( + main_frame, + text="OK", + command=self.ok, + bg_color=StylingColors.ACCENT.value, + hover_color=StylingColors.HOVER.value, + canvas_bg=StylingColors.BG.value, + state='disabled', + width=120 ) + ok_button.grid(row=4, column=0, columnspan=2, padx=5, pady=(15, 0)) + + # Configure grid weights + main_frame.columnconfigure(1, weight=1) + dialog_window.columnconfigure(0, weight=1) + dialog_window.rowconfigure(0, weight=1) + dialog_window.grab_set() dialog_window.attributes('-topmost', True) dialog_window.protocol('WM_DELETE_WINDOW', self.ok) diff --git a/pwmanager/gui/dialogs/password_dialog.py b/pwmanager/gui/dialogs/password_dialog.py index 6fffc3c..ddef127 100644 --- a/pwmanager/gui/dialogs/password_dialog.py +++ b/pwmanager/gui/dialogs/password_dialog.py @@ -5,8 +5,13 @@ """ import tkinter as tk +import tkinter.ttk as ttk from pwmanager.crypto import generate_random_password +from pwmanager.gui.styles import ( + configure_dialog_styles, StylingColors +) +from pwmanager.gui.rounded_button import RoundedButton class PasswordDialog: # pylint: disable=too-many-instance-attributes @@ -14,37 +19,78 @@ class PasswordDialog: # pylint: disable=too-many-instance-attributes def __init__(self, parent, **kwargs): dialog_window = self.top = tk.Toplevel(master=parent) + dialog_window.title('Password Entry') + dialog_window.resizable(width=False, height=False) + + # Apply modern styling + style = ttk.Style() + configure_dialog_styles(dialog_window, style) + + # Main frame + main_frame = ttk.Frame(dialog_window, style='Dialog.TFrame', padding=20) + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + self.__site = tk.StringVar(master=dialog_window) self.__username = tk.StringVar(master=dialog_window) self.__password = tk.StringVar(master=dialog_window) self.__ok_pressed = False - tk.Label(master=dialog_window, text='Site:').grid( - row=0, column=0, - padx=2, pady=2 + + # Site field + ttk.Label(main_frame, text='Site:', style='Dialog.TLabel').grid( + row=0, column=0, padx=5, pady=8, sticky=tk.W ) - site_entry = self.site_entry = tk.Entry(master=dialog_window, width=32, textvariable=self.__site) - site_entry.grid(row=0, column=1, columnspan=2, padx=2, pady=2) + site_entry = self.site_entry = ttk.Entry(main_frame, width=35, textvariable=self.__site, style='Dialog.TEntry') + site_entry.grid(row=0, column=1, padx=5, pady=8, sticky=(tk.W, tk.E)) site_entry.bind('', self.ok) - tk.Label(master=dialog_window, text='Username:').grid( - row=1, column=0, - padx=2, pady=2 + + # Username field + ttk.Label(main_frame, text='Username:', style='Dialog.TLabel').grid( + row=1, column=0, padx=5, pady=8, sticky=tk.W ) - username_entry = self.username_entry = tk.Entry(master=dialog_window, width=32, textvariable=self.__username) - username_entry.grid(row=1, column=1, columnspan=2, padx=2, pady=2) + username_entry = self.username_entry = ttk.Entry(main_frame, width=35, textvariable=self.__username, style='Dialog.TEntry') + username_entry.grid(row=1, column=1, padx=5, pady=8, sticky=(tk.W, tk.E)) username_entry.bind('', self.ok) - tk.Label(master=dialog_window, text='Password:').grid( - row=2, column=0, - padx=2, pady=2 + + # Password field + ttk.Label(main_frame, text='Password:', style='Dialog.TLabel').grid( + row=2, column=0, padx=5, pady=8, sticky=tk.W ) - password_entry = self.password_entry = tk.Entry(master=dialog_window, width=32, textvariable=self.__password) - password_entry.grid(row=2, column=1, columnspan=2, padx=2, pady=2) + password_entry = self.password_entry = ttk.Entry(main_frame, width=35, textvariable=self.__password, + style='Dialog.TEntry', show='*') + password_entry.grid(row=2, column=1, padx=5, pady=8, sticky=(tk.W, tk.E)) password_entry.bind('', self.ok) password_entry.bind('', lambda evt: self.password_entry.config(show='')) password_entry.bind('', lambda evt: self.password_entry.config(show='*')) - ok_button = self.ok_button = tk.Button(dialog_window, text="OK", command=self.ok) - ok_button.grid(row=3, column=2, columnspan=1, padx=10, pady=2, sticky='ew') - generate_button = self.generate_button = tk.Button(dialog_window, text="Generate", command=self.__generate) - generate_button.grid(row=3, column=1, columnspan=1, padx=2, pady=2, sticky='ew') + + # Button frame + button_frame = ttk.Frame(main_frame, style='Dialog.TFrame') + button_frame.grid(row=3, column=0, columnspan=2, pady=15, sticky=tk.E) + + generate_button = self.generate_button = RoundedButton( + button_frame, + text="Generate", + command=self.__generate, + bg_color=StylingColors.ACCENT.value, + hover_color=StylingColors.HOVER.value, + canvas_bg=StylingColors.BG.value + ) + generate_button.pack(side=tk.LEFT, padx=(0, 10)) + + ok_button = self.ok_button = RoundedButton( + button_frame, + text="OK", + command=self.ok, + bg_color=StylingColors.ACCENT.value, + hover_color=StylingColors.HOVER.value, + canvas_bg=StylingColors.BG.value + ) + ok_button.pack(side=tk.LEFT, padx=0) + + # Configure grid weights + main_frame.columnconfigure(1, weight=1) + dialog_window.columnconfigure(0, weight=1) + dialog_window.rowconfigure(0, weight=1) + if 'site' in kwargs: self.__site.set(kwargs['site']) if 'username' in kwargs: diff --git a/pwmanager/gui/main_window.py b/pwmanager/gui/main_window.py index 6a48e16..0f7cb89 100644 --- a/pwmanager/gui/main_window.py +++ b/pwmanager/gui/main_window.py @@ -21,6 +21,10 @@ from pwmanager.gui.dialogs import ( InitialConfig, AskPassphrase, PasswordDialog ) +from pwmanager.gui.styles import ( + configure_main_window_styles, StylingColors +) +from pwmanager.gui.rounded_button import RoundedButton # Constants for password actions PASSWORD_ACTION_ADD = 0 @@ -67,7 +71,8 @@ def handle_password(master: tk.Tk, datastore: dict, encryption_key: bytes, passw cipher_mode ) datastore['store'][password_dialog.site] = deepcopy(entry) - password_list_view.insert('', tk.END, values=(deepcopy(password_dialog.site), deepcopy(password_dialog.username), deepcopy(password_dialog.password))) + # Reload and sort the treeview + reload_treeview(datastore, encryption_key, password_list_view) elif action == PASSWORD_ACTION_DELETE: if password_list_view.focus() == '': return @@ -75,7 +80,8 @@ def handle_password(master: tk.Tk, datastore: dict, encryption_key: bytes, passw site_name = password_list_view.item(selected_item_id)['values'][0] if mbox.askyesno(title='Delete password', message='Are you sure you want to delete the selected password?\r\n(This cannot be undone)'): datastore['store'].pop(site_name, None) - password_list_view.delete(selected_item_id) + # Reload and sort the treeview + reload_treeview(datastore, encryption_key, password_list_view) else: print('ERROR: Unknown action') @@ -96,16 +102,44 @@ def copy_to_clipboard(master: tk.Tk, password_list_view: ttk.Treeview, copy_type master.update() -def search_callback(datastore: dict, encryption_key: bytes, password_list_view: ttk.Treeview, search_var: tk.StringVar): - """Handle search functionality in the GUI.""" +def reload_treeview(datastore: dict, encryption_key: bytes, password_list_view: ttk.Treeview, search_var: tk.StringVar = None): + """ + Reload and sort Treeview entries by website name. + + Args: + datastore: The datastore dictionary + encryption_key: Encryption key for decrypting entries + password_list_view: The Treeview widget to populate + search_var: Optional search variable to filter results + """ + # Clear existing entries for child in password_list_view.get_children(): password_list_view.delete(child) + cipher_mode = datastore['cipher_mode'] - for site_name in [x for x in datastore['store'].keys() if search_var.get().lower() in x.lower()]: + + # Get all site names + site_names = list(datastore['store'].keys()) + + # Apply search filter if provided + if search_var and search_var.get(): + search_term = search_var.get().lower() + site_names = [x for x in site_names if search_term in x.lower()] + + # Sort site names alphabetically (case-insensitive) + site_names.sort(key=str.lower) + + # Insert entries in sorted order + for site_name in site_names: entry_data = decrypt_entry(datastore['store'][site_name], encryption_key, cipher_mode) password_list_view.insert('', tk.END, values=(deepcopy(site_name), deepcopy(entry_data['username']), deepcopy(entry_data['password']))) +def search_callback(datastore: dict, encryption_key: bytes, password_list_view: ttk.Treeview, search_var: tk.StringVar): + """Handle search functionality in the GUI.""" + reload_treeview(datastore, encryption_key, password_list_view, search_var) + + def create_main_window(store_path: str): """ Create and run the main GUI window. @@ -114,11 +148,13 @@ def create_main_window(store_path: str): store_path: Path to the datastore file """ root = tk.Tk() - root.title('Simple password manager') - root.minsize(width=800, height=600) + root.title('Password Manager') + root.minsize(width=1000, height=700) + root.geometry('1000x700') + # Configure modern ttk styles style = ttk.Style() - style.theme_use('clam') + configure_main_window_styles(root, style) data_dir = os.path.dirname(store_path) if os.path.dirname(store_path) else './data' @@ -154,68 +190,87 @@ def create_main_window(store_path: str): root.attributes('-topmost', False) root.protocol('WM_DELETE_WINDOW', lambda: save_and_exit(datastore, store_path)) - # Create menu - menu = tk.Menu(master=root) - root.config(menu=menu) - - filemenu = tk.Menu(master=menu, tearoff=0) - menu.add_cascade(label='File', menu=filemenu) - filemenu.add_command(label='Save', command=lambda: save_datastore(datastore, store_path)) - filemenu.add_command(label='Exit', command=lambda: save_and_exit(datastore, store_path)) - - viewmenu = tk.Menu(master=menu, tearoff=0) - menu.add_cascade(label='View', menu=viewmenu) - - def change_theme(theme_name): - style.theme_use(theme_name) - for i in range(viewmenu.index(tk.END) + 1): - viewmenu.entryconfig(i, state='normal') - print(f'Themed changed to: {theme_name}') - - available_themes = ['clam', 'alt', 'default', 'classic'] - for theme in available_themes: - viewmenu.add_command(label=f"Theme: {theme}", - command=lambda t=theme: change_theme(t)) - - # Create toolbar - toolbar = tk.Frame(root) - add_button = ttk.Button(toolbar, text='Add', width=6) - add_button.pack(side=tk.LEFT, padx=2, pady=2) - delete_button = ttk.Button(toolbar, text='Remove', width=6) - delete_button.pack(side=tk.LEFT, padx=2, pady=2) - edit_button = ttk.Button(toolbar, text='Edit', width=6) - edit_button.pack(side=tk.LEFT, padx=2, pady=2) - search_label = ttk.Label(toolbar, text='Search:', width=8) - search_label.pack(side=tk.LEFT, padx=2, pady=2) - search_var = tk.StringVar(master=toolbar) - search_entry = ttk.Entry(master=toolbar, width=32, textvariable=search_var) - search_entry.pack(side=tk.LEFT, padx=2) + # Create toolbar with modern layout + toolbar = ttk.Frame(root, style='TFrame') + toolbar.pack(side=tk.TOP, fill=tk.X, padx=10, pady=10) + + # Left side: Action buttons + action_frame = ttk.Frame(toolbar, style='TFrame') + action_frame.pack(side=tk.LEFT) + + add_button = RoundedButton(action_frame, text='Add', width=80, + bg_color=StylingColors.ACCENT.value, + hover_color=StylingColors.HOVER.value, + canvas_bg=StylingColors.BG.value) + add_button.pack(side=tk.LEFT, padx=3) + edit_button = RoundedButton(action_frame, text='Edit', width=80, + bg_color=StylingColors.ACCENT.value, + hover_color=StylingColors.HOVER.value, + canvas_bg=StylingColors.BG.value) + edit_button.pack(side=tk.LEFT, padx=3) + delete_button = RoundedButton(action_frame, text='Remove', width=90, + bg_color=StylingColors.ACCENT.value, + hover_color=StylingColors.HOVER.value, + canvas_bg=StylingColors.BG.value) + delete_button.pack(side=tk.LEFT, padx=3) + + # Center: Search + search_frame = ttk.Frame(toolbar, style='TFrame') + search_frame.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=20) + search_label = ttk.Label(search_frame, text='Search:', width=8) + search_label.pack(side=tk.LEFT, padx=(0, 5)) + search_var = tk.StringVar(master=search_frame) + search_entry = ttk.Entry(master=search_frame, width=30, textvariable=search_var) + search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Right side: Copy buttons + copy_frame = ttk.Frame(toolbar, style='TFrame') + copy_frame.pack(side=tk.RIGHT) + + copy_username_button = RoundedButton(copy_frame, text='Copy Username', width=130, + bg_color=StylingColors.ACCENT.value, + hover_color=StylingColors.HOVER.value, + canvas_bg=StylingColors.BG.value) + copy_username_button.pack(side=tk.LEFT, padx=3) + copy_password_button = RoundedButton(copy_frame, text='Copy Password', width=130, + bg_color=StylingColors.ACCENT.value, + hover_color=StylingColors.HOVER.value, + canvas_bg=StylingColors.BG.value) + copy_password_button.pack(side=tk.LEFT, padx=3) cipher_mode = datastore['cipher_mode'] - copy_username_button = ttk.Button(toolbar, text='Copy username', width=12) - copy_username_button.pack(side=tk.RIGHT, padx=2, pady=2) - copy_password_button = ttk.Button(toolbar, text='Copy password', width=12) - copy_password_button.pack(side=tk.RIGHT, padx=2, pady=2) - toolbar.pack(side=tk.TOP, fill=tk.X) - - # Create list frame - list_frame = tk.Frame(master=root) - scrollbar = tk.Scrollbar(master=list_frame, orient=tk.VERTICAL) - password_list_view = ttk.Treeview(master=list_frame, columns=['site', 'username', 'password'], show='headings', selectmode='browse') - password_list_view.heading('site', text='Site') + # Create list frame with modern styling + list_frame = ttk.Frame(master=root, style='TFrame') + list_frame.pack(fill=tk.BOTH, expand=1, padx=10, pady=(0, 10)) + + # Create scrollbar + scrollbar = ttk.Scrollbar(master=list_frame, orient=tk.VERTICAL) + + # Create treeview with better column configuration + password_list_view = ttk.Treeview(master=list_frame, + columns=['site', 'username', 'password'], + show='headings', + selectmode='browse', + yscrollcommand=scrollbar.set) + + # Configure columns with appropriate widths + password_list_view.heading('site', text='Website/Service') password_list_view.heading('username', text='Username') password_list_view.heading('password', text='Password') + + password_list_view.column('site', width=300, anchor=tk.W) + password_list_view.column('username', width=250, anchor=tk.W) + password_list_view.column('password', width=300, anchor=tk.W) + scrollbar.config(command=password_list_view.yview) - # Load entries into password list view - for site_name in datastore['store'].keys(): - entry_data = decrypt_entry(datastore['store'][site_name], encryption_key, cipher_mode) - password_list_view.insert('', tk.END, values=(deepcopy(site_name), deepcopy(entry_data['username']), deepcopy(entry_data['password']))) + # Load entries into password list view (sorted by website name) + reload_treeview(datastore, encryption_key, password_list_view) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + # Pack scrollbar and treeview password_list_view.pack(side=tk.LEFT, fill=tk.BOTH, expand=1) - list_frame.pack(fill=tk.BOTH, expand=1, padx=4, pady=4) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # Configure button commands add_button.config(command=lambda: handle_password(root, datastore, encryption_key, password_list_view, PASSWORD_ACTION_ADD)) diff --git a/pwmanager/gui/rounded_button.py b/pwmanager/gui/rounded_button.py new file mode 100644 index 0000000..b1567e8 --- /dev/null +++ b/pwmanager/gui/rounded_button.py @@ -0,0 +1,220 @@ +""" +Custom rounded button widget using Canvas for full control over appearance. + +Provides a button with rounded corners that works reliably across all contexts. +""" + +import tkinter as tk + +from pwmanager.gui.styles import StylingColors + + +class RoundedButton(tk.Canvas): + """ + A custom button widget with rounded corners using Canvas. + + This provides full control over the button appearance and works reliably + in both main windows and dialogs. + """ + + def __init__(self, parent, text='', command=None, width=None, height=None, + corner_radius=12, bg_color=None, hover_color=None, + text_color='white', font=None, state='normal', canvas_bg=None, **kwargs): + """ + Create a rounded button. + + Args: + parent: Parent widget + text: Button text + command: Command to execute on click + width: Button width (None for auto) + height: Button height (default: 35) + corner_radius: Radius of rounded corners (default: 12) + bg_color: Background color (default: accent color) + hover_color: Hover state color (default: hover color) + text_color: Text color (default: white) + font: Font tuple (default: Segoe UI, 9, bold) + state: Button state ('normal' or 'disabled') + canvas_bg: Canvas background color to match window theme (default: BG color) + **kwargs: Additional Canvas options + """ + # Set defaults + if bg_color is None: + bg_color = StylingColors.ACCENT.value + if hover_color is None: + hover_color = StylingColors.HOVER.value + if font is None: + font = ('Segoe UI', 9, 'bold') + if height is None: + height = 35 + if canvas_bg is None: + canvas_bg = StylingColors.BG.value + + # Calculate minimum width based on text + if width is None: + temp_canvas = tk.Canvas(parent) + text_width = temp_canvas.create_text(0, 0, text=text, font=font) + bbox = temp_canvas.bbox(text_width) + text_w = bbox[2] - bbox[0] if bbox else 50 + temp_canvas.destroy() + width = max(text_w + 24, 80) # Add padding, minimum 80px + + # Initialize Canvas with background matching window theme + super().__init__(parent, width=width, height=height, + highlightthickness=0, relief='flat', bd=0, + bg=canvas_bg, **kwargs) + + # Store attributes + self._text = text + self._command = command + self._corner_radius = corner_radius + self._bg_color = bg_color + self._hover_color = hover_color + self._text_color = text_color + self._font = font + self._state = state + self._is_hovered = False + self._is_pressed = False + + # Bind events + self.bind('', self._on_enter) + self.bind('', self._on_leave) + self.bind('', self._on_press) + self.bind('', self._on_release) + self.bind('', self._on_motion) + self.bind('', self._on_configure) + + # Draw initial button (after a short delay to ensure widget is sized) + self.after_idle(self._draw) + + def _draw(self): + """Draw the button with current state.""" + self.delete('all') + + # Determine current color + if self._state == 'disabled': + color = StylingColors.DISABLED.value + text_color = '#ffffff' + elif self._is_pressed or self._is_hovered: + color = self._hover_color + text_color = self._text_color + else: + color = self._bg_color + text_color = self._text_color + + # Get dimensions + width = self.winfo_width() + height = self.winfo_height() + + if width <= 1 or height <= 1: + # Widget not yet sized, skip drawing + return + + # Draw rounded rectangle using arcs and rectangles + radius = min(self._corner_radius, width // 2, height // 2) + + # Draw the main rectangle (excluding corners) + self.create_rectangle(radius, 0, width - radius, height, + fill=color, outline=color, width=0) + self.create_rectangle(0, radius, width, height - radius, + fill=color, outline=color, width=0) + + # Draw the four rounded corners using arcs + # Top-left + self.create_arc(0, 0, radius * 2, radius * 2, + start=90, extent=90, fill=color, outline=color, width=0) + # Top-right + self.create_arc(width - radius * 2, 0, width, radius * 2, + start=0, extent=90, fill=color, outline=color, width=0) + # Bottom-right + self.create_arc(width - radius * 2, height - radius * 2, width, height, + start=270, extent=90, fill=color, outline=color, width=0) + # Bottom-left + self.create_arc(0, height - radius * 2, radius * 2, height, + start=180, extent=90, fill=color, outline=color, width=0) + + # Draw text + self.create_text(width // 2, height // 2, text=self._text, + fill=text_color, font=self._font) + + def _on_enter(self, event): + """Handle mouse enter event.""" + if self._state == 'normal': + self._is_hovered = True + self._draw() + self.config(cursor='hand2') + + def _on_leave(self, event): + """Handle mouse leave event.""" + self._is_hovered = False + self._is_pressed = False + self._draw() + self.config(cursor='') + + def _on_press(self, event): + """Handle mouse press event.""" + if self._state == 'normal': + self._is_pressed = True + self._draw() + + def _on_release(self, event): + """Handle mouse release event.""" + if self._state == 'normal' and self._is_pressed: + self._is_pressed = False + self._draw() + # Execute command if within button bounds + x, y = event.x, event.y + if 0 <= x <= self.winfo_width() and 0 <= y <= self.winfo_height(): + if self._command: + self._command() + + def _on_motion(self, event): + """Handle mouse motion event.""" + # Update hover state based on position + x, y = event.x, event.y + width = self.winfo_width() + height = self.winfo_height() + was_hovered = self._is_hovered + self._is_hovered = (0 <= x <= width and 0 <= y <= height and self._state == 'normal') + + if was_hovered != self._is_hovered: + self._draw() + self.config(cursor='hand2' if self._is_hovered else '') + + def _on_configure(self, event): + """Handle widget resize.""" + self._draw() + + def config(self, **kwargs): + """Override config to handle state changes.""" + if 'state' in kwargs: + state_val = kwargs.pop('state') + # Handle both string and tkinter constants + if state_val == 'normal' or state_val == tk.NORMAL: + self._state = 'normal' + elif state_val == 'disabled' or state_val == tk.DISABLED: + self._state = 'disabled' + else: + self._state = str(state_val) + self._draw() + if 'text' in kwargs: + self._text = kwargs.pop('text') + self._draw() + if 'command' in kwargs: + self._command = kwargs.pop('command') + super().config(**kwargs) + + def configure(self, **kwargs): + """Alias for config.""" + self.config(**kwargs) + + def cget(self, key): + """Get configuration value.""" + if key == 'state': + return self._state + if key == 'text': + return self._text + if key == 'command': + return self._command + return super().cget(key) + diff --git a/pwmanager/gui/styles.py b/pwmanager/gui/styles.py new file mode 100644 index 0000000..090fe9a --- /dev/null +++ b/pwmanager/gui/styles.py @@ -0,0 +1,423 @@ +""" +Centralized styling configuration for the password manager GUI. + +Provides consistent modern styling for both the main window and dialogs. +""" + +import tkinter as tk +import tkinter.ttk as ttk +from enum import Enum + +# Try to import PIL for rounded button images +try: + from PIL import Image, ImageDraw, ImageTk + HAS_PIL = True +except ImportError: + HAS_PIL = False + + +class StylingColors(Enum): + """Modern color scheme for the password manager GUI.""" + BG = '#d0d0d0' + FG = '#000000' + ACCENT = '#404040' + HOVER = '#b0b0b0' + BORDER = '#c0c0c0' + ENTRY_BG = '#ffffff' + ENTRY_FG = '#000000' + ERROR = '#c04040' + DISABLED = '#808040' + +# Font configuration +FONT_FAMILY = 'Segoe UI' +FONT_SIZE_NORMAL = 10 +FONT_SIZE_LARGE = 11 +FONT_SIZE_SMALL = 9 + +# Button corner radius for rounded buttons +BUTTON_CORNER_RADIUS = 8 + + +def configure_main_window_styles(root: tk.Tk, style: ttk.Style): + """ + Configure ttk styles for the main application window. + + Args: + root: The main Tk root window + style: The ttk.Style instance to configure + """ + # Set theme + style.theme_use('alt') + + # Configure root window background + root.configure(bg=StylingColors.BG.value) + + # Configure ttk styles + style.configure('TFrame', background=StylingColors.BG.value) + style.configure('TLabel', + background=StylingColors.BG.value, + foreground=StylingColors.FG.value, + font=(FONT_FAMILY, FONT_SIZE_NORMAL)) + style.configure('TButton', + background=StylingColors.ACCENT.value, + foreground='white', + borderwidth=0, + focuscolor='none', + padding=(12, 6), + font=(FONT_FAMILY, FONT_SIZE_SMALL, 'bold')) + style.map('TButton', + background=[('active', StylingColors.HOVER.value), ('pressed', StylingColors.HOVER.value)]) + style.configure('TEntry', + fieldbackground=StylingColors.ENTRY_BG.value, + foreground=StylingColors.FG.value, + borderwidth=1, + relief='solid', + padding=(8, 4), + font=(FONT_FAMILY, FONT_SIZE_NORMAL)) + style.map('TEntry', + bordercolor=[('focus', StylingColors.ACCENT.value)]) + style.configure('Treeview', + background=StylingColors.ENTRY_BG.value, + foreground=StylingColors.ENTRY_FG.value, + fieldbackground=StylingColors.ENTRY_BG.value, + borderwidth=1, + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + rowheight=28) + style.configure('Treeview.Heading', + background=StylingColors.FG.value, + foreground='white', + font=(FONT_FAMILY, FONT_SIZE_NORMAL, 'bold'), + padding=8) + # Explicitly disable hover effects on headers - keep same style regardless of mouse state + style.map('Treeview.Heading', + background=[('active', StylingColors.FG.value), + ('pressed', StylingColors.FG.value)], + foreground=[('active', 'white'), + ('pressed', 'white')]) + style.map('Treeview', + background=[('selected', StylingColors.ACCENT.value)], + foreground=[('selected', 'white')]) + style.configure('TScrollbar', + background=StylingColors.BORDER.value, + troughcolor=StylingColors.BG.value, + borderwidth=0, + arrowcolor=StylingColors.FG.value, + darkcolor=StylingColors.BORDER.value, + lightcolor=StylingColors.BORDER.value) + + +def configure_dialog_styles(dialog_window: tk.Toplevel, style: ttk.Style): + """ + Configure ttk styles for dialog windows. + + Args: + dialog_window: The Toplevel dialog window + style: The ttk.Style instance to configure + """ + # Configure dialog window background + dialog_window.configure(bg=StylingColors.BG.value) + + # Configure dialog-specific ttk styles + style.configure('Dialog.TFrame', background=StylingColors.BG.value) + style.configure('Dialog.TLabel', + background=StylingColors.BG.value, + foreground=StylingColors.FG.value, + font=(FONT_FAMILY, FONT_SIZE_NORMAL)) + style.configure('Dialog.TButton', + background=StylingColors.ACCENT.value, + foreground='white', + borderwidth=0, + focuscolor='none', + padding=(12, 6), + font=(FONT_FAMILY, FONT_SIZE_SMALL, 'bold')) + style.map('Dialog.TButton', + background=[('active', StylingColors.HOVER.value), + ('pressed', StylingColors.HOVER.value), + ('disabled', StylingColors.DISABLED.value)]) + style.configure('Dialog.TEntry', + fieldbackground=StylingColors.ENTRY_BG.value, + foreground=StylingColors.FG.value, + borderwidth=1, + relief='solid', + padding=(8, 4), + font=(FONT_FAMILY, FONT_SIZE_NORMAL)) + style.map('Dialog.TEntry', + bordercolor=[('focus', StylingColors.ACCENT.value)]) + + +def get_entry_style_config(): + """ + Get styling configuration for tk.Entry widgets (used for password fields). + + Returns: + dict: Dictionary of styling options for tk.Entry + """ + return { + 'bg': StylingColors.ENTRY_BG.value, + 'fg': StylingColors.FG.value, + 'font': (FONT_FAMILY, FONT_SIZE_NORMAL), + 'relief': 'solid', + 'bd': 1, + 'highlightthickness': 1, + 'highlightcolor': StylingColors.ACCENT.value, + 'highlightbackground': StylingColors.BORDER.value + } + + +def _hex_to_rgb(hex_color: str) -> tuple: + """Convert hex color to RGB tuple.""" + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + + +def _create_rounded_button_image(width: int, height: int, bg_color: str, + corner_radius: int = BUTTON_CORNER_RADIUS) -> ImageTk.PhotoImage: + """ + Create a rounded rectangle button image. + + Args: + width: Button width in pixels + height: Button height in pixels + bg_color: Background color (hex string) + corner_radius: Radius of rounded corners + + Returns: + PhotoImage object for use with ttk.Button + """ + if not HAS_PIL: + return None + + # Create image with the button background color (not transparent) + # This prevents black corners from showing through + rgb = _hex_to_rgb(bg_color) + img = Image.new('RGB', (width, height), rgb) + draw = ImageDraw.Draw(img) + + # Draw rounded rectangle with the same color (fills the entire area) + draw.rounded_rectangle( + [(0, 0), (width - 1, height - 1)], + radius=corner_radius, + fill=rgb, + outline=rgb # Same color for outline to avoid borders + ) + + return ImageTk.PhotoImage(img) + + +def _create_button_images(style: ttk.Style, style_name: str, + normal_color: str, hover_color: str, + disabled_color: str = None): + """ + Create rounded button images for different states using PIL. + + This function creates rounded rectangle images and applies them to buttons + using a border image approach that works with ttk's layout system. + + Args: + style: ttk.Style instance + style_name: Style name (e.g., 'TButton' or 'Dialog.TButton') + normal_color: Normal state color + hover_color: Hover/pressed state color + disabled_color: Disabled state color (optional) + """ + if not HAS_PIL: + return + + # Skip dialog buttons - they have display issues with layout modification + if 'Dialog' in style_name: + return + + # Create images with sufficient size for border image scaling + # The border image will be sliced into 9 parts for proper scaling + width, height = 200, 50 + radius = BUTTON_CORNER_RADIUS + + # Create images for different states + normal_img = _create_rounded_button_image(width, height, normal_color) + hover_img = _create_rounded_button_image(width, height, hover_color) + disabled_img = None + if disabled_color: + disabled_img = _create_rounded_button_image(width, height, disabled_color or StylingColors.DISABLED.value) + + if normal_img: + # Store images to prevent garbage collection + if not hasattr(style, '_button_images'): + style._button_images = {} + style._button_images[style_name] = { + 'normal': normal_img, + 'hover': hover_img, + 'disabled': disabled_img + } + + try: + # Get original layout + try: + original_layout = style.layout(style_name) + except Exception: + original_layout = None + + # Clean up any existing image element first + try: + style.element_delete(f'{style_name}.image') + except Exception: + pass + + # Configure button to remove background (will use image instead) + style.configure(style_name, background='', borderwidth=0, relief='flat') + + # Create the image element - this will be the button background + style.element_create( + f'{style_name}.image', + 'image', + normal_img, + ('active', hover_img), + ('pressed', hover_img), + ('disabled', disabled_img) if disabled_img else (), + sticky='nsew' + ) + + # Create a simple, reliable layout structure + # Image as base layer, then focus/padding/label on top + new_layout = [ + (f'{style_name}.image', {'sticky': 'nsew'}), + ('Button.focus', {'sticky': 'nsew', 'children': [ + ('Button.padding', {'sticky': 'nsew', 'children': [ + ('Button.label', {'sticky': 'nsew'}) + ]}) + ]}) + ] + + # Apply the layout + style.layout(style_name, new_layout) + + # Ensure foreground color is set + style.configure(style_name, foreground='white') + + except Exception: + # On any error, clean up and restore normal button + try: + style.element_delete(f'{style_name}.image') + except Exception: + pass + # Restore normal button styling + style.configure(style_name, background=normal_color, relief='flat') + + +def configure_main_window_styles(root: tk.Tk, style: ttk.Style): + """ + Configure ttk styles for the main application window. + + Args: + root: The main Tk root window + style: The ttk.Style instance to configure + """ + # Set theme + style.theme_use('alt') + + # Configure root window background + root.configure(bg=StylingColors.BG.value) + + # Configure ttk styles + style.configure('TFrame', background=StylingColors.BG.value) + style.configure('TLabel', + background=StylingColors.BG.value, + foreground=StylingColors.FG.value, + font=(FONT_FAMILY, FONT_SIZE_NORMAL)) + + # Configure button style first + style.configure('TButton', + background=StylingColors.ACCENT.value, + foreground='white', + borderwidth=0, + focuscolor='none', + padding=(12, 6), + font=(FONT_FAMILY, FONT_SIZE_SMALL, 'bold')) + style.map('TButton', + background=[('active', StylingColors.HOVER.value), ('pressed', StylingColors.HOVER.value)]) + + # Create rounded button images (simplified approach) + _create_button_images(style, 'TButton', StylingColors.ACCENT.value, StylingColors.HOVER.value) + + style.configure('TEntry', + fieldbackground=StylingColors.ENTRY_BG.value, + foreground=StylingColors.FG.value, + borderwidth=1, + relief='solid', + padding=(8, 4), + font=(FONT_FAMILY, FONT_SIZE_NORMAL)) + style.map('TEntry', + bordercolor=[('focus', StylingColors.ACCENT.value)]) + style.configure('Treeview', + background=StylingColors.ENTRY_BG.value, + foreground=StylingColors.ENTRY_FG.value, + fieldbackground=StylingColors.ENTRY_BG.value, + borderwidth=1, + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + rowheight=28) + style.configure('Treeview.Heading', + background=StylingColors.FG.value, + foreground='white', + font=(FONT_FAMILY, FONT_SIZE_NORMAL, 'bold'), + padding=8) + # Explicitly disable hover effects on headers - keep same style regardless of mouse state + style.map('Treeview.Heading', + background=[('active', StylingColors.FG.value), + ('pressed', StylingColors.FG.value)], + foreground=[('active', 'white'), + ('pressed', 'white')]) + style.map('Treeview', + background=[('selected', StylingColors.ACCENT.value)], + foreground=[('selected', 'white')]) + style.configure('TScrollbar', + background=StylingColors.BORDER.value, + troughcolor=StylingColors.BG.value, + borderwidth=0, + arrowcolor=StylingColors.FG.value, + darkcolor=StylingColors.BORDER.value, + lightcolor=StylingColors.BORDER.value) + + +def configure_dialog_styles(dialog_window: tk.Toplevel, style: ttk.Style): + """ + Configure ttk styles for dialog windows. + + Args: + dialog_window: The Toplevel dialog window + style: The ttk.Style instance to configure + """ + # Configure dialog window background + dialog_window.configure(bg=StylingColors.BG.value) + + # Configure dialog-specific ttk styles + style.configure('Dialog.TFrame', background=StylingColors.BG.value) + style.configure('Dialog.TLabel', + background=StylingColors.BG.value, + foreground=StylingColors.FG.value, + font=(FONT_FAMILY, FONT_SIZE_NORMAL)) + + # Configure button style first + style.configure('Dialog.TButton', + background=StylingColors.ACCENT.value, + foreground='white', + borderwidth=0, + focuscolor='none', + padding=(12, 6), + font=(FONT_FAMILY, FONT_SIZE_SMALL, 'bold')) + style.map('Dialog.TButton', + background=[('active', StylingColors.HOVER.value), + ('pressed', StylingColors.HOVER.value), + ('disabled', StylingColors.DISABLED.value)]) + + # Rounded corners disabled for dialog buttons - causes display issues + # Main window buttons can use rounded corners, but dialogs need standard buttons + # _create_button_images(style, 'Dialog.TButton', StylingColors.ACCENT.value, StylingColors.HOVER.value, StylingColors.DISABLED.value) + style.configure('Dialog.TEntry', + fieldbackground=StylingColors.ENTRY_BG.value, + foreground=StylingColors.FG.value, + borderwidth=1, + relief='solid', + padding=(8, 4), + font=(FONT_FAMILY, FONT_SIZE_NORMAL)) + style.map('Dialog.TEntry', + bordercolor=[('focus', StylingColors.ACCENT.value)]) + diff --git a/requirements.txt b/requirements.txt index 79cd4ac..7a8ef6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pycryptodome>=3.15.0 +Pillow>=9.0.0