From b86dfe864420dbd61a9a5271fcf5e1c0600451e4 Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Fri, 13 Nov 2026 09:58:42 +0200 Subject: [PATCH] The new look, step 3: icons, vault tiles, the backdrop follows the mode - icons.py: twelve line icons drawn with Pillow on a 24-unit grid, in the light and the dark text colour (no files, no licence to carry). - The side bar's rows, Sync, Lock, and an entry's Edit and Archive carry an icon beside their name. - The Vaults window shows each vault's tile; the last outlined cards (vault rows, conflict cards, archive rows, the phrase grid) lose their outline. - The login backdrop is repainted when the system switches between light and dark while the login screen shows. - Tests: every icon draws inside its square, in the colour asked for, cached, in both modes. The test window collects its Tk objects in the main thread when it closes (on Linux CI they were otherwise collected in a test server's thread, where each waits a second for a main loop). Version 3.1.0-alpha.3. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011ojnyVg7ccsR3pZ64s7aRH --- README.md | 1 + sourceFiles/dialogs.py | 3 +- sourceFiles/gui.py | 55 ++++++++--- sourceFiles/icons.py | 163 +++++++++++++++++++++++++++++++++ sourceFiles/sync_ui.py | 8 +- sourceFiles/tests/test_look.py | 50 ++++++++++ sourceFiles/vaults_ui.py | 21 +++-- sourceFiles/version.py | 2 +- 8 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 sourceFiles/icons.py diff --git a/README.md b/README.md index 56b4f91..2668feb 100644 --- a/README.md +++ b/README.md @@ -491,6 +491,7 @@ needs no update for this. one tile per account on this PC. Every dialog shares the look: a header band (with the tile of the entry or vault it is about), settings grouped in cards, and the buttons in a bar at the bottom +- Its own line icons, drawn by the app in the colour of the text - Light and dark: follows Windows, or choose one in Settings → *Appearance* *(3.1)* - Search with Ctrl+F diff --git a/sourceFiles/dialogs.py b/sourceFiles/dialogs.py index 65e706c..5a88b37 100644 --- a/sourceFiles/dialogs.py +++ b/sourceFiles/dialogs.py @@ -192,8 +192,7 @@ def _phrase_dialog(app, new, password): "them on paper, in order.")) wrap = shell.body - grid = ctk.CTkFrame(wrap, fg_color=CARD, corner_radius=16, - border_width=1, border_color=BORDER) + grid = ctk.CTkFrame(wrap, fg_color=CARD, corner_radius=16) grid.pack(fill="x", pady=(14, 0)) for c in range(3): grid.grid_columnconfigure(c, weight=1, uniform="word") diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index d757c67..18062e2 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -25,6 +25,7 @@ import changelog import dialogs import history_ui +from icons import icon import lockout import sync_client import sync_ui @@ -58,6 +59,7 @@ LOGIN_TILES_PER_ROW = 3 BACKDROP_REDRAW_MS = 60 # the login backdrop waits for a resize to settle +BACKDROP_MODE_MS = 1000 # how often the login screen looks for a mode change BACKDROP_OVERSAMPLE = 3 # painted this many times larger, then scaled down #: The vault filter's entry that shows every vault of the account. @@ -278,6 +280,7 @@ def __init__(self, root): self._idle_job = None self._lockout_job = None self._backdrop_job = None + self._mode_job = None self._last_activity = time.monotonic() self._autolock_seconds = appconfig.DEFAULT_AUTOLOCK_MINUTES * 60 self._syncing = False @@ -495,6 +498,9 @@ def _clear(self): if self._backdrop_job: self.root.after_cancel(self._backdrop_job) self._backdrop_job = None + if self._mode_job: + self.root.after_cancel(self._mode_job) + self._mode_job = None for w in self.root.winfo_children(): w.destroy() self._toast = None @@ -673,6 +679,7 @@ def show_login_screen(self): backdrop = tk.Canvas(self.root, bg=pick(BG), highlightthickness=0, bd=0) backdrop.place(relx=0, rely=0, relwidth=1, relheight=1) + self._watch_backdrop_mode(backdrop) backdrop.bind("", lambda e: self._backdrop_resized(backdrop, e.width, e.height)) @@ -775,6 +782,23 @@ def draw(): self._draw_backdrop(canvas, w, h) self._backdrop_job = self.root.after(BACKDROP_REDRAW_MS, draw) + def _watch_backdrop_mode(self, canvas, seen=None): + """ + The backdrop is a painting, not a widget, so it does not turn light + or dark by itself: look once a second while the login screen shows, + and paint it again when the mode has changed. + """ + self._mode_job = None + if not canvas.winfo_exists(): + return + now = ctk.get_appearance_mode() + if seen is not None and now != seen: + canvas.configure(bg=pick(BG)) + self._draw_backdrop(canvas, canvas.winfo_width(), + canvas.winfo_height()) + self._mode_job = self.root.after( + BACKDROP_MODE_MS, lambda: self._watch_backdrop_mode(canvas, now)) + def _draw_backdrop(self, canvas, w, h): """ The shapes behind the login card. A Tk canvas draws without @@ -1032,12 +1056,13 @@ def go(): # ================================================================== # Main window # ================================================================== - def _nav_button(self, parent, text, command): - """A quiet row of the side bar.""" + def _nav_button(self, parent, text, command, picture): + """A quiet row of the side bar: an icon and a name.""" return ctk.CTkButton( parent, text=text, command=command, height=38, corner_radius=12, fg_color="transparent", hover_color=CARD_HOVER, anchor="w", - text_color=MUTED, font=F(13, "bold")) + text_color=MUTED, font=F(13, "bold"), + image=icon(picture, 18, MUTED), compound="left") def show_main_window(self): self._clear() @@ -1066,7 +1091,8 @@ def show_main_window(self): # Which vault the list shows. New entries go to the one picked. self.vault_menu = VaultNav(side, self._on_vault_pick) self.vault_menu.frame.pack(fill="x") - manage = self._nav_button(side, "Manage vaults…", self.vaults_dialog) + manage = self._nav_button(side, "Manage vaults…", self.vaults_dialog, + "vaults") manage.pack(fill="x", pady=(6, 0)) Tooltip(manage, "Add, rename, share or delete vaults") @@ -1088,25 +1114,28 @@ def show_main_window(self): pair.pack(fill="x", padx=12, pady=(0, 12)) pair.grid_columnconfigure((0, 1), weight=1, uniform="pair") syn = self._small_button(pair, "Sync", self.sync_now) + syn.configure(image=icon("sync", 16), compound="left") syn.grid(row=0, column=0, sticky="ew", padx=(0, 4)) Tooltip(syn, "Sync this account with its server (Settings → Sync)") lock = self._small_button(pair, "Lock", self.lock_vault) + lock.configure(image=icon("lock", 16), compound="left") lock.grid(row=0, column=1, sticky="ew", padx=(4, 0)) Tooltip(lock, "Lock the account and sign out") - gear = self._nav_button(side, "Settings", self.settings_dialog) + gear = self._nav_button(side, "Settings", self.settings_dialog, + "settings") gear.pack(side="bottom", fill="x", pady=(0, 10)) - imp = self._nav_button(side, "Import", self.import_dialog) + imp = self._nav_button(side, "Import", self.import_dialog, "import") imp.pack(side="bottom", fill="x") Tooltip(imp, "Import a PassMan 2.x vault, or a CSV or JSON file") gen = self._nav_button(side, "Password generator", - self.generator_dialog) + self.generator_dialog, "key") gen.pack(side="bottom", fill="x") Tooltip(gen, "Make a strong password") # The archive: somewhere you go occasionally; its count tells you # there is something in it. self.archive_btn = self._nav_button(side, "Archive", - self.archive_dialog) + self.archive_dialog, "archive") self.archive_btn.pack(side="bottom", fill="x") Tooltip(self.archive_btn, "Entries you removed, kept so they can be " "restored") @@ -1458,11 +1487,15 @@ def _show_detail(self): actions = ctk.CTkFrame(band, fg_color="transparent") actions.grid(row=0, column=2, padx=(8, 22)) if writable: - self._ghost_button(actions, "Edit", lambda: self.account_dialog(acc), - width=74).pack(side="left", padx=(0, 8)) + edit = self._ghost_button(actions, "Edit", + lambda: self.account_dialog(acc), + width=92) + edit.configure(image=icon("edit", 16), compound="left") + edit.pack(side="left", padx=(0, 8)) arch = self._ghost_button(actions, "Archive", lambda: self._archive_account(acc), - width=88) + width=112) + arch.configure(image=icon("archive", 16), compound="left") arch.pack(side="left") Tooltip(arch, "Move to archive — restorable, and it still syncs " "the removal to your other PCs") diff --git a/sourceFiles/icons.py b/sourceFiles/icons.py new file mode 100644 index 0000000..af5371c --- /dev/null +++ b/sourceFiles/icons.py @@ -0,0 +1,163 @@ +""" +The app's icons, drawn here. + +A dozen line icons on a 24-unit grid, one stroke width, round ends. They are +drawn with Pillow instead of shipped as files, so there is one look, no +licence to carry, and each comes in the light and the dark text colour. +:func:`icon` gives a CustomTkinter image that follows the appearance mode. +""" + +import customtkinter as ctk +from PIL import Image, ImageDraw + +from theme import TEXT + +GRID = 24 # the units the shapes below are written in +STROKE = 1.8 # line width, in those units +OVERSAMPLE = 4 # drawn this much larger, then scaled down: smooth edges +HIDPI = 2 # pixels per point kept in the image, for scaled screens + + +class _Pen: + """Draws in grid units onto an oversampled image.""" + + def __init__(self, pixels, colour): + self.scale = pixels * OVERSAMPLE / GRID + self.colour = colour + self.width = max(1, round(STROKE * self.scale)) + self.image = Image.new("RGBA", (pixels * OVERSAMPLE,) * 2, (0, 0, 0, 0)) + self.draw = ImageDraw.Draw(self.image) + + def _p(self, x, y): + return (x * self.scale, y * self.scale) + + def _box(self, x0, y0, x1, y1): + return [x0 * self.scale, y0 * self.scale, x1 * self.scale, y1 * self.scale] + + def dot(self, x, y, r=STROKE / 2): + self.draw.ellipse(self._box(x - r, y - r, x + r, y + r), fill=self.colour) + + def line(self, *points, closed=False): + """A stroke through the points, with round ends and joints.""" + points = list(points) + ([points[0]] if closed else []) + self.draw.line([self._p(x, y) for x, y in points], fill=self.colour, + width=self.width, joint="curve") + for x, y in points: + self.dot(x, y) + + def circle(self, x, y, r): + self.draw.ellipse(self._box(x - r, y - r, x + r, y + r), + outline=self.colour, width=self.width) + + def arc(self, x0, y0, x1, y1, start, end): + """Angles as Pillow has them: clockwise from three o'clock.""" + self.draw.arc(self._box(x0, y0, x1, y1), start, end, fill=self.colour, + width=self.width) + + def box(self, x0, y0, x1, y1, r=2): + self.draw.rounded_rectangle(self._box(x0, y0, x1, y1), + radius=r * self.scale, + outline=self.colour, width=self.width) + + +def _plus(p): + p.line((12, 5), (12, 19)) + p.line((5, 12), (19, 12)) + + +def _vaults(p): + p.line((12, 3), (3, 8), (12, 13), (21, 8), closed=True) + p.line((3, 13), (12, 18), (21, 13)) + + +def _archive(p): + p.box(3, 4, 21, 9, r=1.5) + p.line((5, 9), (5, 20), (19, 20), (19, 9)) + p.line((10, 13.5), (14, 13.5)) + + +def _key(p): + p.circle(8, 16, 4.2) + p.line((11, 13), (20, 4)) + p.line((16.5, 7.5), (19, 10)) + p.line((19, 5), (21, 7)) + + +def _import(p): + p.line((12, 3.5), (12, 15)) + p.line((7, 10), (12, 15), (17, 10)) + p.line((4, 20), (20, 20)) + + +def _settings(p): + p.line((4, 7), (12.5, 7)) + p.line((19.5, 7), (20, 7)) + p.circle(16, 7, 2.6) + p.line((4, 17), (4.5, 17)) + p.line((11.5, 17), (20, 17)) + p.circle(8, 17, 2.6) + + +def _sync(p): + p.arc(4, 4, 20, 20, 195, 345) + p.line((20, 4.5), (20, 10), (14.5, 10)) + p.arc(4, 4, 20, 20, 15, 165) + p.line((4, 19.5), (4, 14), (9.5, 14)) + + +def _lock(p): + p.box(5, 11, 19, 20.5, r=2.2) + p.arc(8, 3.5, 16, 12.5, 180, 360) + p.line((8, 8), (8, 11)) + p.line((16, 8), (16, 11)) + + +def _copy(p): + p.box(9, 9, 20, 20, r=2.2) + p.line((5, 15), (5, 5), (15, 5)) + + +def _edit(p): + p.line((4, 20), (5, 16), (16, 5), (19, 8), (8, 19), closed=True) + + +def _people(p): + p.circle(9, 8, 3.5) + p.arc(2.5, 14.5, 15.5, 27.5, 180, 360) + p.arc(13, 4.5, 20, 11.5, 270, 90) + p.arc(11.5, 14.8, 21.5, 27, 290, 360) + + +def _activity(p): + p.line((3, 12), (7, 12), (10, 4.5), (14, 19.5), (17, 12), (21, 12)) + + +SHAPES = { + "plus": _plus, "vaults": _vaults, "archive": _archive, "key": _key, + "import": _import, "settings": _settings, "sync": _sync, "lock": _lock, + "copy": _copy, "edit": _edit, "people": _people, "activity": _activity, +} + + +def picture(name, colour, pixels): + """One icon as a Pillow image of ``pixels`` square, in ``colour``.""" + pen = _Pen(pixels, colour) + SHAPES[name](pen) + return pen.image.resize((pixels, pixels), Image.LANCZOS) + + +_cache = {} + + +def icon(name, size=18, colour=TEXT): + """ + The icon as an image for a CustomTkinter widget: ``size`` points square, + in the pair's light or dark colour, whichever mode is in use. + """ + key = (name, size, tuple(colour)) + if key not in _cache: + light, dark = colour + _cache[key] = ctk.CTkImage( + light_image=picture(name, light, size * HIDPI), + dark_image=picture(name, dark, size * HIDPI), size=(size, size)) + return _cache[key] diff --git a/sourceFiles/sync_ui.py b/sourceFiles/sync_ui.py index 7899e9e..62e38b8 100644 --- a/sourceFiles/sync_ui.py +++ b/sourceFiles/sync_ui.py @@ -20,7 +20,7 @@ import sync_client import vault from history_ui import local_time -from theme import (F, TEXT, MUTED, GREEN, DANGER, INPUT_BG, BORDER, CARD, +from theme import (F, TEXT, MUTED, GREEN, DANGER, INPUT_BG, 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) @@ -300,8 +300,7 @@ def _conflict_row(parent, index, conflict, answers): if index == 0: _secret_labels.clear() - card = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=16, - border_width=1, border_color=BORDER) + card = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=16) card.grid(row=index + 1, column=0, sticky="ew", pady=6, padx=2) card.grid_columnconfigure((0, 1), weight=1, uniform="sides") @@ -541,8 +540,7 @@ def rebuild(): def _archive_row(app, parent, index, entry, rebuild): """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 = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=14) row.grid(row=index, column=0, sticky="ew", pady=(10 if index == 0 else 4, 4), padx=2) row.grid_columnconfigure(1, weight=1) diff --git a/sourceFiles/tests/test_look.py b/sourceFiles/tests/test_look.py index 25037fd..8856ca8 100644 --- a/sourceFiles/tests/test_look.py +++ b/sourceFiles/tests/test_look.py @@ -153,6 +153,12 @@ def window(): root.withdraw() yield root root.destroy() + # Collect what the window left behind here, in the thread Tk lives in. + # Fonts and images delete themselves through Tk; collected later from + # another thread (a test server's, say) each waits a second for a main + # loop that no test runs, and the request being served times out. + import gc + gc.collect() @pytest.fixture @@ -198,3 +204,47 @@ def test_strength_colours_show_on_a_card_in_both_modes(name, i): """A meter and its word, not body text: 3 to 1 is the bar for those.""" assert _contrast(theme.strength_color(name)[i], theme.CARD[i]) >= 3 assert theme.strength_color("") == theme.MUTED + + +# ---- icons ------------------------------------------------------------------- +import icons # noqa: E402 + + +@pytest.mark.parametrize("name", sorted(icons.SHAPES)) +def test_every_icon_draws_something_inside_its_square(name): + picture = icons.picture(name, "#16181D", 36) + assert picture.size == (36, 36) and picture.mode == "RGBA" + alpha = picture.getchannel("A") + box = alpha.getbbox() + assert box is not None # not empty + assert box[2] - box[0] >= 14 and box[3] - box[1] >= 10 # not a speck + edge = [alpha.getpixel((x, y)) for x in range(36) for y in (0, 35)] + edge += [alpha.getpixel((x, y)) for y in range(36) for x in (0, 35)] + assert max(edge) < 64 # not cut off + + +def test_an_icon_is_drawn_in_the_colour_asked_for(): + picture = icons.picture("lock", "#FF0000", 36) + r, g, b, alpha = picture.split() + assert alpha.getextrema()[1] == 255 # something solid + # Red is full and the rest is nothing wherever the stroke is solid. + solid = alpha.point(lambda v: 255 if v == 255 else 0) + for channel, wanted in ((r, 255), (g, 0), (b, 0)): + seen = {channel.getpixel(xy) for xy in + ((x, y) for x in range(36) for y in range(36)) + if solid.getpixel(xy)} + assert seen == {wanted} + + +def test_icons_follow_the_mode_and_are_made_once(window): + one = icons.icon("sync", 16) + assert one is icons.icon("sync", 16) # cached + assert one is not icons.icon("sync", 18) + light, dark = one.cget("light_image"), one.cget("dark_image") + assert light.size == dark.size == (32, 32) # kept at 2x + assert light.tobytes() != dark.tobytes() + + +def test_an_unknown_icon_is_an_error(): + with pytest.raises(KeyError): + icons.picture("no-such-icon", "#000000", 24) diff --git a/sourceFiles/vaults_ui.py b/sourceFiles/vaults_ui.py index cbcc24f..ab865e5 100644 --- a/sourceFiles/vaults_ui.py +++ b/sourceFiles/vaults_ui.py @@ -17,7 +17,7 @@ import accounts import sharing_ui from sync_ui import _in_background -from theme import F, TEXT, MUTED, CARD, BORDER, WARN +from theme import F, TEXT, MUTED, CARD, WARN, tile_colors from widgets import (dialog_shell, ghost_button, accent_button, small_button, field, error_label) @@ -105,23 +105,28 @@ def _vault_row(app, parent, index, vault_id, changed): name = app.vault_name(vault_id) record = account.vault_record(vault_id) - row = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=14, - border_width=1, border_color=BORDER) + row = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=14) row.grid(row=index, column=0, sticky="ew", padx=2, pady=(10 if index == 0 else 4, 4)) - row.grid_columnconfigure(0, weight=1) - + row.grid_columnconfigure(1, weight=1) + + # The vault's tile, as in the list at the left of the main window. + tile_bg, tile_ink, _ = tile_colors(name) + ctk.CTkLabel(row, text=(name[:1].upper() or "?"), width=40, height=40, + corner_radius=12, fg_color=tile_bg, text_color=tile_ink, + font=F(15, "bold")).grid(row=0, column=0, padx=(14, 0), + pady=12) info = ctk.CTkFrame(row, fg_color="transparent") - info.grid(row=0, column=0, sticky="w", padx=(16, 8), pady=12) + info.grid(row=0, column=1, sticky="w", padx=(12, 8), pady=12) ctk.CTkLabel(info, text=name, anchor="w", font=F(14, "bold"), text_color=TEXT).pack(anchor="w") text, colour = _status(app, vault_id) ctk.CTkLabel(info, text=f"{_count(app.vaults.get(vault_id, {}))} · {text}", - anchor="w", justify="left", wraplength=290, font=F(12), + anchor="w", justify="left", wraplength=240, font=F(12), text_color=colour).pack(anchor="w") side = ctk.CTkFrame(row, fg_color="transparent") - side.grid(row=0, column=1, padx=(0, 14), pady=8) + side.grid(row=0, column=2, padx=(0, 14), pady=8) upper = ctk.CTkFrame(side, fg_color="transparent") upper.pack(anchor="e") owner = account.vault_role(vault_id) == accounts.ROLE_OWNER diff --git a/sourceFiles/version.py b/sourceFiles/version.py index dcc8783..47031d8 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -12,7 +12,7 @@ import re -__version__ = "3.1.0-alpha.2" +__version__ = "3.1.0-alpha.3" #: The container a 3.0 account's vaults are written in (account_crypto). VAULT_FORMAT = "PMV3"