From 09a3cb3faa4d568057be2014cf5133c3b2409c3d Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Sat, 7 Nov 2026 14:34:48 +0200 Subject: [PATCH] Port the Windows redesign: window and dialogs (0.7.0) The black-and-green look of PassMan for Windows 2.7.0 (window, vault-tile login) and 2.9.0 (dialogs), ported. dialogs.py, widgets.py, history_ui.py and test_gui_helpers.py are the Windows files unchanged; gui.py and theme.py are the Windows ones with the Linux differences kept: the Tk clipboard, the PNG window icon, DejaVu fonts and clipboard-manager wording. sync_ui.py differs only in a Linux certificate path example. tools/smoke_gui.py logs in through the vault tile and also screenshots an entry dialog, so CI shows the new dialogs rendered on Linux. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ojnyVg7ccsR3pZ64s7aRH --- README.md | 12 +- sourceFiles/dialogs.py | 484 +++++++--------- sourceFiles/gui.py | 799 ++++++++++++++++++-------- sourceFiles/history_ui.py | 73 +-- sourceFiles/sync_ui.py | 352 ++++++------ sourceFiles/tests/test_gui_helpers.py | 50 ++ sourceFiles/theme.py | 46 +- sourceFiles/version.py | 2 +- sourceFiles/widgets.py | 294 ++++++++-- tools/smoke_gui.py | 23 +- 10 files changed, 1300 insertions(+), 835 deletions(-) create mode 100644 sourceFiles/tests/test_gui_helpers.py diff --git a/README.md b/README.md index 693b4f8..5b1ae24 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ encrypted file. There is no account and no service behind it: it works entirely offline, and if you want two PCs in step you run the sync server yourself, on your own hardware. -**Version 0.6.0 — early.** It installs from a `.deb` on Ubuntu 22.04 or +**Version 0.7.0 — early.** It installs from a `.deb` on Ubuntu 22.04 or newer, or runs from source. It has so far been tested in CI only, on a virtual display, not yet on a real desktop. @@ -19,8 +19,9 @@ virtual display, not yet on a real desktop. | [PassMan-Server-Linux](https://github.com/usermaneGermane/PassMan-Server-Linux) | the sync server for Linux, run by systemd | **Same vaults as on Windows.** The vault file, the sync protocol and the -features match PassMan for Windows 2.8.0, except the 2.7.0 redesign, which is -not ported yet (see *What is different from Windows*). +features match PassMan for Windows 2.9.0, including its black-and-green +window and dialogs (see the version table under *What is different from +Windows*). A vault moves between a Windows PC and a Linux PC unchanged, and both sync with either server. @@ -132,8 +133,9 @@ Where that README names a Windows version, read the Linux one beside it: | 2.4.0 | 0.3.0 | deleting archived entries after N days | | 2.5.0 | 0.4.0 | switching servers no longer removes entries silently | | 2.6.0 | 0.5.0 | a copy that is not installed says LAB in its title | -| 2.7.0 | not yet | the redesigned window | +| 2.7.0 | 0.7.0 | the redesigned window | | 2.8.0 | 0.6.0 | locking no longer clears a copied password | +| 2.9.0 | 0.7.0 | the dialogs redesigned to match | (0.2.0 added the `.deb` package, which has no Windows counterpart.) @@ -141,7 +143,7 @@ Where that README names a Windows version, read the Linux one beside it: The master password *is* the encryption key. Nobody can decrypt your vault without it, and there is no reset and no recovery. **Make backups:** -Settings → *Save encrypted backup* writes a copy that is still protected by +Settings → *Encrypted backup* → *Save backup…* writes a copy that is still protected by your master password. ## Project layout diff --git a/sourceFiles/dialogs.py b/sourceFiles/dialogs.py index 043c1cc..3288eb8 100644 --- a/sourceFiles/dialogs.py +++ b/sourceFiles/dialogs.py @@ -5,6 +5,9 @@ uses its helpers (``app._modal``, ``app.toast``, ``app._save`` ...). Splitting them out keeps gui.py to the application shell: the login screen, the account list and the session lifecycle. + +Every dialog is laid out by :func:`widgets.dialog_shell`: a header band, the +body, and the buttons in a footer bar. """ import os @@ -16,38 +19,40 @@ import importers import sync_client import vault -from theme import (F, TEXT, MUTED, GREEN, GREEN_HOVER, DANGER, DANGER_HOVER, - INPUT_BG, BORDER, CARD_HOVER, ON_ACCENT, MONO) +from theme import (F, TEXT, MUTED, GREEN, GREEN_HOVER, INPUT_BG, BORDER, + CARD_HOVER, ON_ACCENT, MONO, tile_colors) from vault import (add_account, create_new_user, delete_user, get_vault_path, validate_username, export_encrypted, export_csv, export_json, update_entry, change_master_password, find_duplicate) +from widgets import (dialog_shell, section, setting_row, option_menu, note, + error_label, check, field_label, entry, danger_button) from crypto_utils import decrypt from password_generator import generate_password from version import long_version_string -def create_user_dialog(app): - top = app._modal("Create new user", 440, 560) - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=30, pady=26) +def _site_tile(site): + bg, ink, _ = tile_colors(site) + return (site[:1].upper() or "?"), bg, ink + - ctk.CTkLabel(wrap, text="Create new user", font=F(20, "bold"), - text_color=TEXT).pack(anchor="w") - ctk.CTkLabel(wrap, text="Your master password encrypts the vault and " - "cannot be recovered.", font=F(12), text_color=MUTED, - wraplength=380, justify="left").pack(anchor="w", pady=(2, 6)) +def create_user_dialog(app): + top = app._modal("Create new user", 460, 560) + shell = dialog_shell(top, "Create new user", + "Your master password encrypts the vault and " + "cannot be recovered.") + wrap = shell.body user_entry = app._field(wrap, "Username", placeholder="e.g. alex") pw_entry = app._field(wrap, "Master password", show="•") - confirm_entry = app._field(wrap, "Confirm password", show="•") - bar, label, update_strength = app._strength_widget(wrap) + confirm_entry = app._field(wrap, "Confirm password", show="•") pw_entry.bind("", lambda e: update_strength(pw_entry.get())) show = ctk.CTkSwitch(wrap, text="Show passwords", font=F(12), text_color=MUTED, progress_color=GREEN) - show.pack(anchor="w", pady=(10, 4)) + show.pack(anchor="w", pady=(14, 0)) def toggle(): s = "" if show.get() else "•" @@ -55,8 +60,7 @@ def toggle(): confirm_entry.configure(show=s) show.configure(command=toggle) - err = ctk.CTkLabel(wrap, text="", font=F(12), text_color=DANGER) - err.pack(anchor="w", pady=(4, 0)) + err = error_label(wrap, shell.text_width) def create(): u = user_entry.get().strip() @@ -89,10 +93,10 @@ def create(): app.show_main_window() app.toast(f"Welcome, {u}!") - row = ctk.CTkFrame(wrap, fg_color="transparent") - row.pack(fill="x", side="bottom", pady=(14, 0)) - app._ghost_button(row, "Cancel", top.destroy, width=120).pack(side="left") - app._accent_button(row, "Create user", create, width=150).pack(side="right") + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=120).pack(side="left") + app._accent_button(shell.buttons, "Create user", create, + width=150).pack(side="right") for e in (user_entry, pw_entry, confirm_entry): e.bind("", lambda ev: create()) user_entry.focus() @@ -133,21 +137,12 @@ def _import_encrypted_vault(app, path): app.toast(f"Could not read file: {e}", "error") return - top = app._modal("Unlock vault file", 430, 260) - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=28, pady=24) - ctk.CTkLabel(wrap, text="Unlock vault file", font=F(18, "bold"), - text_color=TEXT).pack(anchor="w") - ctk.CTkLabel(wrap, text=f"“{os.path.basename(path)}” is encrypted. Enter " - "its master password to import the accounts inside.", - font=F(12), text_color=MUTED, wraplength=370, - justify="left").pack(anchor="w", pady=(4, 12)) - pw = ctk.CTkEntry(wrap, height=42, corner_radius=10, fg_color=INPUT_BG, - border_color=BORDER, show="•", font=F(14), - placeholder_text="Master password of the file") - pw.pack(fill="x") - err = ctk.CTkLabel(wrap, text="", font=F(12), text_color=DANGER) - err.pack(anchor="w", pady=(6, 0)) + top = app._modal("Unlock vault file", 460, 280) + shell = dialog_shell(top, "Unlock vault file", + f"“{os.path.basename(path)}” is encrypted. Enter its " + "master password to import the accounts inside.") + pw = app._field(shell.body, "Master password of the file", show="•") + err = error_label(shell.body, shell.text_width) def unlock(): try: @@ -162,11 +157,10 @@ def unlock(): return _finish_import(app, result, path) - row = ctk.CTkFrame(wrap, fg_color="transparent") - row.pack(fill="x", side="bottom", pady=(14, 0)) - app._ghost_button(row, "Cancel", top.destroy, width=110).pack(side="left") - app._accent_button(row, "Unlock & import", unlock, - width=170).pack(side="right") + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=110).pack(side="left") + app._accent_button(shell.buttons, "Unlock & import", unlock, + width=170).pack(side="right") pw.bind("", lambda e: unlock()) pw.focus() @@ -183,13 +177,13 @@ def _finish_import(app, parsed, path): seen = {(a["site"], a["username"], a["password"]) for a in app.vault["accounts"]} new_items, dupes = [], 0 - for entry in candidates: - key = (entry.site, entry.username, entry.password) + for item in candidates: + key = (item.site, item.username, item.password) if key in seen: dupes += 1 continue seen.add(key) - new_items.append(entry) + new_items.append(item) if not new_items: app.toast("Those entries are already in your vault", "warn") return @@ -211,9 +205,9 @@ def _finish_import(app, parsed, path): if not app._confirm("Import accounts", msg, danger=False): return - for entry in new_items: - add_account(app.vault, entry.site, entry.username, entry.password, - notes=entry.notes, totp=entry.totp) + for item in new_items: + add_account(app.vault, item.site, item.username, item.password, + notes=item.notes, totp=item.totp) app._save() app.refresh_account_list() app.toast(f"Imported {len(new_items)} account(s)") @@ -221,40 +215,34 @@ def _finish_import(app, parsed, path): def account_dialog(app, acc): editing = acc is not None - title = "Edit account" if editing else "Add account" - subtitle = ("Update this login; changes are saved to your vault." - if editing else "Store a new login in your vault.") - - top = app._modal(title, 460, 720) - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=30, pady=26) + title = "Edit entry" if editing else "Add an entry" + subtitle = ("Update this login; the version it replaces is kept in its " + "history." if editing else "Store a new login in your vault.") - ctk.CTkLabel(wrap, text=title, font=F(20, "bold"), - text_color=TEXT).pack(anchor="w") - ctk.CTkLabel(wrap, text=subtitle, font=F(12), - text_color=MUTED).pack(anchor="w", pady=(2, 6)) + top = app._modal(title, 500, 760) + shell = dialog_shell(top, acc["site"] if editing else title, + subtitle, tile=_site_tile(acc["site"] if editing + else "")) + wrap = shell.body - site_entry = app._field(wrap, "Website / App", - placeholder="e.g. GitHub") + site_entry = app._field(wrap, "Website / App", placeholder="e.g. GitHub") user_entry = app._field(wrap, "Username / Email", - placeholder="e.g. you@example.com") + placeholder="e.g. you@example.com") - ctk.CTkLabel(wrap, text="Password", anchor="w", font=F(13, "bold"), - text_color=MUTED).pack(anchor="w", pady=(12, 4)) + field_label(wrap, "Password") pw_row = ctk.CTkFrame(wrap, fg_color="transparent") pw_row.pack(fill="x") pw_row.grid_columnconfigure(0, weight=1) - pw_entry = ctk.CTkEntry(pw_row, height=42, corner_radius=10, - fg_color=INPUT_BG, border_color=BORDER, - show="•", font=F(14)) + pw_entry = entry(pw_row, show="•", + font=ctk.CTkFont(family=MONO, size=14)) pw_entry.grid(row=0, column=0, sticky="ew") show_btn = ctk.CTkButton( - pw_row, text="Show", width=58, height=42, corner_radius=10, + pw_row, text="Show", width=64, height=44, corner_radius=12, fg_color="transparent", border_width=1, border_color=BORDER, - hover_color=CARD_HOVER, text_color=MUTED, font=F(12, "bold")) + hover_color=CARD_HOVER, text_color=TEXT, font=F(12, "bold")) show_btn.grid(row=0, column=1, padx=(8, 8)) gen_btn = ctk.CTkButton( - pw_row, text="Generate", width=92, height=42, corner_radius=10, + pw_row, text="Generate", width=96, height=44, corner_radius=12, fg_color=GREEN, hover_color=GREEN_HOVER, text_color=ON_ACCENT, font=F(12, "bold")) gen_btn.grid(row=0, column=2) @@ -265,10 +253,8 @@ def account_dialog(app, acc): wrap, "TOTP secret (optional)", placeholder="stored for safekeeping — codes are not generated") - ctk.CTkLabel(wrap, text="Notes (optional)", anchor="w", - font=F(13, "bold"), text_color=MUTED).pack(anchor="w", - pady=(12, 4)) - notes_box = ctk.CTkTextbox(wrap, height=70, corner_radius=10, + field_label(wrap, "Notes (optional)") + notes_box = ctk.CTkTextbox(wrap, height=76, corner_radius=12, fg_color=INPUT_BG, border_color=BORDER, border_width=1, font=F(13)) notes_box.pack(fill="x") @@ -288,6 +274,15 @@ def gen(): gen_btn.configure(command=gen) pw_entry.bind("", lambda e: update_strength(pw_entry.get())) + def retitle(_=None): + # The header follows the site as it is typed, tile and all, so the + # dialog looks like the details card the entry will get. + site = site_entry.get().strip() + letter, bg, ink = _site_tile(site) + shell.tile.configure(text=letter, fg_color=bg, text_color=ink) + shell.heading.configure(text=site or title) + site_entry.bind("", retitle, add="+") + if editing: site_entry.insert(0, acc["site"]) user_entry.insert(0, acc["username"]) @@ -296,8 +291,7 @@ def gen(): notes_box.insert("1.0", acc.get("notes", "")) update_strength(acc["password"]) - err = ctk.CTkLabel(wrap, text="", font=F(12), text_color=DANGER) - err.pack(anchor="w", pady=(6, 0)) + err = error_label(wrap, shell.text_width) def save(): site = site_entry.get().strip() @@ -334,40 +328,30 @@ def save(): app.refresh_account_list() app.toast("Account updated" if editing else "Account added") - row = ctk.CTkFrame(wrap, fg_color="transparent") - row.pack(fill="x", side="bottom", pady=(14, 0)) - app._ghost_button(row, "Cancel", top.destroy, width=120).pack(side="left") - app._accent_button(row, "Save changes" if editing else "Save account", - save, width=150).pack(side="right") + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=120).pack(side="left") + app._accent_button(shell.buttons, + "Save changes" if editing else "Save entry", + save, width=150).pack(side="right") for e in (site_entry, user_entry, pw_entry, totp_entry): e.bind("", lambda ev: save()) site_entry.focus() def generator_dialog(app): - top = app._modal("Password generator", 470, 480) - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=30, pady=26) - - ctk.CTkLabel(wrap, text="Password generator", font=F(20, "bold"), - text_color=TEXT).pack(anchor="w") - - # result field - result = ctk.CTkEntry(wrap, height=48, corner_radius=10, fg_color=INPUT_BG, - border_color=BORDER, - font=ctk.CTkFont(family=MONO, size=16)) - result.pack(fill="x", pady=(16, 10)) - + top = app._modal("Password generator", 480, 520) + shell = dialog_shell(top, "Password generator", + "A new password appears with every change. Nothing " + "is saved — copy it, or use Generate in an entry.") + wrap = shell.body + + field_label(wrap, "Password") + result = entry(wrap, height=52, font=ctk.CTkFont(family=MONO, size=16)) + result.pack(fill="x") bar, strength_label, update_strength = app._strength_widget(wrap) - # length - len_row = ctk.CTkFrame(wrap, fg_color="transparent") - len_row.pack(fill="x", pady=(14, 2)) - len_val = ctk.CTkLabel(len_row, text="Length: 20", font=F(13, "bold"), - text_color=TEXT) - len_val.pack(side="left") + card = section(wrap, "Options") length = ctk.IntVar(value=20) - opts = {k: ctk.BooleanVar(value=True) for k in ("upper", "lower", "digits", "symbols")} @@ -379,65 +363,60 @@ def regenerate(*_): result.delete(0, "end"); result.insert(0, pw) update_strength(pw) - slider = ctk.CTkSlider(wrap, from_=6, to=64, number_of_steps=58, - variable=length, progress_color=GREEN, - button_color=GREEN, button_hover_color=GREEN_HOVER, - command=lambda v: (len_val.configure( - text=f"Length: {int(float(v))}"), regenerate())) - slider.pack(fill="x", pady=(2, 12)) - - checks = ctk.CTkFrame(wrap, fg_color="transparent") - checks.pack(fill="x") + len_row = ctk.CTkFrame(card, fg_color="transparent") + len_row.pack(fill="x", padx=16, pady=(14, 0)) + ctk.CTkLabel(len_row, text="Length", font=F(13, "bold"), + text_color=TEXT).pack(side="left") + len_val = ctk.CTkLabel(len_row, text="20", font=F(13, "bold"), + text_color=GREEN) + len_val.pack(side="right") + ctk.CTkSlider(card, from_=6, to=64, number_of_steps=58, variable=length, + progress_color=GREEN, button_color=GREEN, + button_hover_color=GREEN_HOVER, + command=lambda v: (len_val.configure(text=str(int(float(v)))), + regenerate()) + ).pack(fill="x", padx=12, pady=(8, 6)) + + checks = ctk.CTkFrame(card, fg_color="transparent") + checks.pack(fill="x", padx=16, pady=(4, 14)) for idx, (key, lab) in enumerate((("upper", "A-Z"), ("lower", "a-z"), ("digits", "0-9"), ("symbols", "!@#"))): ctk.CTkCheckBox(checks, text=lab, variable=opts[key], font=F(13), text_color=TEXT, fg_color=GREEN, - hover_color=GREEN_HOVER, command=regenerate, - checkbox_width=22, checkbox_height=22 - ).grid(row=0, column=idx, padx=(0, 18), pady=6) + hover_color=GREEN_HOVER, border_color=MUTED, + command=regenerate, checkbox_width=22, + checkbox_height=22, width=80 + ).grid(row=0, column=idx, sticky="w", pady=4) - row = ctk.CTkFrame(wrap, fg_color="transparent") - row.pack(fill="x", side="bottom", pady=(16, 0)) - app._ghost_button(row, "Regenerate", regenerate, width=130).pack(side="left") + app._ghost_button(shell.buttons, "Regenerate", regenerate, + width=130).pack(side="left") app._accent_button( - row, "Copy", lambda: app._copy(result.get(), "Password copied", - sensitive=True), + shell.buttons, "Copy", lambda: app._copy(result.get(), + "Password copied", + sensitive=True), width=150).pack(side="right") regenerate() def settings_dialog(app): - top = app._modal("Settings", 480, 620) - # The sections scroll and the Close row stays put: together they are - # taller than many screens (display scaling, Linux font metrics), and - # growing the window to fit pushed Close off the bottom of the screen. - foot = ctk.CTkFrame(top, fg_color="transparent") - foot.pack(fill="x", side="bottom", padx=30, pady=(10, 20)) - wrap = ctk.CTkScrollableFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=(30, 12), pady=(20, 0)) - - ctk.CTkLabel(wrap, text="Settings", font=F(20, "bold"), - text_color=TEXT).pack(anchor="w", pady=(0, 16)) + # The sections scroll and the header and Close row stay put: together + # they are taller than many screens (display scaling, Linux font + # metrics), and growing the window to fit pushed Close off the screen. + top = app._modal("Settings", 540, 660) + shell = dialog_shell(top, "Settings", + f"For the vault “{app.current_user}” on this PC.", + scroll=True) + wrap = shell.body + hint = shell.text_width - 210 # ---- security ---- - ctk.CTkLabel(wrap, text="Security", font=F(13, "bold"), - text_color=MUTED).pack(anchor="w") - sec = ctk.CTkFrame(wrap, fg_color="transparent", border_width=1, - border_color=BORDER, corner_radius=10) - sec.pack(fill="x", pady=(6, 18)) - ctk.CTkLabel(sec, text="Re-encrypts your vault with a new master " - "password.", font=F(12), text_color=MUTED, - wraplength=340, justify="left").pack(anchor="w", padx=14, - pady=(12, 8)) - app._ghost_button(sec, "Change master password", - lambda: _change_master_dialog(app, top), - width=210).pack(anchor="w", padx=14, pady=(0, 12)) - - lock_row = ctk.CTkFrame(sec, fg_color="transparent") - lock_row.pack(fill="x", padx=14, pady=(0, 14)) - ctk.CTkLabel(lock_row, text="Lock after inactivity", font=F(12), - text_color=TEXT).pack(side="left") + sec = section(wrap, "Security", first=True) + app._ghost_button( + setting_row(sec, "Master password", + "Re-encrypts your vault with a new one.", hint, first=True), + "Change…", lambda: _change_master_dialog(app, top), + width=150).pack() def label_for(m): return "Never" if m == 0 else f"{m} minute{'s' if m != 1 else ''}" @@ -449,47 +428,30 @@ def set_autolock(choice): app._start_idle_timer() break - ctk.CTkOptionMenu( - lock_row, width=130, height=32, corner_radius=8, - values=[label_for(m) for m in appconfig.AUTOLOCK_CHOICES], - fg_color=INPUT_BG, button_color=BORDER, button_hover_color=CARD_HOVER, - text_color=TEXT, font=F(12), command=set_autolock, - ).pack(side="right") - # reflect the stored value - for child in lock_row.winfo_children(): - if isinstance(child, ctk.CTkOptionMenu): - child.set(label_for(appconfig.autolock_minutes())) + lock_menu = option_menu( + setting_row(sec, "Lock after inactivity", + "Locking keeps a copied password until it clears.", hint), + [label_for(m) for m in appconfig.AUTOLOCK_CHOICES], set_autolock, + width=150) + lock_menu.pack() + lock_menu.set(label_for(appconfig.autolock_minutes())) # ---- sync ---- - ctk.CTkLabel(wrap, text="Sync", font=F(13, "bold"), - text_color=MUTED).pack(anchor="w") - syn = ctk.CTkFrame(wrap, fg_color="transparent", border_width=1, - border_color=BORDER, corner_radius=10) - syn.pack(fill="x", pady=(6, 18)) + syn = section(wrap, "Sync") configured = sync_client.is_configured(app.vault) - ctk.CTkLabel( - syn, - text=(f"Syncing with {sync_client.get_settings(app.vault)['url']}" - if configured else - "Keep this vault in step with another PC through your own " - "server. The vault is encrypted before it is sent."), - font=F(12), text_color=GREEN if configured else MUTED, - wraplength=340, justify="left").pack(anchor="w", padx=14, pady=(12, 8)) - app._ghost_button(syn, - "Sync settings" if configured else "Set up sync", - lambda: (top.destroy(), app.sync_settings_dialog()), - width=210).pack(anchor="w", padx=14, pady=(0, 14)) + app._ghost_button( + setting_row(syn, "Syncing" if configured else "Not set up", + (f"With {sync_client.get_settings(app.vault)['url']}" + if configured else + "Keep this vault in step with another PC through your " + "own server. It is encrypted before it is sent."), + hint, first=True), + "Sync settings" if configured else "Set up sync", + lambda: (top.destroy(), app.sync_settings_dialog()), + width=150).pack() # ---- archive ---- - ctk.CTkLabel(wrap, text="Archive", font=F(13, "bold"), - text_color=MUTED).pack(anchor="w") - arc = ctk.CTkFrame(wrap, fg_color="transparent", border_width=1, - border_color=BORDER, corner_radius=10) - arc.pack(fill="x", pady=(6, 18)) - purge_row = ctk.CTkFrame(arc, fg_color="transparent") - purge_row.pack(fill="x", padx=14, pady=(12, 6)) - ctk.CTkLabel(purge_row, text="Delete archived entries", font=F(12), - text_color=TEXT).pack(side="left") + arc = section(wrap, "Archive") def days_label(d): return "Never" if d == 0 else f"After {d} days" @@ -515,61 +477,44 @@ def set_purge(choice): app.toast(f"Deleted {purged} archived " f"entr{'y' if purged == 1 else 'ies'}", "warn") - purge_menu = ctk.CTkOptionMenu( - purge_row, width=130, height=32, corner_radius=8, - values=[days_label(d) for d in vault.ARCHIVE_PURGE_CHOICES], - fg_color=INPUT_BG, button_color=BORDER, button_hover_color=CARD_HOVER, - text_color=TEXT, font=F(12), command=set_purge) - purge_menu.pack(side="right") + purge_menu = option_menu( + setting_row(arc, "Delete archived entries", + "Counted from when an entry was archived, and applied " + "each time you unlock. Deleted entries are gone for good, " + "from your other PC too after its next sync.", hint, + first=True), + [days_label(d) for d in vault.ARCHIVE_PURGE_CHOICES], set_purge, + width=150) + purge_menu.pack() purge_menu.set(days_label(vault.archive_purge_days(app.vault))) - ctk.CTkLabel(arc, text="Counted from when an entry was archived, and " - "applied each time you unlock. Deleted entries are gone for " - "good, from your other PC too after its next sync.", - font=F(12), text_color=MUTED, wraplength=340, - justify="left").pack(anchor="w", padx=14, pady=(0, 12)) # ---- backup & export ---- - ctk.CTkLabel(wrap, text="Backup & export", font=F(13, "bold"), - text_color=MUTED).pack(anchor="w") - exp = ctk.CTkFrame(wrap, fg_color="transparent", border_width=1, - border_color=BORDER, corner_radius=10) - exp.pack(fill="x", pady=(6, 18)) - ctk.CTkLabel(exp, text="An encrypted backup stays protected by your " - "master password, so it is safe to keep on a USB stick " - "or in cloud storage.", font=F(12), text_color=MUTED, - wraplength=340, justify="left").pack(anchor="w", padx=14, - pady=(12, 8)) - app._ghost_button(exp, "Save encrypted backup", - lambda: _backup_encrypted(app), width=210 - ).pack(anchor="w", padx=14, pady=(0, 10)) - ctk.CTkLabel(exp, text="A plain export is readable by anyone who " - "opens the file.", font=F(12), text_color=MUTED, - wraplength=340, justify="left").pack(anchor="w", padx=14, - pady=(0, 8)) - app._ghost_button(exp, "Export unencrypted…", lambda: _export_plaintext(app), - width=210, danger=True).pack(anchor="w", padx=14, - pady=(0, 14)) + exp = section(wrap, "Backup & export") + app._ghost_button( + setting_row(exp, "Encrypted backup", + "Stays protected by your master password, so it is safe " + "on a USB stick or in cloud storage.", hint, first=True), + "Save backup…", lambda: _backup_encrypted(app), width=150).pack() + app._ghost_button( + setting_row(exp, "Plain export", + "Readable by anyone who opens the file.", hint), + "Export…", lambda: _export_plaintext(app), width=150, + danger=True).pack() # ---- danger zone ---- - ctk.CTkLabel(wrap, text="Danger zone", font=F(13, "bold"), - text_color=DANGER).pack(anchor="w") - danger = ctk.CTkFrame(wrap, fg_color="transparent", border_width=1, - border_color=DANGER, corner_radius=10) - danger.pack(fill="x", pady=(6, 6)) - ctk.CTkLabel(danger, text=f"Permanently delete the vault for " - f"“{app.current_user}”.", font=F(12), - text_color=MUTED, wraplength=340, justify="left" - ).pack(anchor="w", padx=14, pady=(12, 8)) - ctk.CTkButton( - danger, text="Delete this user", height=38, corner_radius=10, - fg_color=DANGER, hover_color=DANGER_HOVER, text_color="white", - font=F(13, "bold"), - command=lambda: _delete_user_flow(app, top)).pack( - anchor="w", padx=14, pady=(0, 14)) - - ctk.CTkLabel(foot, text=long_version_string(), font=F(11), + danger = section(wrap, "Danger zone", danger=True) + danger_button( + setting_row(danger, "Delete this user", + f"Permanently deletes the vault for " + f"“{app.current_user}” on this PC.", hint, first=True), + "Delete…", lambda: _delete_user_flow(app, top), width=150).pack() + ctk.CTkFrame(wrap, height=8, fg_color="transparent").pack() + + ctk.CTkLabel(shell.buttons, text=long_version_string(), font=F(11), text_color=MUTED).pack(side="left") - app._ghost_button(foot, "Close", top.destroy, width=120).pack(side="right") + app._ghost_button(shell.buttons, "Close", top.destroy, + width=120).pack(side="right") + top.bind("", lambda e: top.destroy()) def _backup_encrypted(app): @@ -618,44 +563,31 @@ def _export_plaintext(app): def _change_master_dialog(app, parent_top): - top = app._modal("Change master password", 440, 540) - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=30, pady=26) - - ctk.CTkLabel(wrap, text="Change master password", font=F(18, "bold"), - text_color=TEXT).pack(anchor="w") - ctk.CTkLabel(wrap, text="The vault is re-encrypted immediately. The " - "new password cannot be recovered if forgotten.", - font=F(12), text_color=MUTED, wraplength=370, - justify="left").pack(anchor="w", pady=(2, 4)) + top = app._modal("Change master password", 460, 560) + shell = dialog_shell(top, "Change master password", + "The vault is re-encrypted immediately. The new " + "password cannot be recovered if forgotten.") + wrap = shell.body cur_entry = app._field(wrap, "Current master password", show="•") new_entry = app._field(wrap, "New master password", show="•") - confirm_entry = app._field(wrap, "Confirm new password", show="•") - bar, label, update_strength = app._strength_widget(wrap) + confirm_entry = app._field(wrap, "Confirm new password", show="•") new_entry.bind("", lambda e: update_strength(new_entry.get())) syncing = sync_client.is_configured(app.vault) drop = None if syncing: - ctk.CTkLabel(wrap, text="This vault syncs. Your server is updated " - "straight away, and your other PCs ask for the new " - "password on their next sync.", font=F(12), - text_color=MUTED, wraplength=370, - justify="left").pack(anchor="w", pady=(10, 0)) + note(wrap, "This vault syncs. Your server is updated straight away, " + "and your other PCs ask for the new password on their next " + "sync.", shell.text_width, pady=(14, 0)) # Checked by default: a master password is usually changed because # the old one may be exposed, and these copies still open with it. - drop = ctk.CTkCheckBox( - wrap, text="Also delete the older copies on the server — they " - "still open with the old password", - font=F(12), text_color=TEXT, fg_color=GREEN, - hover_color=GREEN_HOVER) - drop.select() - drop.pack(anchor="w", pady=(8, 0)) + drop = check(wrap, "Also delete the older copies on the server — " + "they still open with the old password", + shell.text_width, checked=True) - err = ctk.CTkLabel(wrap, text="", font=F(12), text_color=DANGER) - err.pack(anchor="w", pady=(6, 0)) + err = error_label(wrap, shell.text_width) def change(): if cur_entry.get() != app.master_password: @@ -700,34 +632,22 @@ def change(): "reached — the next Sync will ask for your previous " "password once to update it.") - row = ctk.CTkFrame(wrap, fg_color="transparent") - row.pack(fill="x", side="bottom", pady=(14, 0)) - app._ghost_button(row, "Cancel", top.destroy, width=110).pack(side="left") - app._accent_button(row, "Change password", change, - width=170).pack(side="right") + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=110).pack(side="left") + app._accent_button(shell.buttons, "Change password", change, + width=170).pack(side="right") for e in (cur_entry, new_entry, confirm_entry): e.bind("", lambda ev: change()) cur_entry.focus() def _delete_user_flow(app, parent_top): - top = app._modal("Delete user", 410, 270) - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=28, pady=24) - - ctk.CTkLabel(wrap, text="Delete user", font=F(18, "bold"), - text_color=DANGER).pack(anchor="w") - ctk.CTkLabel(wrap, text="Re-enter your master password to confirm. " - "This deletes the vault file permanently.", font=F(12), - text_color=MUTED, wraplength=350, justify="left" - ).pack(anchor="w", pady=(4, 12)) - - pw = ctk.CTkEntry(wrap, height=42, corner_radius=10, fg_color=INPUT_BG, - border_color=BORDER, show="•", font=F(14), - placeholder_text="Master password") - pw.pack(fill="x") - err = ctk.CTkLabel(wrap, text="", font=F(12), text_color=DANGER) - err.pack(anchor="w", pady=(6, 0)) + top = app._modal("Delete user", 440, 300) + shell = dialog_shell(top, f"Delete “{app.current_user}”", + "Re-enter your master password to confirm. This " + "deletes the vault file permanently.", danger=True) + pw = app._field(shell.body, "Master password", show="•") + err = error_label(shell.body, shell.text_width) def do_delete(): if pw.get() != app.master_password: @@ -741,11 +661,9 @@ def do_delete(): app.show_login_screen() app.toast("User deleted") - row = ctk.CTkFrame(wrap, fg_color="transparent") - row.pack(fill="x", side="bottom", pady=(14, 0)) - app._ghost_button(row, "Cancel", top.destroy, width=110).pack(side="left") - ctk.CTkButton(row, text="Delete", width=120, height=40, corner_radius=10, - fg_color=DANGER, hover_color=DANGER_HOVER, text_color="white", - font=F(13, "bold"), command=do_delete).pack(side="right") + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=110).pack(side="left") + danger_button(shell.buttons, "Delete", do_delete, + width=120).pack(side="right") pw.bind("", lambda e: do_delete()) pw.focus() diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index 37f76ff..60f7f04 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -2,8 +2,9 @@ PassMan - a modern desktop password manager. Only the presentation layer lives here, and only the application shell: the -login screen, the account list and the session lifecycle. The palette lives in -theme.py, reusable widgets in widgets.py and the modal dialogs in dialogs.py. +login screen, the account list with its details pane and the session +lifecycle. The palette lives in theme.py, reusable widgets in widgets.py and +the modal dialogs in dialogs.py. The vault is still encrypted exactly as before with AES-GCM + PBKDF2 (see crypto_utils.py / vault.py); this file never touches the cipher, it just talks @@ -22,24 +23,29 @@ import history_ui import lockout import sync_ui -from theme import (F, HEADER_BG, CARD, CARD_HOVER, INPUT_BG, BORDER, TEXT, - MUTED, GREEN, GREEN_HOVER, DANGER, DANGER_HOVER, +from theme import (F, BG, HEADER_BG, CARD, CARD_HOVER, SELECTED, INPUT_BG, + BORDER, TEXT, MUTED, GREEN, GREEN_HOVER, DANGER, ON_ACCENT, WARN, MONO, - avatar_color, asset_candidates) + tile_colors, asset_candidates) from widgets import (Tooltip, ghost_button, accent_button, field, - strength_widget, modal, fit_modal, safe_grab, confirm) + strength_widget, modal, fit_modal, safe_grab, confirm, + STRENGTH_SEGMENTS) +from password_generator import estimate_strength from vault import (load_vault, save_vault, needs_rekey, active_accounts, archive_account, sort_accounts, SORT_ORDERS, - auto_purge_archive, archive_purge_days, + auto_purge_archive, archive_purge_days, list_users, window_title) from version import version_string CLIPBOARD_CLEAR_MS = 30_000 # auto-clear copied passwords after 30s IDLE_POLL_MS = 15_000 # how often the auto-lock timer checks in +REVEAL_MS = 10_000 # a shown password hides itself again SEARCH_DEBOUNCE_MS = 150 # wait for a pause in typing before filtering ROW_SLICE_SECONDS = 0.1 # list work per step before redrawing the window +LOGIN_TILES_PER_ROW = 3 + #: What the Sort menu shows for each of vault.SORT_ORDERS. SORT_LABELS = { "modified_desc": "Recently modified", @@ -57,15 +63,32 @@ def _row_signature(acc): """ return (id(acc), acc.get("site"), acc.get("username"), acc.get("password"), bool(acc.get("notes")), bool(acc.get("totp")), + acc.get("updated_at"), tuple(r.get("rid") for r in acc.get("history") or [])) +def _clip(text, limit): + """Shorten ``text`` for a fixed-width spot; Tk labels never truncate.""" + text = text or "" + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def _initials(name): + """Two letters for a vault avatar: 'alex.morgan' -> 'AM'.""" + parts = [p for p in name.replace("_", ".").replace("-", ".") + .replace(" ", ".").split(".") if p] + if len(parts) >= 2: + return (parts[0][0] + parts[1][0]).upper() + return (name[:2] or "?").upper() + + class PasswordManagerGUI: def __init__(self, root): self.root = root self.root.title(window_title()) - self.root.geometry("980x660") - self.root.minsize(840, 580) + self.root.geometry("1180x760") + self.root.minsize(980, 640) + self.root.configure(fg_color=BG) self.vault = None self.master_password = None @@ -90,9 +113,12 @@ def __init__(self, root): self._last_activity = time.monotonic() self._autolock_seconds = appconfig.DEFAULT_AUTOLOCK_MINUTES * 60 self._syncing = False - # Entry ids whose history drop-down is open, so it survives the list - # being rebuilt after a revert or a sync. - self._history_open = set() + # The entry shown in the details pane, by id, so it survives the list + # being rebuilt after an edit, a revert or a sync. + self._selected_id = None + self._reveal_job = None + self._login_user = "" + self.search_entry = None self._load_branding() self.root.protocol("WM_DELETE_WINDOW", self._on_close) @@ -100,10 +126,11 @@ def __init__(self, root): # show_main_window() would stack another handler on every unlock. for seq in ("", "", ""): self.root.bind_all(seq, self._mark_activity, add="+") + self.root.bind("", self._focus_search) self.show_login_screen() # ------------------------------------------------------------------ - # Branding (window icon + login logo) - all optional / best effort + # Branding (window icon + logo) - all optional / best effort # ------------------------------------------------------------------ def _load_branding(self): # Linux takes the window icon as an image, not an .ico. With @@ -117,23 +144,59 @@ def _load_branding(self): except Exception: pass - self.logo_image = None + self.logo_image = self.logo_small = None for p in asset_candidates("PassMan-logo.png"): if os.path.exists(p): try: - from PIL import Image - img = Image.open(p) + from PIL import Image, ImageChops + img = Image.open(p).convert("RGBA") + # The PNG has a near-black square behind the shield; let + # the card show through it instead (channel ops, so it + # costs nothing at startup). + r, g, b, a = img.split() + lit = ImageChops.lighter(ImageChops.lighter(r, g), b) + img.putalpha(ImageChops.multiply( + a, lit.point(lambda v: 0 if v < 24 else 255))) self.logo_image = ctk.CTkImage(light_image=img, - dark_image=img, size=(72, 72)) + dark_image=img, size=(60, 60)) + self.logo_small = ctk.CTkImage(light_image=img, + dark_image=img, size=(32, 32)) break except Exception: pass + def _logo(self, parent, small=False): + """The logo image, or a green lettered square if it is missing.""" + image = self.logo_small if small else self.logo_image + if image is not None: + return ctk.CTkLabel(parent, image=image, text="") + size = 32 if small else 60 + return ctk.CTkLabel(parent, text="P", width=size, height=size, + corner_radius=size // 3, fg_color=GREEN, + text_color=ON_ACCENT, + font=F(16 if small else 28, "bold")) + + @staticmethod + def _circle(parent, text, size, bg, ink, font): + """ + A round avatar. A CTkLabel cannot be one: it pads its text by its + corner radius, so a radius of half the size turns it into a pill. + Returns (frame, label) so callers can bind clicks on both. + """ + disc = ctk.CTkFrame(parent, width=size, height=size, + corner_radius=size // 2, fg_color=bg) + disc.pack_propagate(False) + label = ctk.CTkLabel(disc, text=text, fg_color="transparent", + text_color=ink, font=font) + label.place(relx=0.5, rely=0.5, anchor="center") + return disc, label + # ------------------------------------------------------------------ # Small shared helpers # ------------------------------------------------------------------ def _clear(self): self._cancel_row_build() + self._cancel_reveal() if self._search_job: self.root.after_cancel(self._search_job) self._search_job = None @@ -142,9 +205,7 @@ def _clear(self): self._toast = None self._rows = {} self._empty_box = None - - def _avatar_color(self, s): - return avatar_color(s) + self.search_entry = None # ------------------------------------------------------------------ # Thin wrappers over widgets.py, so dialog code reads the same as before @@ -173,7 +234,8 @@ def toast(self, message, kind="info"): self.root.after_cancel(self._toast_job) self._toast = ctk.CTkLabel( self.root, text=f" {message} ", height=38, corner_radius=12, - fg_color=color, text_color=ON_ACCENT if kind == "info" else "white", + fg_color=color, + text_color=ON_ACCENT if kind in ("info", "warn") else "white", font=F(13, "bold"), ) self._toast.place(relx=0.5, rely=1.0, anchor="s", y=-22) @@ -273,38 +335,43 @@ def show_login_screen(self): self._cancel_lockout_timer() self._clear() - outer = ctk.CTkFrame(self.root, fg_color="transparent") - outer.pack(expand=True) + # A drawn backdrop behind the card: shapes only, redrawn to the + # window's size. + backdrop = tk.Canvas(self.root, bg=BG, highlightthickness=0, bd=0) + backdrop.place(relx=0, rely=0, relwidth=1, relheight=1) + backdrop.bind("", + lambda e: self._draw_backdrop(backdrop, e.width, e.height)) - card = ctk.CTkFrame(outer, fg_color=CARD, corner_radius=20) - card.pack(padx=24, pady=24) + card = ctk.CTkFrame(backdrop, fg_color=CARD, corner_radius=24, + bg_color=BG, border_width=1, border_color=BORDER) + card.place(relx=0.5, rely=0.5, anchor="center") pad = ctk.CTkFrame(card, fg_color="transparent") - pad.pack(padx=44, pady=40) - - if self.logo_image is not None: - ctk.CTkLabel(pad, image=self.logo_image, text="").pack(pady=(0, 10)) - ctk.CTkLabel(pad, text="PassMan", font=F(30, "bold"), - text_color=GREEN).pack() - ctk.CTkLabel(pad, text="Unlock your secure vault", font=F(13), - text_color=MUTED).pack(pady=(2, 0)) - ctk.CTkLabel(pad, text=version_string(), font=F(11), - text_color=MUTED).pack(pady=(2, 20)) + pad.pack(padx=40, pady=36) - self.username_entry = ctk.CTkEntry( - pad, width=300, height=46, corner_radius=10, fg_color=INPUT_BG, - border_color=BORDER, font=F(14), placeholder_text="Username") - self.username_entry.pack(pady=7) + self._logo(pad).pack() + ctk.CTkLabel(pad, text="Welcome back", font=F(26, "bold"), + text_color=TEXT).pack(pady=(12, 0)) + ctk.CTkLabel(pad, text="Pick your vault and enter its master password.", + font=F(13), text_color=MUTED).pack(pady=(4, 20)) - pw_row = ctk.CTkFrame(pad, fg_color="transparent", width=300, height=46) - pw_row.pack(pady=7) - pw_row.grid_propagate(False) + users = list_users() + last = appconfig.load_config().get("last_username", "") + self._login_user = last if last in users else (users[0] if users else "") + self._login_tiles = ctk.CTkFrame(pad, fg_color="transparent") + self._login_tiles.pack(fill="x") + self._draw_login_tiles(users) + + ctk.CTkLabel(pad, text="Master password", font=F(12, "bold"), + text_color=MUTED, anchor="w").pack(fill="x", pady=(20, 6)) + pw_row = ctk.CTkFrame(pad, fg_color="transparent") + pw_row.pack(fill="x") pw_row.grid_columnconfigure(0, weight=1) self.password_entry = ctk.CTkEntry( - pw_row, height=46, corner_radius=10, fg_color=INPUT_BG, show="•", - border_color=BORDER, font=F(14), placeholder_text="Master password") + pw_row, height=50, corner_radius=14, fg_color=INPUT_BG, show="•", + border_color=BORDER, font=F(15), placeholder_text="Master password") self.password_entry.grid(row=0, column=0, sticky="ew") reveal_btn = ctk.CTkButton( - pw_row, text="Show", width=58, height=46, corner_radius=10, + pw_row, text="Show", width=64, height=50, corner_radius=14, fg_color="transparent", border_width=1, border_color=BORDER, hover_color=CARD_HOVER, text_color=MUTED, font=F(12, "bold")) reveal_btn.grid(row=0, column=1, padx=(8, 0)) @@ -319,37 +386,124 @@ def toggle_pw(): reveal_btn.configure(command=toggle_pw) self.login_error = ctk.CTkLabel(pad, text="", font=F(12), - text_color=DANGER) + text_color=DANGER, wraplength=360) self.login_error.pack(pady=(8, 2)) - unlock = self._accent_button(pad, "Unlock Vault", self.unlock_vault, width=300) - unlock.configure(height=46) - unlock.pack(pady=(4, 14)) - self.unlock_button = unlock + self.unlock_button = self._accent_button( + pad, self._unlock_text(), self.unlock_vault, width=360) + self.unlock_button.configure(height=52, corner_radius=14, + font=F(15, "bold")) + self.unlock_button.pack(fill="x", pady=(4, 16)) - ctk.CTkLabel(pad, text="New here?", font=F(12), text_color=MUTED).pack() - self._ghost_button(pad, "Create a new user", - self.create_user_dialog, width=300).pack(pady=(6, 0)) + links = ctk.CTkFrame(pad, fg_color="transparent") + links.pack(fill="x") # The second PC's starting point: there is no vault here yet, so there # is nothing to unlock and nothing to merge -- just a download. - self._ghost_button(pad, "Restore from server", - self.restore_from_server_dialog, - width=300).pack(pady=(8, 0)) + for text, command, side in ( + ("Create a new user", self.create_user_dialog, "left"), + ("Restore from server", self.restore_from_server_dialog, + "right")): + ctk.CTkButton(links, text=text, command=command, width=10, + height=32, fg_color="transparent", + hover_color=CARD_HOVER, text_color=GREEN, + font=F(13, "bold")).pack(side=side) + ctk.CTkLabel(pad, text=version_string(), font=F(11), + text_color=MUTED).pack(pady=(14, 0)) - self.username_entry.bind("", lambda e: self.password_entry.focus()) self.password_entry.bind("", lambda e: self.unlock_vault()) - - # Prefill the last-used username (not a secret) and go straight to - # the password field, so a returning user only types one thing. - last = appconfig.load_config().get("last_username", "") - if last: - self.username_entry.insert(0, last) - self.password_entry.focus() - # A penalty earned before the app was closed is still in force. - if lockout.remaining(last): - self._begin_lockout(last, lockout.remaining(last)) + self.password_entry.focus() + # A penalty earned before the app was closed is still in force. + if self._login_user and lockout.remaining(self._login_user): + self._begin_lockout(self._login_user, + lockout.remaining(self._login_user)) + + def _draw_backdrop(self, canvas, w, h): + canvas.delete("all") + r1, r2 = max(w, h) * 0.17, max(w, h) * 0.21 + canvas.create_oval(w * 0.1 - r1, h * 0.16 - r1, w * 0.1 + r1, + h * 0.16 + r1, fill="#0F2117", outline="") + canvas.create_oval(w * 0.93 - r2, h * 0.9 - r2, w * 0.93 + r2, + h * 0.9 + r2, fill="#101A14", outline="") + # A lock, top right. + x, y = w * 0.86, h * 0.2 + canvas.create_rectangle(x - 26, y - 6, x + 26, y + 34, outline=GREEN, + width=4) + canvas.create_arc(x - 17, y - 32, x + 17, y + 2, start=0, extent=180, + style="arc", outline=GREEN, width=4) + # A key, bottom left. + x, y = w * 0.15, h * 0.8 + canvas.create_oval(x - 40, y - 18, x - 4, y + 18, outline="#7BE0A6", + width=4) + canvas.create_line(x - 4, y, x + 44, y, fill="#7BE0A6", width=4) + canvas.create_line(x + 30, y, x + 30, y + 16, fill="#7BE0A6", width=4) + canvas.create_line(x + 42, y, x + 42, y + 12, fill="#7BE0A6", width=4) + for fx, fy in ((0.3, 0.1), (0.7, 0.92), (0.96, 0.48), (0.05, 0.55)): + canvas.create_oval(w * fx - 4, h * fy - 4, w * fx + 4, h * fy + 4, + fill="#1F3A2A", outline="") + + def _draw_login_tiles(self, users): + """One tile per vault on this PC, plus one to create another.""" + for w in self._login_tiles.winfo_children(): + w.destroy() + for c in range(LOGIN_TILES_PER_ROW): + self._login_tiles.grid_columnconfigure(c, weight=1, uniform="tile") + if not users: + ctk.CTkLabel(self._login_tiles, + text="There is no vault on this PC yet. Create one, " + "or restore one from your server.", + font=F(13), text_color=MUTED, wraplength=360).grid( + row=0, column=0, columnspan=LOGIN_TILES_PER_ROW) + return + for i, name in enumerate(users + [None]): + tile = self._login_tile(name) + tile.grid(row=i // LOGIN_TILES_PER_ROW, + column=i % LOGIN_TILES_PER_ROW, padx=6, pady=6, + sticky="ew") + + def _login_tile(self, name): + picked = name is not None and name == self._login_user + tile = ctk.CTkFrame(self._login_tiles, width=112, height=108, + corner_radius=18, border_width=2, + border_color=GREEN if picked else BORDER, + fg_color=SELECTED if picked else CARD) + tile.pack_propagate(False) + if name is None: + avatar, letters = self._circle(tile, "+", 50, INPUT_BG, MUTED, + F(22, "bold")) + label = "Add vault" + command = self.create_user_dialog else: - self.username_entry.focus() + bg, ink, _ = tile_colors(name) + avatar, letters = self._circle(tile, _initials(name), 50, bg, ink, + F(16, "bold")) + label = _clip(name, 14) + command = lambda: self._pick_login_user(name) + avatar.pack(pady=(14, 6)) + caption = ctk.CTkLabel(tile, text=label, font=F(12, "bold"), + text_color=TEXT) + caption.pack() + for w in (tile, avatar, letters, caption): + w.bind("", lambda e: command()) + if name is not None and len(name) > 14: + Tooltip(tile, name) + return tile + + def _pick_login_user(self, name): + self._login_user = name + self._draw_login_tiles(list_users()) + self._cancel_lockout_timer() + wait = lockout.remaining(name) + if wait: + self._begin_lockout(name, wait) + else: + self.unlock_button.configure(state="normal", + text=self._unlock_text()) + self._login_error("") + self.password_entry.focus() + + def _unlock_text(self): + return (f"Unlock {_clip(self._login_user, 24)}" if self._login_user + else "Unlock") def _begin_lockout(self, username, seconds): """Disable unlocking and tick down the remaining wait.""" @@ -361,7 +515,7 @@ def tick(): self._lockout_job = None try: self.unlock_button.configure(state="normal", - text="Unlock Vault") + text=self._unlock_text()) self._login_error("") except Exception: pass @@ -390,10 +544,14 @@ def _login_error(self, msg): self.login_error.configure(text=msg) def unlock_vault(self): - username = self.username_entry.get().strip() + username = self._login_user master_password = self.password_entry.get() - if not username or not master_password: - self._login_error("Enter your username and master password.") + if not username: + self._login_error("Create a vault or restore one from your " + "server first.") + return + if not master_password: + self._login_error("Enter the master password.") return wait = lockout.remaining(username) @@ -419,7 +577,7 @@ def unlock_vault(self): self._begin_lockout(username, wait) else: left = lockout.FREE_ATTEMPTS - lockout.failures(username) - self._login_error("Incorrect username or master password." + + self._login_error("Incorrect master password." + (f" {left} attempt(s) before a delay." if left else "")) return @@ -457,90 +615,122 @@ def show_main_window(self): self.root.grid_columnconfigure(0, weight=1) self.root.grid_rowconfigure(1, weight=1) - # ---- header ---- - header = ctk.CTkFrame(self.root, fg_color=HEADER_BG, corner_radius=0, - height=76) - header.grid(row=0, column=0, sticky="ew") - header.grid_propagate(False) - header.grid_columnconfigure(1, weight=1) - - brand = ctk.CTkFrame(header, fg_color="transparent") - brand.grid(row=0, column=0, padx=22, pady=14, sticky="w") - ctk.CTkLabel(brand, text="PassMan", font=F(20, "bold"), - text_color=GREEN).pack(anchor="w") - ctk.CTkLabel(brand, text=f"Vault · {self.current_user}", font=F(12), - text_color=MUTED).pack(anchor="w") - - controls = ctk.CTkFrame(header, fg_color="transparent") - controls.grid(row=0, column=2, padx=22, sticky="e") + # ---- top bar ---- + bar = ctk.CTkFrame(self.root, fg_color=HEADER_BG, corner_radius=0, + height=72) + bar.grid(row=0, column=0, sticky="ew") + bar.grid_propagate(False) + bar.grid_rowconfigure(0, weight=1) + bar.grid_columnconfigure(2, weight=1) + + brand = ctk.CTkFrame(bar, fg_color="transparent") + brand.grid(row=0, column=0, padx=(24, 14)) + self._logo(brand, small=True).pack(side="left") + ctk.CTkLabel(brand, text="PassMan", font=F(17, "bold"), + text_color=TEXT).pack(side="left", padx=(10, 0)) + + bg, ink, _ = tile_colors(self.current_user) + chip = ctk.CTkFrame(bar, fg_color=INPUT_BG, corner_radius=20, + border_width=1, border_color=BORDER, height=40) + chip.grid(row=0, column=1) + self._circle(chip, _initials(self.current_user), 30, bg, ink, + F(11, "bold"))[0].pack(side="left", padx=(5, 8), pady=5) + ctk.CTkLabel(chip, text=_clip(self.current_user, 24), + font=F(13, "bold"), text_color=TEXT).pack( + side="left", padx=(0, 14)) + Tooltip(chip, "The vault you unlocked. Lock to switch to another.") self.search_entry = ctk.CTkEntry( - controls, width=190, height=40, corner_radius=10, fg_color=INPUT_BG, - border_color=BORDER, font=F(13), placeholder_text="Search accounts...") - self.search_entry.grid(row=0, column=0, padx=(0, 10)) + bar, height=44, corner_radius=22, fg_color=INPUT_BG, + border_color=INPUT_BG, font=F(14), + placeholder_text="Search sites and usernames (Ctrl+F)") + self.search_entry.grid(row=0, column=2, sticky="ew", padx=24) self.search_entry.bind("", self._on_search) - sync_btn = self._ghost_button(controls, "Sync", self.sync_now, width=80) - sync_btn.grid(row=0, column=1, padx=(0, 10)) + controls = ctk.CTkFrame(bar, fg_color="transparent") + controls.grid(row=0, column=3, padx=(0, 24)) + sync_btn = self._ghost_button(controls, "Sync", self.sync_now, width=74) + sync_btn.pack(side="left", padx=(0, 8)) Tooltip(sync_btn, "Merge this vault with the one on your server") - - imp = self._ghost_button(controls, "Import", self.import_dialog, width=90) - imp.grid(row=0, column=2, padx=(0, 10)) + imp = self._ghost_button(controls, "Import", self.import_dialog, width=80) + imp.pack(side="left", padx=(0, 8)) Tooltip(imp, "Import accounts from a CSV or JSON file") - self._ghost_button(controls, "Generate", self.generator_dialog, - width=100).grid(row=0, column=3, padx=(0, 10)) - self._accent_button(controls, "+ Add account", self.add_account_dialog, - width=145).grid(row=0, column=4, padx=(0, 10)) + gen = self._ghost_button(controls, "Generate", self.generator_dialog, + width=92) + gen.pack(side="left", padx=(0, 8)) + Tooltip(gen, "Make a strong password") gear = self._ghost_button(controls, "⚙", self.settings_dialog, width=44) - gear.grid(row=0, column=5, padx=(0, 10)) + gear.pack(side="left", padx=(0, 8)) Tooltip(gear, "Settings") - lock = self._ghost_button(controls, "Lock", self.lock_vault, width=80) - lock.grid(row=0, column=6) + lock = self._ghost_button(controls, "Lock", self.lock_vault, width=70) + lock.pack(side="left") Tooltip(lock, "Lock the vault and sign out") - # ---- account list ---- - self.list_frame = ctk.CTkScrollableFrame(self.root, fg_color="transparent") - self.list_frame.grid(row=1, column=0, sticky="nsew", padx=18, pady=(12, 6)) - self.list_frame.grid_columnconfigure(0, weight=1) - - # ---- footer ---- - footer = ctk.CTkFrame(self.root, fg_color=HEADER_BG, corner_radius=0, - height=46) - footer.grid(row=2, column=0, sticky="ew") - footer.grid_propagate(False) - self.count_label = ctk.CTkLabel(footer, text="", font=F(11), - text_color=MUTED) - self.count_label.pack(side="left", padx=(20, 12)) - - # The archive lives down here rather than in the crowded header: it is - # somewhere you go occasionally, and it belongs next to the count that - # tells you there is something in it. + # ---- body: the list on the left, the selected entry on the right ---- + body = ctk.CTkFrame(self.root, fg_color="transparent") + body.grid(row=1, column=0, sticky="nsew", padx=24, pady=20) + body.grid_rowconfigure(0, weight=1) + body.grid_columnconfigure(0, weight=2, minsize=340) + body.grid_columnconfigure(1, weight=3, minsize=480) + + left = ctk.CTkFrame(body, fg_color="transparent") + left.grid(row=0, column=0, sticky="nsew", padx=(0, 20)) + left.grid_rowconfigure(1, weight=1) + left.grid_columnconfigure(0, weight=1) + + chips = ctk.CTkFrame(left, fg_color="transparent") + chips.grid(row=0, column=0, sticky="ew", pady=(0, 12)) + self.all_chip = ctk.CTkLabel(chips, text="All", height=34, + corner_radius=17, fg_color=GREEN, + text_color=ON_ACCENT, font=F(12, "bold")) + self.all_chip.pack(side="left") + # The archive sits beside the count: somewhere you go occasionally, + # next to the number that tells you there is something in it. self.archive_btn = ctk.CTkButton( - footer, text="Archive", command=self.archive_dialog, width=110, - height=28, corner_radius=8, fg_color="transparent", border_width=1, - border_color=BORDER, hover_color=CARD_HOVER, text_color=MUTED, - font=F(11, "bold")) - self.archive_btn.pack(side="left", pady=8) + chips, text="Archive", command=self.archive_dialog, width=90, + height=34, corner_radius=17, fg_color="transparent", + border_width=1, border_color=BORDER, hover_color=CARD_HOVER, + text_color=TEXT, font=F(12, "bold")) + self.archive_btn.pack(side="left", padx=(8, 0)) Tooltip(self.archive_btn, "Entries you removed, kept so they can be " "restored") - - ctk.CTkLabel(footer, text=version_string(), font=F(11), - text_color=MUTED).pack(side="right", padx=20) - sort_menu = ctk.CTkOptionMenu( - footer, width=160, height=28, corner_radius=8, + chips, width=160, height=34, corner_radius=10, values=[SORT_LABELS[o] for o in SORT_ORDERS], fg_color=INPUT_BG, button_color=BORDER, - button_hover_color=CARD_HOVER, text_color=TEXT, font=F(11), - command=self._on_sort) + button_hover_color=CARD_HOVER, text_color=TEXT, font=F(12), + dropdown_fg_color=CARD, dropdown_hover_color=CARD_HOVER, + dropdown_text_color=TEXT, command=self._on_sort) sort_menu.set(SORT_LABELS[self.sort_order]) - sort_menu.pack(side="right", pady=8) - ctk.CTkLabel(footer, text="Sort", font=F(11), - text_color=MUTED).pack(side="right", padx=(0, 8)) + sort_menu.pack(side="right") + + list_card = ctk.CTkFrame(left, fg_color=CARD, corner_radius=20, + border_width=1, border_color=BORDER) + list_card.grid(row=1, column=0, sticky="nsew") + self.list_frame = ctk.CTkScrollableFrame(list_card, fg_color=CARD, + corner_radius=0) + self.list_frame.pack(fill="both", expand=True, padx=6, pady=8) + self.list_frame.grid_columnconfigure(0, weight=1) + + add = self._accent_button(left, "+ Add an entry", + self.add_account_dialog) + add.configure(height=48, corner_radius=16, font=F(14, "bold")) + add.grid(row=2, column=0, sticky="ew", pady=(12, 0)) + ctk.CTkLabel(left, text=version_string(), font=F(11), + text_color=MUTED).grid(row=3, column=0, pady=(8, 0)) + + self.detail = ctk.CTkFrame(body, fg_color=CARD, corner_radius=24, + border_width=1, border_color=BORDER) + self.detail.grid(row=0, column=1, sticky="nsew") self.refresh_account_list() self._start_idle_timer() + def _focus_search(self, _=None): + if self.search_entry is not None and self.search_entry.winfo_exists(): + self.search_entry.focus() + return "break" + def _on_search(self, _=None): # Every keystroke lands here, so filter once typing pauses rather # than re-gridding the whole list per letter. @@ -580,12 +770,14 @@ def refresh_account_list(self): if q in a["site"].lower() or q in a["username"].lower()] accounts = sort_accounts(accounts, self.sort_order) - self.count_label.configure( - text=f"{total} account{'s' if total != 1 else ''} stored" - + (f" · {archived} archived" if archived else "")) - if hasattr(self, "archive_btn"): - self.archive_btn.configure(text=f"Archive ({archived})" if archived - else "Archive") + self.all_chip.configure(text=f" All {total} ") + self.archive_btn.configure(text=f"Archive {archived}" if archived + else "Archive") + + # Keep the selected entry while it is still listed; otherwise show + # the first one, so the details pane is never stale. + if self._selected_id not in {a["id"] for a in accounts}: + self._selected_id = accounts[0]["id"] if accounts else None # Drop the rows of entries that are gone or have changed. Hide every # row that is filtered out or moving: hiding is cheap, and a moving @@ -598,10 +790,14 @@ def refresh_account_list(self): if acc is None or _row_signature(acc) != sig: frame.destroy() del self._rows[eid] - elif pos is not None and position.get(eid) != pos: - frame.grid_remove() - cached[2] = None + else: + frame.configure(fg_color=SELECTED if eid == self._selected_id + else CARD) + if pos is not None and position.get(eid) != pos: + frame.grid_remove() + cached[2] = None + self._show_detail() if not accounts: self._empty_state(filtered=bool(q)) return @@ -623,7 +819,7 @@ def _place_rows(self, pending): i, acc = pending.pop(0) cached = self._rows.get(acc["id"]) if cached: - cached[1].grid(row=i, column=0, sticky="ew", pady=6, padx=2) + cached[1].grid(row=i, column=0, sticky="ew", pady=2, padx=2) cached[2] = i else: self._rows[acc["id"]] = [_row_signature(acc), @@ -650,104 +846,227 @@ def _empty_state(self, filtered): text_color=TEXT).pack() ctk.CTkLabel(box, text="Add your first login to get started.", font=F(13), text_color=MUTED).pack(pady=(4, 16)) - self._accent_button(box, "+ Add account", - self.add_account_dialog, width=170).pack() self._ghost_button(box, "Import from file", - self.import_dialog, width=170).pack(pady=(10, 0)) + self.import_dialog, width=170).pack() def _add_row(self, i, acc): - row = ctk.CTkFrame(self.list_frame, fg_color=CARD, corner_radius=12) - row.grid(row=i, column=0, sticky="ew", pady=6, padx=2) - row.grid_columnconfigure(2, weight=1) - - ctk.CTkLabel(row, text=(acc["site"][:1].upper() or "?"), width=46, - height=46, corner_radius=23, text_color="#FFFFFF", - fg_color=self._avatar_color(acc["site"]), - font=F(18, "bold")).grid(row=0, column=0, padx=(14, 14), - pady=12) + """A compact list row; clicking it shows the entry in the details.""" + row = ctk.CTkFrame( + self.list_frame, corner_radius=14, + fg_color=SELECTED if acc["id"] == self._selected_id else CARD) + row.grid(row=i, column=0, sticky="ew", pady=2, padx=2) + row.grid_columnconfigure(1, weight=1) + + bg, ink, _ = tile_colors(acc["site"]) + tile = ctk.CTkLabel(row, text=(acc["site"][:1].upper() or "?"), + width=42, height=42, corner_radius=12, + fg_color=bg, text_color=ink, font=F(16, "bold")) + tile.grid(row=0, column=0, padx=(10, 12), pady=9) info = ctk.CTkFrame(row, fg_color="transparent") - info.grid(row=0, column=1, sticky="w", pady=12) - ctk.CTkLabel(info, text=acc["site"], anchor="w", font=F(15, "bold"), - text_color=TEXT).pack(anchor="w") + info.grid(row=0, column=1, sticky="w") + site = ctk.CTkLabel(info, text=_clip(acc["site"], 30), anchor="w", + font=F(14, "bold"), text_color=TEXT) + site.pack(anchor="w") subtitle = acc["username"] or "—" extras = [name for name, key in (("2FA", "totp"), ("note", "notes")) if acc.get(key)] + subtitle = _clip(subtitle, 30 if not extras else 22) if extras: - subtitle += " · " + " · ".join(extras) - ctk.CTkLabel(info, text=subtitle, anchor="w", - font=F(12), text_color=MUTED).pack(anchor="w") + subtitle += " · " + " · ".join(extras) + user = ctk.CTkLabel(info, text=subtitle, anchor="w", font=F(12), + text_color=MUTED) + user.pack(anchor="w") - pwd_lbl = ctk.CTkLabel(row, text="•" * 8, width=150, anchor="e", - font=ctk.CTkFont(family=MONO, size=15), - text_color=MUTED) - pwd_lbl.grid(row=0, column=3, padx=(8, 14)) + when = ctk.CTkLabel(row, text=history_ui.short_when( + acc.get("updated_at", "")), font=F(11), text_color=MUTED) + when.grid(row=0, column=2, padx=(8, 14)) - btns = ctk.CTkFrame(row, fg_color="transparent") - btns.grid(row=0, column=4, padx=(0, 12)) + for w in (row, tile, info, site, user, when): + w.bind("", lambda e, eid=acc["id"]: self._select(eid)) + return row - state = {"shown": False, "job": None} + def _select(self, eid): + previous, self._selected_id = self._selected_id, eid + for key in {previous, eid}: + cached = self._rows.get(key) + if cached: + cached[1].configure(fg_color=SELECTED if key == eid else CARD) + self._show_detail() - def toggle(): - if state["shown"]: - pwd_lbl.configure(text="•" * 8, text_color=MUTED) - show_btn.configure(text="Show") - state["shown"] = False - if state["job"]: - self.root.after_cancel(state["job"]); state["job"] = None - else: - pwd_lbl.configure(text=acc["password"], text_color=TEXT) - show_btn.configure(text="Hide") - state["shown"] = True - state["job"] = self.root.after(10000, toggle) + # ------------------------------------------------------------------ + # Details pane + # ------------------------------------------------------------------ + def _selected_entry(self): + if self._selected_id is None or self.vault is None: + return None + for acc in active_accounts(self.vault): + if acc["id"] == self._selected_id: + return acc + return None + + def _cancel_reveal(self): + if self._reveal_job: + try: + self.root.after_cancel(self._reveal_job) + except Exception: + pass + self._reveal_job = None - show_btn = ctk.CTkButton( - btns, text="Show", width=58, height=34, corner_radius=8, - fg_color="transparent", border_width=1, border_color=BORDER, - hover_color=CARD_HOVER, text_color=TEXT, font=F(12, "bold"), - command=toggle) - show_btn.grid(row=0, column=0, padx=4) - Tooltip(show_btn, "Show / hide password") - - copy_pw = ctk.CTkButton( - btns, text="Copy", width=58, height=34, corner_radius=8, - fg_color=GREEN, hover_color=GREEN_HOVER, text_color=ON_ACCENT, - font=F(12, "bold"), - command=lambda: self._copy(acc["password"], "Password copied", - sensitive=True)) - copy_pw.grid(row=0, column=1, padx=4) - Tooltip(copy_pw, "Copy password — cleared after " - f"{CLIPBOARD_CLEAR_MS // 1000}s, but a clipboard " - "manager may keep its own copy") + def _show_detail(self): + self._cancel_reveal() + for w in self.detail.winfo_children(): + w.destroy() + acc = self._selected_entry() + if acc is None: + box = ctk.CTkFrame(self.detail, fg_color="transparent") + box.place(relx=0.5, rely=0.5, anchor="center") + ctk.CTkLabel(box, text="Nothing selected", font=F(17, "bold"), + text_color=TEXT).pack() + ctk.CTkLabel(box, text="Pick an entry on the left, or add one.", + font=F(13), text_color=MUTED).pack(pady=(4, 0)) + return - copy_user = ctk.CTkButton( - btns, text="User", width=58, height=34, corner_radius=8, - fg_color="transparent", border_width=1, border_color=BORDER, - hover_color=CARD_HOVER, text_color=TEXT, font=F(12, "bold"), - command=lambda: self._copy(acc["username"], "Username copied")) - copy_user.grid(row=0, column=2, padx=4) + bg, ink, band_color = tile_colors(acc["site"]) + band = ctk.CTkFrame(self.detail, fg_color=band_color, corner_radius=18, + height=112) + band.pack(fill="x", padx=8, pady=(8, 0)) + band.pack_propagate(False) + band.grid_columnconfigure(1, weight=1) + band.grid_rowconfigure(0, weight=1) + ctk.CTkLabel(band, text=(acc["site"][:1].upper() or "?"), width=72, + height=72, corner_radius=20, fg_color=bg, text_color=ink, + bg_color=band_color, font=F(30, "bold")).grid( + row=0, column=0, padx=(22, 18)) + names = ctk.CTkFrame(band, fg_color="transparent") + names.grid(row=0, column=1, sticky="w") + ctk.CTkLabel(names, text=_clip(acc["site"], 34), font=F(24, "bold"), + text_color=TEXT, anchor="w").pack(anchor="w") + changed = acc.get("updated_at") + ctk.CTkLabel(names, text=("Last changed " + history_ui.local_time( + changed)) if changed else "", font=F(12), text_color=MUTED, + anchor="w").pack(anchor="w") + actions = ctk.CTkFrame(band, fg_color="transparent") + actions.grid(row=0, column=2, padx=(8, 22)) + self._ghost_button(actions, "Edit", lambda: self.account_dialog(acc), + width=74).pack(side="left", padx=(0, 8)) + arch = self._ghost_button(actions, "Archive", + lambda: self._archive_account(acc), width=88) + arch.pack(side="left") + Tooltip(arch, "Move to archive — restorable, and it still syncs the " + "removal to your other PCs") + + scroll = ctk.CTkScrollableFrame(self.detail, fg_color=CARD, + corner_radius=0) + scroll.pack(fill="both", expand=True, padx=14, pady=(14, 14)) + + fields = ctk.CTkFrame(scroll, fg_color="transparent") + fields.pack(fill="x", padx=8) + fields.grid_columnconfigure((0, 1), weight=1, uniform="field") + self._username_box(fields, acc).grid(row=0, column=0, sticky="nsew", + padx=(0, 7)) + self._password_box(fields, acc).grid(row=0, column=1, sticky="nsew", + padx=(7, 0)) + + self._strength_row(scroll, acc.get("password", "")) + + if acc.get("totp"): + ctk.CTkLabel(scroll, text=" 2FA seed stored with this entry ", + height=28, corner_radius=14, fg_color=INPUT_BG, + text_color=MUTED, font=F(12, "bold")).pack( + anchor="w", padx=8, pady=(14, 0)) + + self._section_title(scroll, "Notes") + ctk.CTkLabel(scroll, text=acc.get("notes") or "No notes.", + font=F(13), text_color=TEXT if acc.get("notes") else MUTED, + wraplength=520, justify="left", anchor="w").pack( + anchor="w", fill="x", padx=8) + + count = len(acc.get("history") or []) + self._section_title( + scroll, f"History · {count} earlier version" + f"{'s' if count != 1 else ''}") + history_ui.panel(self, scroll, acc).pack(fill="x", padx=8, + pady=(0, 8)) + + def _section_title(self, parent, text): + ctk.CTkLabel(parent, text=text, font=F(12, "bold"), text_color=MUTED, + anchor="w").pack(anchor="w", padx=8, pady=(20, 6)) + + def _field_box(self, parent, label): + box = ctk.CTkFrame(parent, fg_color=INPUT_BG, corner_radius=16) + box.grid_columnconfigure(0, weight=1) + ctk.CTkLabel(box, text=label, font=F(12, "bold"), text_color=MUTED, + anchor="w").grid(row=0, column=0, sticky="w", + padx=16, pady=(12, 0)) + return box + + def _small_button(self, parent, text, command, accent=False, width=60): + return ctk.CTkButton( + parent, text=text, command=command, width=width, height=36, + corner_radius=10, + fg_color=GREEN if accent else "transparent", + hover_color=GREEN_HOVER if accent else CARD_HOVER, + border_width=0 if accent else 1, border_color=BORDER, + text_color=ON_ACCENT if accent else TEXT, font=F(12, "bold")) + + def _username_box(self, parent, acc): + box = self._field_box(parent, "Username") + ctk.CTkLabel(box, text=_clip(acc["username"] or "—", 26), + font=F(15, "bold"), text_color=TEXT, anchor="w").grid( + row=1, column=0, sticky="w", padx=16, pady=(2, 12)) + copy_user = self._small_button( + box, "Copy", lambda: self._copy(acc["username"], "Username copied")) + copy_user.grid(row=0, column=1, rowspan=2, padx=(4, 12)) Tooltip(copy_user, "Copy username") + return box - edit_btn = ctk.CTkButton( - btns, text="✎", width=40, height=34, corner_radius=8, - fg_color="transparent", border_width=1, border_color=BORDER, - hover_color=CARD_HOVER, text_color=TEXT, font=F(13, "bold"), - command=lambda: self.account_dialog(acc)) - edit_btn.grid(row=0, column=4, padx=4) - Tooltip(edit_btn, "Edit account") - - # History button at column 3, and the drop-down beneath this row. - history_ui.attach(self, row, acc, btns, column=3) + def _password_box(self, parent, acc): + box = self._field_box(parent, "Password") + pwd = ctk.CTkLabel(box, text="•" * 12, anchor="w", + font=ctk.CTkFont(family=MONO, size=15), + text_color=TEXT) + pwd.grid(row=1, column=0, sticky="w", padx=16, pady=(2, 12)) + buttons = ctk.CTkFrame(box, fg_color="transparent") + buttons.grid(row=0, column=1, rowspan=2, padx=(4, 12)) - del_btn = ctk.CTkButton( - btns, text="✕", width=40, height=34, corner_radius=8, - fg_color="transparent", border_width=1, border_color=BORDER, - hover_color=DANGER_HOVER, text_color=DANGER, font=F(13, "bold"), - command=lambda: self._archive_account(acc)) - del_btn.grid(row=0, column=5, padx=4) - Tooltip(del_btn, "Move to archive — restorable, and it still syncs " - "the removal to your other PCs") - return row + def toggle(): + self._cancel_reveal() + if show.cget("text") == "Hide": + pwd.configure(text="•" * 12) + show.configure(text="Show") + else: + pwd.configure(text=_clip(acc["password"], 24) or "—") + show.configure(text="Hide") + self._reveal_job = self.root.after(REVEAL_MS, toggle) + + show = self._small_button(buttons, "Show", toggle) + show.pack(side="left", padx=(0, 6)) + Tooltip(show, "Show / hide password") + copy_pw = self._small_button( + buttons, "Copy", + lambda: self._copy(acc["password"], "Password copied", + sensitive=True), accent=True) + copy_pw.pack(side="left") + Tooltip(copy_pw, "Copy password — cleared after " + f"{CLIPBOARD_CLEAR_MS // 1000}s, but a clipboard " + "manager may keep its own copy") + return box + + def _strength_row(self, parent, password): + name, frac, color = estimate_strength(password) + row = ctk.CTkFrame(parent, fg_color="transparent") + row.pack(fill="x", padx=8, pady=(16, 0)) + ctk.CTkLabel(row, text="Strength", font=F(12, "bold"), + text_color=MUTED).pack(side="left", padx=(0, 14)) + lit = round(frac * STRENGTH_SEGMENTS) if password else 0 + lit = max(lit, 1) if password else 0 + for n in range(STRENGTH_SEGMENTS): + ctk.CTkFrame(row, width=38, height=6, corner_radius=3, + fg_color=color if n < lit else BORDER).pack( + side="left", padx=(0, 4)) + ctk.CTkLabel(row, text=name, font=F(12, "bold"), + text_color=color).pack(side="left", padx=(10, 0)) def _archive_account(self, acc): """ @@ -768,32 +1087,6 @@ def _archive_account(self, acc): self.refresh_account_list() self.toast("Moved to archive") - # ================================================================== - # Import from file - # ================================================================== - - - - # ================================================================== - # Add / edit account (same dialog; acc=None means "add new") - # ================================================================== - - - # ================================================================== - # Standalone password generator - # ================================================================== - - # ================================================================== - # Settings (danger zone) - # ================================================================== - - # ------------------------------------------------------------------ - # Backup / export - # ------------------------------------------------------------------ - - - - # ================================================================== # Reusable widgets # ================================================================== @@ -849,7 +1142,7 @@ def lock_vault(self, auto=False): # A copied password outlives the lock: the usual auto-clear timer # keeps running, so it can still be pasted until that fires. self.vault = self.master_password = self.current_user = None - self._history_open.clear() + self._selected_id = None self.search_query = "" self.show_login_screen() if auto: diff --git a/sourceFiles/history_ui.py b/sourceFiles/history_ui.py index d8eb2f2..faafe70 100644 --- a/sourceFiles/history_ui.py +++ b/sourceFiles/history_ui.py @@ -1,5 +1,5 @@ """ -The revision history drop-down under each account row. +The revision history section of the entry details. Presentation only: recording, reverting and clearing revisions all live in vault.py (update_entry, revert_entry, clear_history), and merging them across @@ -34,6 +34,28 @@ def local_time(stamp: str) -> str: return "unknown time" +def short_when(stamp: str, now=None) -> str: + """ + A stored UTC timestamp, short enough for a list row: the time for today, + the day and month for this year, else the month and year. + """ + for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"): + try: + moment = datetime.strptime(stamp, fmt).replace(tzinfo=timezone.utc) + break + except (TypeError, ValueError): + continue + else: + return "" + moment = moment.astimezone() + now = (now or datetime.now(timezone.utc)).astimezone() + if moment.date() == now.date(): + return moment.strftime("%H:%M") + if moment.year == now.year: + return f"{moment.day} {moment.strftime('%b')}" + return moment.strftime("%b %Y") + + def describe(rev: dict) -> str: """What the edit that replaced this version changed, in a few words.""" changed = rev.get("changed") or [] @@ -64,47 +86,12 @@ def _small_button(parent, text, command, accent=False, danger=False, width=62): font=F(11, "bold")) -def attach(app, row, acc, buttons, column): +def panel(app, parent, acc): """ - Give an account row its History button (in ``buttons`` at ``column``) - and the drop-down beneath the row. - - Which rows are open is remembered on the app, so the panel stays open - across the list refresh that a revert or a sync triggers. + Build the history of ``acc`` into ``parent`` and return the frame, for + the caller to place. Revert and Clear refresh the app, which rebuilds it. """ - count = len(acc.get("history") or []) - toggle_btn = ctk.CTkButton( - buttons, text="History", width=70, height=34, corner_radius=8, - fg_color="transparent", border_width=1, border_color=BORDER, - hover_color=CARD_HOVER, text_color=TEXT if count else MUTED, - font=F(13, "bold")) - toggle_btn.grid(row=0, column=column, padx=4) - Tooltip(toggle_btn, - f"History — {count} earlier version{'s' if count != 1 else ''}" - if count else "History — no earlier versions yet") - - state = {"panel": None} - - def open_panel(): - state["panel"] = _build_panel(app, row, acc) - app._history_open.add(acc["id"]) - - def close_panel(): - if state["panel"] is not None: - state["panel"].destroy() - state["panel"] = None - app._history_open.discard(acc["id"]) - - toggle_btn.configure( - command=lambda: close_panel() if state["panel"] else open_panel()) - if acc["id"] in app._history_open: - open_panel() - - -def _build_panel(app, row, acc): - panel = ctk.CTkFrame(row, fg_color=HEADER_BG, corner_radius=10) - panel.grid(row=1, column=0, columnspan=5, sticky="ew", - padx=14, pady=(0, 12)) + panel = ctk.CTkFrame(parent, fg_color=HEADER_BG, corner_radius=14) panel.grid_columnconfigure(1, weight=1) history = acc.get("history") or [] @@ -138,11 +125,13 @@ def _revision_line(app, panel, line, acc, rev): ctk.CTkLabel(panel, text=local_time(rev.get("replaced_at", "")), width=150, anchor="w", font=F(12), text_color=TEXT).grid( row=line, column=0, sticky="w", padx=(12, 8), pady=3) + # Wrapped: the details pane is narrower than the old full-width row. ctk.CTkLabel(panel, text=describe(rev), anchor="w", font=F(11), - text_color=MUTED).grid(row=line, column=1, sticky="w", pady=3) + text_color=MUTED, wraplength=170, justify="left").grid( + row=line, column=1, sticky="w", pady=3) secret = rev.get("password", "") - pwd = ctk.CTkLabel(panel, text="•" * 8, width=140, anchor="e", + pwd = ctk.CTkLabel(panel, text="•" * 8, width=110, anchor="e", font=ctk.CTkFont(family=MONO, size=13), text_color=MUTED) pwd.grid(row=line, column=2, padx=(8, 8), pady=3) diff --git a/sourceFiles/sync_ui.py b/sourceFiles/sync_ui.py index d718790..abd41ec 100644 --- a/sourceFiles/sync_ui.py +++ b/sourceFiles/sync_ui.py @@ -19,9 +19,11 @@ import sync import sync_client import vault -from theme import (F, TEXT, MUTED, GREEN, GREEN_HOVER, DANGER, DANGER_HOVER, - INPUT_BG, BORDER, CARD, CARD_HOVER, ON_ACCENT, WARN, MONO) -from widgets import ghost_button, accent_button, field +from history_ui import local_time +from theme import (F, TEXT, MUTED, GREEN, DANGER, INPUT_BG, BORDER, CARD, + CARD_HOVER, ACCENT_DEEP, WARN, MONO, tile_colors) +from widgets import (ghost_button, accent_button, small_button, field, note, + check, error_label, dialog_shell) #: Labels for the three ways out of a conflict. "Keep both" is first and is #: the default: it is the only one that cannot hide anything. @@ -196,36 +198,29 @@ def conflict_dialog(app, result): a screenful of rows that need no decision is how people learn to click through the one that did. """ - top = app._modal("Review sync conflicts", 820, 620) + top = app._modal("Review sync conflicts", 880, 680) answers = {c.id: sync.DEFAULT_RESOLUTION for c in result.conflicts} outcome = {"choices": None} revealed = {"on": False} rows = [] - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=24, pady=20) - - ctk.CTkLabel(wrap, text="Review sync conflicts", font=F(20, "bold"), - text_color=TEXT).pack(anchor="w") count = len(result.conflicts) + shell = dialog_shell( + top, "Review sync conflicts", + f"{count} entr{'y' if count == 1 else 'ies'} changed on both this PC " + f"and the server. Everything else merged on its own: " + f"{result.summary()}.", scroll=True) + listing = shell.body + listing.grid_columnconfigure(0, weight=1) + ctk.CTkLabel( - wrap, - text=(f"{count} entr{'y' if count == 1 else 'ies'} changed on both " - f"this PC and the server. Everything else merged on its own: " - f"{result.summary()}."), - font=F(12), text_color=MUTED, wraplength=760, - justify="left").pack(anchor="w", pady=(2, 2)) - ctk.CTkLabel( - wrap, + listing, text="Whichever side you do not keep is saved in that entry's " "history — open History on the entry to revert. (An entry " "deleted on one side goes to the Archive instead.)", - font=F(12), text_color=GREEN, wraplength=760, - justify="left").pack(anchor="w", pady=(0, 10)) - - listing = ctk.CTkScrollableFrame(wrap, fg_color="transparent") - listing.pack(fill="both", expand=True) - listing.grid_columnconfigure(0, weight=1) + font=F(12), text_color=GREEN, wraplength=shell.text_width, + justify="left", anchor="w").grid(row=0, column=0, sticky="w", + padx=2, pady=(10, 6)) for i, conflict in enumerate(result.conflicts): rows.append(_conflict_row(listing, i, conflict, answers)) @@ -242,15 +237,14 @@ def toggle_reveal(): reveal_btn.configure(text="Hide passwords" if revealed["on"] else "Show passwords") for widget, secret in _secret_labels: - widget.configure(text=secret if revealed["on"] else "•" * 10) + widget.configure(text=(secret or "—") if revealed["on"] + else "•" * 10) def apply(): outcome["choices"] = dict(answers) top.destroy() - bar = ctk.CTkFrame(wrap, fg_color="transparent") - bar.pack(fill="x", pady=(14, 0)) - + bar = shell.buttons reveal_btn = ghost_button(bar, "Show passwords", toggle_reveal, width=150) reveal_btn.pack(side="left") ghost_button(bar, "All: this PC", @@ -279,16 +273,24 @@ def _conflict_row(parent, index, conflict, answers): if index == 0: _secret_labels.clear() - card = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=12) - card.grid(row=index, column=0, sticky="ew", pady=6, padx=2) + card = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=16, + border_width=1, border_color=BORDER) + card.grid(row=index + 1, column=0, sticky="ew", pady=6, padx=2) card.grid_columnconfigure((0, 1), weight=1, uniform="sides") header = ctk.CTkFrame(card, fg_color="transparent") - header.grid(row=0, column=0, columnspan=2, sticky="ew", padx=14, pady=(12, 0)) - ctk.CTkLabel(header, text=conflict.site or "(no site)", - font=F(15, "bold"), text_color=TEXT).pack(side="left") - ctk.CTkLabel(header, text=f" {_kind_note(conflict)}", font=F(12), - text_color=WARN).pack(side="left") + header.grid(row=0, column=0, columnspan=2, sticky="ew", padx=16, + pady=(14, 0)) + site = conflict.site or "(no site)" + bg, ink, _ = tile_colors(conflict.site) + ctk.CTkLabel(header, text=site[:1].upper(), width=38, height=38, + corner_radius=11, fg_color=bg, text_color=ink, + font=F(15, "bold")).pack(side="left", padx=(0, 12)) + ctk.CTkLabel(header, text=site, font=F(15, "bold"), + text_color=TEXT).pack(side="left") + ctk.CTkLabel(header, text=f" {_kind_note(conflict)} ", height=26, + corner_radius=13, fg_color=INPUT_BG, text_color=WARN, + font=F(11, "bold")).pack(side="left", padx=(12, 0)) gone = "not on the server" if conflict.kind == "missing" else "deleted" _version_panel(card, 1, 0, "On this PC", conflict.local, gone) @@ -296,14 +298,16 @@ def _conflict_row(parent, index, conflict, answers): choice_row = ctk.CTkFrame(card, fg_color="transparent") choice_row.grid(row=2, column=0, columnspan=2, sticky="ew", - padx=14, pady=(6, 12)) - ctk.CTkLabel(choice_row, text="Keep:", font=F(12, "bold"), - text_color=MUTED).pack(side="left", padx=(0, 8)) + padx=16, pady=(4, 14)) + ctk.CTkLabel(choice_row, text="Keep", font=F(12, "bold"), + text_color=MUTED).pack(side="left", padx=(0, 10)) segmented = ctk.CTkSegmentedButton( choice_row, values=[CHOICE_LABELS[c] for c in CHOICE_ORDER], - font=F(12, "bold"), selected_color=GREEN, - selected_hover_color=GREEN_HOVER, text_color=TEXT, + font=F(12, "bold"), height=34, corner_radius=10, fg_color=INPUT_BG, + unselected_color=INPUT_BG, unselected_hover_color=CARD_HOVER, + selected_color=ACCENT_DEEP, selected_hover_color=ACCENT_DEEP, + text_color=TEXT, command=lambda label: answers.__setitem__(conflict.id, _choice_from(label))) segmented.pack(side="left") @@ -335,45 +339,52 @@ def _kind_note(conflict): def _version_panel(parent, row, column, title, entry, gone="deleted"): """ - One side of a conflict. Passwords are masked until the user asks. - ``gone`` is what an absent side says. + One side of a conflict, as a field box like the details card's. + Passwords are masked until the user asks. ``gone`` is what an absent + side says. """ - panel = ctk.CTkFrame(parent, fg_color="transparent") - panel.grid(row=row, column=column, sticky="nsew", padx=14, pady=8) + panel = ctk.CTkFrame(parent, fg_color=INPUT_BG, corner_radius=14) + panel.grid(row=row, column=column, sticky="nsew", + padx=(16, 6) if column == 0 else (6, 16), pady=12) - ctk.CTkLabel(panel, text=title, font=F(12, "bold"), - text_color=MUTED).pack(anchor="w") + head = ctk.CTkFrame(panel, fg_color="transparent") + head.pack(fill="x", padx=14, pady=(12, 6)) + ctk.CTkLabel(head, text=title, font=F(12, "bold"), + text_color=MUTED).pack(side="left") if entry is None: - ctk.CTkLabel(panel, text=gone, font=F(13), text_color=DANGER, - anchor="w").pack(anchor="w", pady=(4, 0)) + ctk.CTkLabel(panel, text=gone, font=F(13, "bold"), text_color=DANGER, + anchor="w").pack(anchor="w", padx=14, pady=(0, 14)) return if vault.is_archived(entry): - ctk.CTkLabel(panel, text="archived", font=F(12, "bold"), - text_color=WARN, anchor="w").pack(anchor="w", pady=(4, 0)) - - for label, value in (("user", entry.get("username", "")), - ("site", entry.get("site", ""))): - ctk.CTkLabel(panel, text=f"{label}: {value or '—'}", font=F(12), - text_color=TEXT, anchor="w").pack(anchor="w") - - secret = entry.get("password", "") - pwd = ctk.CTkLabel(panel, text="•" * 10, anchor="w", - font=ctk.CTkFont(family=MONO, size=13), - text_color=TEXT) - pwd.pack(anchor="w", pady=(2, 0)) - _secret_labels.append((pwd, secret)) - - for label, key in (("notes", "notes"), ("2FA", "totp")): - if entry.get(key): - ctk.CTkLabel(panel, text=f"{label}: {entry[key][:40]}", font=F(11), - text_color=MUTED, anchor="w").pack(anchor="w") + ctk.CTkLabel(head, text="archived", font=F(11, "bold"), + text_color=WARN).pack(side="right") + + def line(label, value, font=None): + box = ctk.CTkFrame(panel, fg_color="transparent") + box.pack(fill="x", padx=14, pady=1) + ctk.CTkLabel(box, text=label, width=74, anchor="w", font=F(12), + text_color=MUTED).pack(side="left") + lbl = ctk.CTkLabel(box, text=value, anchor="w", font=font or F(13), + text_color=TEXT) + lbl.pack(side="left", fill="x") + return lbl + + line("Site", entry.get("site", "") or "—") + line("Username", entry.get("username", "") or "—") + pwd = line("Password", "•" * 10, ctk.CTkFont(family=MONO, size=13)) + _secret_labels.append((pwd, entry.get("password", ""))) + if entry.get("notes"): + line("Notes", entry["notes"][:40]) + if entry.get("totp"): + line("2FA", "seed stored") when = entry.get("updated_at", "") - ctk.CTkLabel(panel, text=f"edited {when or 'at an unknown time'}", - font=F(11), text_color=MUTED, anchor="w").pack(anchor="w", - pady=(4, 0)) + ctk.CTkLabel(panel, text=("Changed " + local_time(when)) if when + else "Changed at an unknown time", font=F(11), + text_color=MUTED, anchor="w").pack(anchor="w", padx=14, + pady=(8, 12)) # ========================================================================== @@ -402,7 +413,7 @@ def password_mismatch_dialog(app, adopt, wrong=False): "switches to it: from now on you unlock it with the new " "password on this PC too. Nothing in the vault is lost.") label = "The new master password (set on the other PC)" - note = ("If you also changed it here, the other PC's change reached " + hint = ("If you also changed it here, the other PC's change reached " "the server first and takes precedence.") else: title = "Update the server to your new password" @@ -413,22 +424,16 @@ def password_mismatch_dialog(app, adopt, wrong=False): "copy with it, merges, and uploads everything again under " "your new password.") label = "Your previous master password" - note = "" + hint = "" - top = app._modal(title, 500, 520) + top = app._modal(title, 520, 520) outcome = {"answer": None} + shell = dialog_shell(top, title) + wrap = shell.body - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=28, pady=24) - - ctk.CTkLabel(wrap, text=title, font=F(18, "bold"), - text_color=TEXT).pack(anchor="w") - ctk.CTkLabel(wrap, text=body, font=F(12), text_color=MUTED, - wraplength=430, justify="left").pack(anchor="w", pady=(4, 0)) - if note: - ctk.CTkLabel(wrap, text=note, font=F(11), text_color=MUTED, - wraplength=430, justify="left").pack(anchor="w", - pady=(6, 0)) + note(wrap, body, shell.text_width, color=TEXT, size=13, pady=(14, 0)) + if hint: + note(wrap, hint, shell.text_width, size=12, pady=(8, 0)) entry = field(wrap, label, show="•") @@ -437,19 +442,14 @@ def password_mismatch_dialog(app, adopt, wrong=False): # Checked by default: people change a master password because they # think the old one is exposed, and these copies still open with it. # The PCs keep full copies, so nothing current is lost. - drop = ctk.CTkCheckBox( - wrap, text="Also delete the older copies on the server — they " - "still open with your previous password", - font=F(12), text_color=TEXT, fg_color=GREEN, - hover_color=GREEN_HOVER) - drop.select() - drop.pack(anchor="w", pady=(12, 0)) - - err = ctk.CTkLabel(wrap, font=F(12), text_color=DANGER, - wraplength=430, justify="left", - text=("That password does not open the server's copy. " - "Try again." if wrong else "")) - err.pack(anchor="w", pady=(8, 0)) + drop = check(wrap, "Also delete the older copies on the server — they " + "still open with your previous password", + shell.text_width, checked=True) + + err = error_label(wrap, shell.text_width) + if wrong: + err.configure(text="That password does not open the server's copy. " + "Try again.") def go(): password = entry.get() @@ -458,10 +458,9 @@ def go(): outcome["answer"] = (password, bool(drop.get()) if drop else False) top.destroy() - row = ctk.CTkFrame(wrap, fg_color="transparent") - row.pack(fill="x", side="bottom", pady=(16, 0)) - ghost_button(row, "Cancel", top.destroy, width=110).pack(side="left") - accent_button(row, "Continue", go, width=140).pack(side="right") + ghost_button(shell.buttons, "Cancel", top.destroy, + width=110).pack(side="left") + accent_button(shell.buttons, "Continue", go, width=140).pack(side="right") entry.bind("", lambda e: go()) top.bind("", lambda e: top.destroy()) @@ -480,21 +479,13 @@ def archive_dialog(app): Restoring is one click. Purging is the only way to actually destroy an entry, so it confirms and says plainly that it is final. """ - top = app._modal("Archive", 720, 560) - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=24, pady=20) - - ctk.CTkLabel(wrap, text="Archive", font=F(20, "bold"), - text_color=TEXT).pack(anchor="w") - ctk.CTkLabel( - wrap, - text="Deleted entries are kept here rather than destroyed, and they " - "still hold their passwords. Restore one, or delete it for good.", - font=F(12), text_color=MUTED, wraplength=650, - justify="left").pack(anchor="w", pady=(2, 12)) - - listing = ctk.CTkScrollableFrame(wrap, fg_color="transparent") - listing.pack(fill="both", expand=True) + top = app._modal("Archive", 720, 580) + shell = dialog_shell( + top, "Archive", + "Deleted entries are kept here rather than destroyed, and they still " + "hold their passwords. Restore one, or delete it for good.", + scroll=True) + listing = shell.body listing.grid_columnconfigure(0, weight=1) def rebuild(): @@ -503,37 +494,49 @@ def rebuild(): entries = vault.archived_accounts(app.vault) if not entries: - ctk.CTkLabel(listing, text="The archive is empty", - font=F(15, "bold"), text_color=TEXT).grid(row=0, - column=0, - pady=60) + box = ctk.CTkFrame(listing, fg_color="transparent") + box.grid(row=0, column=0, pady=80) + ctk.CTkLabel(box, text="The archive is empty", font=F(17, "bold"), + text_color=TEXT).pack() + ctk.CTkLabel(box, text="Entries you archive show up here.", + font=F(13), text_color=MUTED).pack(pady=(4, 0)) return for i, entry in enumerate(entries): _archive_row(app, listing, i, entry, rebuild) rebuild() - ghost_button(wrap, "Close", top.destroy, width=110).pack(side="right", - pady=(14, 0)) + ghost_button(shell.buttons, "Close", top.destroy, + width=110).pack(side="right") top.bind("", lambda e: top.destroy()) def _archive_row(app, parent, index, entry, rebuild): - row = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=12) - row.grid(row=index, column=0, sticky="ew", pady=5, padx=2) - row.grid_columnconfigure(0, weight=1) - + """A row like the main list's: tile, site, who and when, then actions.""" + row = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=14, + border_width=1, border_color=BORDER) + row.grid(row=index, column=0, sticky="ew", pady=(10 if index == 0 else 4, + 4), padx=2) + row.grid_columnconfigure(1, weight=1) + + site = entry.get("site", "") or "—" + bg, ink, _ = tile_colors(entry.get("site", "")) + ctk.CTkLabel(row, text=site[:1].upper(), width=42, height=42, + corner_radius=12, fg_color=bg, text_color=ink, + font=F(16, "bold")).grid(row=0, column=0, padx=(12, 12), + pady=10) info = ctk.CTkFrame(row, fg_color="transparent") - info.grid(row=0, column=0, sticky="w", padx=14, pady=10) - ctk.CTkLabel(info, text=entry.get("site", "—"), anchor="w", - font=F(14, "bold"), text_color=TEXT).pack(anchor="w") + info.grid(row=0, column=1, sticky="w") + ctk.CTkLabel(info, text=site, anchor="w", font=F(14, "bold"), + text_color=TEXT).pack(anchor="w") + when = entry.get("archived_at") ctk.CTkLabel(info, text=f"{entry.get('username') or '—'} · archived " - f"{entry.get('archived_at', 'at an unknown time')}", - anchor="w", font=F(11), text_color=MUTED).pack(anchor="w") + f"{local_time(when) if when else 'at an unknown time'}", + anchor="w", font=F(12), text_color=MUTED).pack(anchor="w") buttons = ctk.CTkFrame(row, fg_color="transparent") - buttons.grid(row=0, column=1, padx=(0, 12)) + buttons.grid(row=0, column=2, padx=(8, 14)) def restore(): vault.restore_account(app.vault, entry["id"]) @@ -546,7 +549,7 @@ def purge(): if not app._confirm( "Delete permanently", f"Permanently delete the saved login for " - f"\u201c{entry.get('site', '')}\u201d?\n\n" + f"“{entry.get('site', '')}”?\n\n" "This cannot be undone, and it is the only action in PassMan " "that really destroys a password."): return @@ -556,15 +559,10 @@ def purge(): rebuild() app.toast("Deleted permanently", "warn") - ctk.CTkButton(buttons, text="Restore", width=90, height=34, - corner_radius=8, fg_color=GREEN, hover_color=GREEN_HOVER, - text_color=ON_ACCENT, font=F(12, "bold"), - command=restore).grid(row=0, column=0, padx=4) - ctk.CTkButton(buttons, text="Delete", width=80, height=34, - corner_radius=8, fg_color="transparent", border_width=1, - border_color=BORDER, hover_color=DANGER_HOVER, - text_color=DANGER, font=F(12, "bold"), - command=purge).grid(row=0, column=1, padx=4) + small_button(buttons, "Restore", restore, accent=True, + width=86).pack(side="left", padx=(0, 6)) + small_button(buttons, "Delete", purge, danger=True, + width=76).pack(side="left") # ========================================================================== @@ -605,19 +603,12 @@ def sync_settings_dialog(app): """Server address, token and optional pinned certificate.""" top = app._modal("Sync settings", 560, 640) settings = sync_client.get_settings(app.vault) - - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=28, pady=24) - - ctk.CTkLabel(wrap, text="Sync", font=F(20, "bold"), - text_color=TEXT).pack(anchor="w") - ctk.CTkLabel( - wrap, - text="Your vault is encrypted before it is sent, so the server stores " - "ciphertext it has no key for. These settings are kept inside the " - "vault itself, not in config.json.", - font=F(12), text_color=MUTED, wraplength=450, - justify="left").pack(anchor="w", pady=(2, 4)) + shell = dialog_shell( + top, "Sync", + "Your vault is encrypted before it is sent, so the server stores " + "ciphertext it has no key for. These settings are kept inside the " + "vault itself, not in config.json.") + wrap = shell.body url_entry = field(wrap, "Server address", placeholder="http://192.168.1.50:8787") @@ -627,12 +618,17 @@ def sync_settings_dialog(app): token_entry.insert(0, settings.get("token", "")) ca_entry = field(wrap, "Certificate file (only for https, optional)", - placeholder="C:\\PassMan\\server.crt") + placeholder="/home/you/passman/server.crt") ca_entry.insert(0, settings.get("ca_file", "")) + note(wrap, "Plain http is reasonable over a VPN you trust. For https with " + "a self-signed certificate, copy the .crt here and point the field " + "above at it.", shell.text_width, size=11, pady=(10, 0)) + status = ctk.CTkLabel(wrap, text="", font=F(12), text_color=MUTED, - wraplength=450, justify="left") - status.pack(anchor="w", pady=(10, 0)) + wraplength=shell.text_width, justify="left", + anchor="w") + status.pack(anchor="w", fill="x", pady=(10, 0)) def _validate(): """Shared by Test and Save: the typed fields, or None once the @@ -724,16 +720,7 @@ def save_offline(): top.destroy() app.toast("Saved \u2014 press Sync once you are connected") - ctk.CTkLabel( - wrap, - text="Plain http is reasonable over a VPN you trust. For https with a " - "self-signed certificate, copy the .crt here and point the field " - "above at it.", - font=F(11), text_color=MUTED, wraplength=450, - justify="left").pack(anchor="w", pady=(12, 0)) - - row = ctk.CTkFrame(wrap, fg_color="transparent") - row.pack(fill="x", side="bottom", pady=(16, 0)) + row = shell.buttons ghost_button(row, "Test connection", test, width=140).pack(side="left") accent_button(row, "Save & sync", save, width=130).pack(side="right") ghost_button(row, "Save only", save_offline, width=100).pack( @@ -756,18 +743,12 @@ def restore_from_server_dialog(app): for that username already exists so it can never overwrite one. """ top = app._modal("Restore from server", 520, 660) - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=28, pady=24) - - ctk.CTkLabel(wrap, text="Restore from server", font=F(20, "bold"), - text_color=TEXT).pack(anchor="w") - ctk.CTkLabel( - wrap, - text="Download a vault you already sync from another PC. You will " - "need the server address, its token, and the master password " - "that vault was created with.", - font=F(12), text_color=MUTED, wraplength=450, - justify="left").pack(anchor="w", pady=(2, 4)) + shell = dialog_shell( + top, "Restore from server", + "Download a vault you already sync from another PC. You will need the " + "server address, its token, and the master password that vault was " + "created with.") + wrap = shell.body url_entry = field(wrap, "Server address", placeholder="http://192.168.1.50:8787") token_entry = field(wrap, "Sync token", show="•") @@ -775,8 +756,9 @@ def restore_from_server_dialog(app): pw_entry = field(wrap, "Master password", show="•") status = ctk.CTkLabel(wrap, text="", font=F(12), text_color=DANGER, - wraplength=450, justify="left") - status.pack(anchor="w", pady=(10, 0)) + wraplength=shell.text_width, justify="left", + anchor="w") + status.pack(anchor="w", fill="x", pady=(10, 0)) def restore(): url = url_entry.get().strip() @@ -828,9 +810,9 @@ def done(fetched): on_error=lambda e: status.configure(text=str(e), text_color=DANGER)) - row = ctk.CTkFrame(wrap, fg_color="transparent") - row.pack(fill="x", side="bottom", pady=(16, 0)) - ghost_button(row, "Cancel", top.destroy, width=110).pack(side="left") - accent_button(row, "Restore", restore, width=140).pack(side="right") + ghost_button(shell.buttons, "Cancel", top.destroy, + width=110).pack(side="left") + accent_button(shell.buttons, "Restore", restore, + width=140).pack(side="right") top.bind("", lambda e: top.destroy()) app._fit_modal(top) diff --git a/sourceFiles/tests/test_gui_helpers.py b/sourceFiles/tests/test_gui_helpers.py new file mode 100644 index 0000000..00f8f7e --- /dev/null +++ b/sourceFiles/tests/test_gui_helpers.py @@ -0,0 +1,50 @@ +"""The small display helpers behind the redesigned list and login tiles.""" + +from datetime import datetime, timezone + +import gui +import history_ui +import theme + + +def test_short_when_today_shows_the_time(): + now = datetime(2026, 10, 28, 15, 0, tzinfo=timezone.utc) + stamp = "2026-10-28T09:41:00Z" + local = datetime(2026, 10, 28, 9, 41, tzinfo=timezone.utc).astimezone() + assert history_ui.short_when(stamp, now=now) == local.strftime("%H:%M") + + +def test_short_when_this_year_shows_day_and_month(): + now = datetime(2026, 10, 28, 12, 0, tzinfo=timezone.utc) + assert history_ui.short_when("2026-03-05T12:00:00Z", now=now) == "5 Mar" + + +def test_short_when_older_shows_month_and_year(): + now = datetime(2026, 10, 28, 12, 0, tzinfo=timezone.utc) + assert history_ui.short_when("2024-07-14T12:00:00.123456Z", + now=now) == "Jul 2024" + + +def test_short_when_bad_stamp_is_blank(): + assert history_ui.short_when("") == "" + assert history_ui.short_when(None) == "" + assert history_ui.short_when("yesterday") == "" + + +def test_initials(): + assert gui._initials("alex.morgan") == "AM" + assert gui._initials("studio_work") == "SW" + assert gui._initials("test") == "TE" + assert gui._initials("x") == "X" + + +def test_clip(): + assert gui._clip("github.com", 30) == "github.com" + assert gui._clip("a" * 40, 10) == "a" * 9 + "…" + assert gui._clip(None, 5) == "" + + +def test_tile_colors_are_stable_and_from_the_palette(): + assert theme.tile_colors("github.com") == theme.tile_colors("github.com") + assert theme.tile_colors("github.com") in theme.TILE_PALETTE + assert theme.tile_colors("") == theme.TILE_PALETTE[0] diff --git a/sourceFiles/theme.py b/sourceFiles/theme.py index 0007160..0e77480 100644 --- a/sourceFiles/theme.py +++ b/sourceFiles/theme.py @@ -20,28 +20,38 @@ from customtkinter.windows.widgets.core_rendering import DrawEngine DrawEngine.preferred_drawing_method = "polygon_shapes" -HEADER_BG = "#202022" -CARD = "#2A2A2D" -CARD_HOVER = "#34343A" -INPUT_BG = "#2A2A2D" -BORDER = "#3A3A40" -TEXT = "#ECECEC" -MUTED = "#9B9BA3" -GREEN = "#67C090" -GREEN_HOVER = "#4EA877" +BG = "#0A0C0B" # the window itself +HEADER_BG = "#101311" # top bar, history panel +CARD = "#131715" # cards and panels on top of BG +CARD_HOVER = "#1C231F" +SELECTED = "#18271E" # the selected row in the account list +INPUT_BG = "#1A1F1C" +BORDER = "#27302A" +TEXT = "#E6EDE8" +MUTED = "#8E9A92" +GREEN = "#2BD47D" +GREEN_HOVER = "#22B169" +ACCENT_DEEP = "#177A49" # a selected choice under light text DANGER = "#E5534B" DANGER_HOVER = "#C23B34" -ON_ACCENT = "#0E1A13" # text drawn on top of the green accent -WARN = "#E0A030" +ON_ACCENT = "#06110B" # text drawn on top of the green accent +WARN = "#E3B341" # Segoe UI and Consolas are Windows fonts. DejaVu ships with Ubuntu and # most other desktops; where it is missing, fontconfig picks the nearest. FONT = "DejaVu Sans" MONO = "DejaVu Sans Mono" -# Avatar colors cycled per account for a bit of visual variety. -AVATAR_PALETTE = ["#67C090", "#4F9DDE", "#E0A030", "#C97BD4", - "#E5705B", "#54B7A8", "#7E83E8", "#D98AB0"] +# Per-site tile colours: (tile, letter, header band). Dark tints with a light +# letter, so entries are told apart without breaking the black-and-green look. +TILE_PALETTE = [ + ("#173A27", "#8FF0B9", "#10291B"), + ("#3A2C17", "#F2C27A", "#2A2213"), + ("#2A2340", "#CBB8FF", "#1E1A30"), + ("#3D1E22", "#FFA9B1", "#2D171A"), + ("#172B3D", "#9CCBFF", "#12223A"), + ("#222B35", "#B8C6D6", "#1A222B"), +] def F(size, weight="normal"): @@ -49,11 +59,11 @@ def F(size, weight="normal"): return ctk.CTkFont(family=FONT, size=size, weight=weight) -def avatar_color(s): - """Stable per-site avatar colour.""" +def tile_colors(s): + """Stable per-site (tile, letter, band) colours.""" if not s: - return GREEN - return AVATAR_PALETTE[sum(ord(c) for c in s) % len(AVATAR_PALETTE)] + return TILE_PALETTE[0] + return TILE_PALETTE[sum(ord(c) for c in s) % len(TILE_PALETTE)] def asset_candidates(name): diff --git a/sourceFiles/version.py b/sourceFiles/version.py index e8e8c7c..f855cdb 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -6,7 +6,7 @@ format changes. """ -__version__ = "0.6.0" +__version__ = "0.7.0" #: Container version PassMan writes today (see crypto_utils.MAGIC). VAULT_FORMAT = "PMV2" diff --git a/sourceFiles/widgets.py b/sourceFiles/widgets.py index 9592815..09bd5d4 100644 --- a/sourceFiles/widgets.py +++ b/sourceFiles/widgets.py @@ -3,16 +3,29 @@ Each function takes the widget it should build into, so these can be used from gui.py and dialogs.py alike without either importing the other. + +Every dialog is built the same way since 2.9.0, to match the main window: +:func:`dialog_shell` gives it a header band (title, optional site tile), a +body, and a footer bar for its buttons; :func:`section` groups settings into +cards like the details card's. """ import tkinter as tk +from types import SimpleNamespace import customtkinter as ctk -from theme import (F, FONT, TEXT, MUTED, GREEN, GREEN_HOVER, DANGER, - DANGER_HOVER, INPUT_BG, BORDER, CARD_HOVER, ON_ACCENT) +from theme import (F, FONT, BG, HEADER_BG, CARD, TEXT, MUTED, GREEN, + GREEN_HOVER, DANGER, DANGER_HOVER, INPUT_BG, BORDER, + CARD_HOVER, ON_ACCENT) from password_generator import estimate_strength +#: Side padding inside every dialog. +PAD = 28 + +#: Segments in a strength meter -- the details card and the dialogs alike. +STRENGTH_SEGMENTS = 5 + class Tooltip: """Lightweight hover tooltip that works on any tk/ctk widget.""" @@ -44,6 +57,9 @@ def _hide(self, _=None): self.tip = None +# --------------------------------------------------------------------------- +# Buttons +# --------------------------------------------------------------------------- def ghost_button(parent, text, command, width=120, danger=False): return ctk.CTkButton( parent, text=text, command=command, width=width, height=40, @@ -61,49 +77,248 @@ def accent_button(parent, text, command, width=140): ) +def danger_button(parent, text, command, width=130): + return ctk.CTkButton( + parent, text=text, command=command, width=width, height=40, + corner_radius=10, fg_color=DANGER, hover_color=DANGER_HOVER, + text_color="white", font=F(13, "bold"), + ) + + +def small_button(parent, text, command, accent=False, danger=False, width=72): + """The compact button of list rows (archive) -- same as the details card's.""" + return ctk.CTkButton( + parent, text=text, command=command, width=width, height=34, + corner_radius=10, + fg_color=GREEN if accent else "transparent", + hover_color=GREEN_HOVER if accent else CARD_HOVER, + border_width=0 if accent else 1, border_color=BORDER, + text_color=ON_ACCENT if accent else (DANGER if danger else TEXT), + font=F(12, "bold")) + + +# --------------------------------------------------------------------------- +# Form parts +# --------------------------------------------------------------------------- +def field_label(parent, text): + lbl = ctk.CTkLabel(parent, text=text, anchor="w", font=F(12, "bold"), + text_color=MUTED) + lbl.pack(anchor="w", pady=(14, 6)) + return lbl + + +def entry(parent, show=None, placeholder="", height=44, font=None): + return ctk.CTkEntry(parent, height=height, corner_radius=12, + fg_color=INPUT_BG, border_width=1, border_color=BORDER, + show=show, font=font or F(14), + placeholder_text=placeholder) + + def field(parent, label, show=None, placeholder=""): - ctk.CTkLabel(parent, text=label, anchor="w", font=F(13, "bold"), - text_color=MUTED).pack(anchor="w", pady=(12, 4)) - e = ctk.CTkEntry(parent, height=42, corner_radius=10, fg_color=INPUT_BG, - border_color=BORDER, show=show, font=F(14), - placeholder_text=placeholder) + field_label(parent, label) + e = entry(parent, show=show, placeholder=placeholder) e.pack(fill="x") return e +def note(parent, text, width, color=MUTED, size=12, pady=(0, 0)): + """A paragraph that wraps at ``width`` (the space it has, in points).""" + lbl = ctk.CTkLabel(parent, text=text, font=F(size), text_color=color, + wraplength=width, justify="left", anchor="w") + lbl.pack(anchor="w", fill="x", pady=pady) + return lbl + + +def error_label(parent, width): + lbl = ctk.CTkLabel(parent, text="", font=F(12), text_color=DANGER, + wraplength=width, justify="left", anchor="w") + lbl.pack(anchor="w", fill="x", pady=(10, 0)) + return lbl + + +def check(parent, text, width, checked=False): + """ + A checkbox whose text wraps. CTkCheckBox draws its text on one line, so a + long one ran off the side of the dialog; here the text is a label beside + the box, and clicking it ticks the box all the same. + """ + row = ctk.CTkFrame(parent, fg_color="transparent") + row.pack(fill="x", pady=(14, 0)) + box = ctk.CTkCheckBox(row, text="", width=24, checkbox_width=22, + checkbox_height=22, fg_color=GREEN, + hover_color=GREEN_HOVER, border_color=MUTED) + box.pack(side="left", anchor="n") + lbl = ctk.CTkLabel(row, text=text, font=F(12), text_color=TEXT, + wraplength=width - 34, justify="left", anchor="w", + cursor="hand2") + lbl.pack(side="left", fill="x", padx=(8, 0)) + lbl.bind("", lambda e: box.toggle()) + if checked: + box.select() + return box + + def strength_widget(parent): - """A strength bar + label; returns (bar, label, update(password)).""" + """ + The 5-segment strength meter of the details card, live. + Returns (frame, label, update(password)). + """ box = ctk.CTkFrame(parent, fg_color="transparent") - box.pack(fill="x", pady=(10, 0)) - box.grid_columnconfigure(0, weight=1) - bar = ctk.CTkProgressBar(box, height=8, corner_radius=4, - progress_color=GREEN) - bar.grid(row=0, column=0, sticky="ew", padx=(0, 10)) - bar.set(0) - label = ctk.CTkLabel(box, text="", width=54, font=F(12, "bold"), - text_color=MUTED) - label.grid(row=0, column=1) + box.pack(fill="x", pady=(12, 0)) + segments = [] + for _ in range(STRENGTH_SEGMENTS): + seg = ctk.CTkFrame(box, width=10, height=6, corner_radius=3, + fg_color=BORDER) + seg.pack(side="left", fill="x", expand=True, padx=(0, 4)) + segments.append(seg) + label = ctk.CTkLabel(box, text="", width=64, anchor="e", + font=F(12, "bold"), text_color=MUTED) + label.pack(side="left", padx=(6, 0)) def update(password): name, frac, color = estimate_strength(password) - bar.set(frac) - bar.configure(progress_color=color) - label.configure(text=name, text_color=color) + lit = max(round(frac * STRENGTH_SEGMENTS), 1) if password else 0 + for n, seg in enumerate(segments): + seg.configure(fg_color=color if n < lit else BORDER) + label.configure(text=name if password else "", text_color=color) + + update("") + return box, label, update + + +# --------------------------------------------------------------------------- +# Dialog layout +# --------------------------------------------------------------------------- +def _rule(parent): + ctk.CTkFrame(parent, height=1, corner_radius=0, + fg_color=BORDER).pack(fill="x") + + +def dialog_shell(top, title, subtitle="", tile=None, danger=False, + scroll=False, fit_exact=True): + """ + Header band, body and footer bar for a dialog -- the top bar and the + details card of the main window, in small. + + ``tile`` (letter, tile colour, letter colour): the site tile, as in + the list and the details card. + ``scroll`` the body scrolls (long lists, Settings); the header and the + buttons stay put. + ``fit_exact`` the window takes the height its content needs, so short + dialogs lose their empty middle. Scrolling ones keep theirs. + + Returns a namespace: ``body`` (build into it), ``buttons`` (the footer + row), ``heading`` and ``tile`` (to change them live), ``text_width`` + (what a paragraph in the body can wrap at). + """ + width = getattr(top, "pm_width", 460) + top.pm_fit_exact = fit_exact and not scroll - return bar, label, update + foot = ctk.CTkFrame(top, fg_color=HEADER_BG, corner_radius=0) + foot.pack(side="bottom", fill="x") + _rule(foot) + buttons = ctk.CTkFrame(foot, fg_color="transparent") + buttons.pack(fill="x", padx=PAD, pady=14) + head = ctk.CTkFrame(top, fg_color=HEADER_BG, corner_radius=0) + head.pack(side="top", fill="x") + inner = ctk.CTkFrame(head, fg_color="transparent") + inner.pack(fill="x", padx=PAD, pady=(20, 18)) + text_width = width - 2 * PAD + tile_label = None + if tile: + letter, bg, ink = tile + tile_label = ctk.CTkLabel(inner, text=letter, width=48, height=48, + corner_radius=14, fg_color=bg, + text_color=ink, font=F(20, "bold")) + tile_label.pack(side="left", padx=(0, 16)) + text_width -= 64 + words = ctk.CTkFrame(inner, fg_color="transparent") + words.pack(side="left", fill="x", expand=True) + heading = ctk.CTkLabel(words, text=title, font=F(19, "bold"), + text_color=DANGER if danger else TEXT, anchor="w", + justify="left", wraplength=text_width) + heading.pack(anchor="w") + if subtitle: + ctk.CTkLabel(words, text=subtitle, font=F(12), text_color=MUTED, + anchor="w", justify="left", + wraplength=text_width).pack(anchor="w", pady=(3, 0)) + _rule(head) + if scroll: + body = ctk.CTkScrollableFrame(top, fg_color="transparent") + body.pack(fill="both", expand=True, padx=(PAD - 6, 8), pady=(8, 8)) + body_width = width - 2 * PAD - 24 + else: + body = ctk.CTkFrame(top, fg_color="transparent") + body.pack(fill="both", expand=True, padx=PAD, pady=(6, 22)) + body_width = width - 2 * PAD + return SimpleNamespace(body=body, buttons=buttons, heading=heading, + tile=tile_label, text_width=body_width) + + +def section(parent, title, danger=False, first=False): + """A titled card, like the details card's sections.""" + ctk.CTkLabel(parent, text=title, font=F(12, "bold"), + text_color=DANGER if danger else MUTED, + anchor="w").pack(anchor="w", pady=(10 if first else 22, 6)) + card = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=16, + border_width=1, border_color=DANGER if danger else BORDER) + card.pack(fill="x") + return card + + +def setting_row(card, title, hint="", hint_width=240, first=False): + """ + One row of a section card: what it is on the left (with a hint under + it), the control on the right. Returns the frame for the control. + """ + if not first: + ctk.CTkFrame(card, height=1, corner_radius=0, + fg_color=BORDER).pack(fill="x", padx=16) + row = ctk.CTkFrame(card, fg_color="transparent") + row.pack(fill="x", padx=16, pady=14) + row.grid_columnconfigure(0, weight=1) + text = ctk.CTkFrame(row, fg_color="transparent") + text.grid(row=0, column=0, sticky="w") + ctk.CTkLabel(text, text=title, font=F(13, "bold"), text_color=TEXT, + anchor="w").pack(anchor="w") + if hint: + ctk.CTkLabel(text, text=hint, font=F(12), text_color=MUTED, + wraplength=hint_width, justify="left", + anchor="w").pack(anchor="w", pady=(2, 0)) + right = ctk.CTkFrame(row, fg_color="transparent") + right.grid(row=0, column=1, sticky="e", padx=(12, 0)) + return right + + +def option_menu(parent, values, command, width=140): + return ctk.CTkOptionMenu( + parent, width=width, height=34, corner_radius=10, values=values, + fg_color=INPUT_BG, button_color=BORDER, button_hover_color=CARD_HOVER, + text_color=TEXT, font=F(12), dropdown_fg_color=CARD, + dropdown_hover_color=CARD_HOVER, dropdown_text_color=TEXT, + command=command) + + +# --------------------------------------------------------------------------- +# Modal plumbing +# --------------------------------------------------------------------------- def fit_modal(root, top): """ - Grow a modal so its content is never clipped. The hardcoded sizes are - only a starting point — actual required height varies with Windows - display scaling and font metrics, which used to cut off the bottom - button row on scaled displays. + Size a modal to its content. The hardcoded sizes are only a starting + point -- the height needed varies with Windows display scaling and font + metrics, which used to cut off the bottom button row on scaled displays. + + A dialog that is too short always grows. One built with + ``dialog_shell(fit_exact=True)`` also shrinks, so a short form does not + sit in a tall, mostly empty window. """ top.update_idletasks() need = top.winfo_reqheight() cur_w, cur_h = top.winfo_width(), top.winfo_height() - if need <= cur_h: + if need == cur_h or (need < cur_h and not getattr(top, "pm_fit_exact", + False)): return x = root.winfo_rootx() + (root.winfo_width() - cur_w) // 2 y = root.winfo_rooty() + (root.winfo_height() - need) // 2 @@ -130,9 +345,10 @@ def safe_grab(root, top, icon_path=None): def modal(root, title, w, h, icon_path=None): - top = ctk.CTkToplevel(root) + top = ctk.CTkToplevel(root, fg_color=BG) top.title(title) top.resizable(False, False) + top.pm_width = w root.update_idletasks() x = root.winfo_rootx() + (root.winfo_width() - w) // 2 y = root.winfo_rooty() + (root.winfo_height() - h) // 2 @@ -144,28 +360,20 @@ def modal(root, title, w, h, icon_path=None): def confirm(root, title, message, danger=True, icon_path=None): - top = modal(root, title, 400, 220, icon_path) + top = modal(root, title, 440, 240, icon_path) result = {"ok": False} - wrap = ctk.CTkFrame(top, fg_color="transparent") - wrap.pack(fill="both", expand=True, padx=26, pady=24) - ctk.CTkLabel(wrap, text=title, font=F(17, "bold"), - text_color=TEXT).pack(anchor="w") - ctk.CTkLabel(wrap, text=message, font=F(13), text_color=MUTED, - wraplength=340, justify="left").pack(anchor="w", pady=(8, 0)) + shell = dialog_shell(top, title) + note(shell.body, message, shell.text_width, color=TEXT, size=13, + pady=(14, 0)) def ok(): result["ok"] = True top.destroy() - row = ctk.CTkFrame(wrap, fg_color="transparent") - row.pack(fill="x", side="bottom", pady=(16, 0)) - ghost_button(row, "Cancel", top.destroy, width=120).pack(side="left") - ctk.CTkButton( - row, text="Delete" if danger else "Confirm", width=130, height=40, - corner_radius=10, fg_color=DANGER if danger else GREEN, - hover_color=DANGER_HOVER if danger else GREEN_HOVER, - text_color="white" if danger else ON_ACCENT, font=F(13, "bold"), - command=ok).pack(side="right") + ghost_button(shell.buttons, "Cancel", top.destroy, + width=120).pack(side="left") + (danger_button(shell.buttons, "Delete", ok) if danger else + accent_button(shell.buttons, "Confirm", ok, width=130)).pack(side="right") top.bind("", lambda e: ok()) top.bind("", lambda e: top.destroy()) root.wait_window(top) diff --git a/tools/smoke_gui.py b/tools/smoke_gui.py index 841a1c3..7aa2e91 100644 --- a/tools/smoke_gui.py +++ b/tools/smoke_gui.py @@ -93,8 +93,8 @@ def main(out_dir): def step_login(): check("login screenshot", screenshot(root, os.path.join(out_dir, "login.png"))) - app.username_entry.delete(0, "end") - app.username_entry.insert(0, USER) + # The login screen has one tile per vault; pick ours, then type. + app._pick_login_user(USER) app.password_entry.insert(0, PASSWORD) app.unlock_vault() root.after(2500, step_main) @@ -108,12 +108,24 @@ def step_main(): app.settings_dialog() root.after(1500, step_settings) - def step_settings(): - check("settings screenshot", - screenshot(root, os.path.join(out_dir, "settings.png"))) + def close_dialogs(): for w in root.winfo_children(): if isinstance(w, ctk.CTkToplevel): w.destroy() + + def step_settings(): + check("settings screenshot", + screenshot(root, os.path.join(out_dir, "settings.png"))) + close_dialogs() + # An entry dialog too: its header tile and the field boxes are what + # the 0.7.0 redesign changed most, and only a Linux run shows them. + app.account_dialog(app._selected_entry()) + root.after(1500, step_entry) + + def step_entry(): + check("entry dialog screenshot", + screenshot(root, os.path.join(out_dir, "entry.png"))) + close_dialogs() app._copy("hunter2-github.com", "Password copied", sensitive=True) check("password on the clipboard", clipboard.paste(root) == "hunter2-github.com") @@ -135,6 +147,7 @@ def run(): step_main = guarded(step_main) step_settings = guarded(step_settings) + step_entry = guarded(step_entry) root.after(2000, guarded(step_login)) root.after(30000, root.destroy) # never hang CI root.mainloop()