From bfd3fa2aab1c7161219f3b05ba8c6f5d7665739f Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Tue, 10 Nov 2026 22:03:22 +0200 Subject: [PATCH] A smoother look: smoothed corners, a redrawn sign-in backdrop (3.0.0-alpha.4) The owner still saw sharp edges. Three causes, all fixed: - theme.py forced CustomTkinter to draw rounded corners as polygons, which are not smoothed at all. It now draws them its preferred way (smoothed font glyphs). Cost, measured: about 7 ms more per list row. - The sign-in backdrop (discs, padlock, key) was drawn straight on a Tk canvas: no smoothing, and the padlock was a plain square. It is now painted larger with Pillow and scaled down; the padlock has a rounded body and a keyhole. The plain canvas drawing stays as the fallback. - The coloured header of an entry shrank to its tile's height (pack_propagate on a frame laid out with grid), so the tile touched the header's edges. It keeps its 112 px now. Also: the wide Add button is kept at an even width (smoothed corners step by one pixel at odd widths). tests/test_look.py guards the first two; it fails when polygons are forced back, the padlock is squared, or the smoothing is removed. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011ojnyVg7ccsR3pZ64s7aRH --- README.md | 7 ++- sourceFiles/gui.py | 112 +++++++++++++++++++++++++++++++-- sourceFiles/tests/test_look.py | 59 +++++++++++++++++ sourceFiles/theme.py | 10 ++- sourceFiles/version.py | 2 +- sourceFiles/widgets.py | 23 +++++++ 6 files changed, 199 insertions(+), 14 deletions(-) create mode 100644 sourceFiles/tests/test_look.py diff --git a/README.md b/README.md index a47bc8f..ca309a3 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ SecretSpace lives in four repositories: ## SecretSpace 3 — a test version, not for real passwords yet -`main` is now **3.0.0-alpha.3**, a test step of SecretSpace 3. **For your real +`main` is now **3.0.0-alpha.4**, a test step of SecretSpace 3. **For your real passwords keep using [2.12.0](https://github.com/usermaneGermane/SecretSpace-Client-Windows/releases/tag/v2.12.0)**, which the rest of this README describes. Do not install the test version over it: it opens SecretSpace 3 accounts only, so your 2.x vaults would not @@ -50,6 +50,9 @@ What the test version has: - **Importing a 2.x vault.** *Import* lists the 2.x vaults on this PC (or takes a `.vault.enc` file) and copies every entry with its history and archive state. The 2.x file is only read; its sync settings stay with it. +- **A smoother look** (new in alpha.4). Rounded corners are drawn smoothed + instead of in pixel steps, the sign-in screen's padlock and key are + redrawn, and an entry's coloured header has room around its tile. - **Sync through your own server** (new in alpha.3). *Settings → Sync → Set up…* puts the account on a SecretSpace Server **2.0** (a test version too) with the server's token as the sign-up code; after that the **Sync** @@ -67,7 +70,7 @@ Not in this test version yet: more than one vault per account, recovering an account with the 12-word phrase (the phrase is made and checked now; the screen that uses it comes later), sharing a vault with other people, and *Sync when I unlock* for accounts. SecretSpace for Linux -has the same test version, 1.0.0-alpha.3. Coming next, in this order: +has the same test version, 1.0.0-alpha.4. Coming next, in this order: several vaults per account, sharing a vault with family or colleagues, and recovering an account with the 12-word phrase. diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index 1eb9c19..fe9454a 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -31,7 +31,7 @@ BORDER, TEXT, MUTED, GREEN, GREEN_HOVER, DANGER, ON_ACCENT, WARN, MONO, tile_colors, asset_candidates) -from widgets import (Tooltip, ghost_button, accent_button, field, +from widgets import (Tooltip, even_width, ghost_button, accent_button, field, strength_widget, modal, fit_modal, safe_grab, confirm, STRENGTH_SEGMENTS) from password_generator import estimate_strength @@ -50,6 +50,9 @@ LOGIN_TILES_PER_ROW = 3 +BACKDROP_REDRAW_MS = 60 # the login backdrop waits for a resize to settle +BACKDROP_OVERSAMPLE = 3 # painted this many times larger, then scaled down + #: What the Sort menu shows for each of vault.SORT_ORDERS. SORT_LABELS = { "modified_desc": "Recently modified", @@ -71,6 +74,66 @@ def _row_signature(acc): tuple(r.get("rid") for r in acc.get("history") or [])) +def backdrop_picture(w, h): + """ + The login backdrop: two soft discs, a padlock, a key and a few dots, + with smooth edges. None when the window has no size yet. + """ + from PIL import Image, ImageDraw + if w < 2 or h < 2: + return None + k = BACKDROP_OVERSAMPLE + img = Image.new("RGB", (w * k, h * k), BG) + d = ImageDraw.Draw(img) + + def box(x0, y0, x1, y1): + return [round(x0 * k), round(y0 * k), round(x1 * k), round(y1 * k)] + + def dot(x, y, r, fill): + d.ellipse(box(x - r, y - r, x + r, y + r), fill=fill) + + def stroke(points, fill, width=4): + """A line with round ends and joints.""" + d.line([(round(x * k), round(y * k)) for x, y in points], fill=fill, + width=width * k, joint="curve") + for x, y in (points[0], points[-1]): + dot(x, y, width / 2, fill) + + r1, r2 = max(w, h) * 0.17, max(w, h) * 0.21 + dot(w * 0.1, h * 0.16, r1, "#0F2117") + dot(w * 0.93, h * 0.9, r2, "#101A14") + + # A padlock, top right: a rounded body, a shackle, a keyhole. + x, y = w * 0.86, h * 0.2 + d.arc(box(x - 17, y - 32, x + 17, y + 2), start=180, end=360, fill=GREEN, + width=4 * k) + stroke([(x - 15, y - 15), (x - 15, y - 4)], GREEN) + stroke([(x + 15, y - 15), (x + 15, y - 4)], GREEN) + d.rounded_rectangle(box(x - 26, y - 6, x + 26, y + 34), radius=9 * k, + outline=GREEN, width=4 * k) + dot(x, y + 11, 4.5, GREEN) + stroke([(x, y + 13), (x, y + 21)], GREEN) + + # A key, bottom left: a ring, a shaft, two teeth. + x, y = w * 0.15, h * 0.8 + ink = "#7BE0A6" + d.ellipse(box(x - 40, y - 18, x - 4, y + 18), outline=ink, width=4 * k) + stroke([(x - 5, y), (x + 44, y)], ink) + stroke([(x + 30, y), (x + 30, y + 15)], ink) + stroke([(x + 42, y), (x + 42, y + 11)], ink) + + for fx, fy in ((0.3, 0.1), (0.7, 0.92), (0.96, 0.48), (0.05, 0.55)): + dot(w * fx, h * fy, 4, "#1F3A2A") + return img.resize((w, h), Image.LANCZOS) + + +def backdrop_image(w, h): + """:func:`backdrop_picture` as an image a Tk canvas can show.""" + from PIL import ImageTk + picture = backdrop_picture(w, h) + return None if picture is None else ImageTk.PhotoImage(picture) + + def _clip(text, limit): """Shorten ``text`` for a fixed-width spot; Tk labels never truncate.""" text = text or "" @@ -116,6 +179,7 @@ def __init__(self, root): self._clip_text = None self._idle_job = None self._lockout_job = None + self._backdrop_job = None self._last_activity = time.monotonic() self._autolock_seconds = appconfig.DEFAULT_AUTOLOCK_MINUTES * 60 self._syncing = False @@ -209,6 +273,9 @@ def _clear(self): if self._search_job: self.root.after_cancel(self._search_job) self._search_job = None + if self._backdrop_job: + self.root.after_cancel(self._backdrop_job) + self._backdrop_job = None for w in self.root.winfo_children(): w.destroy() self._toast = None @@ -348,7 +415,8 @@ def show_login_screen(self): 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)) + lambda e: self._backdrop_resized(backdrop, e.width, + e.height)) card = ctk.CTkFrame(backdrop, fg_color=CARD, corner_radius=24, bg_color=BG, border_width=1, border_color=BORDER) @@ -427,8 +495,40 @@ def toggle_pw(): self._begin_lockout(self._login_user, lockout.remaining(self._login_user)) + def _backdrop_resized(self, canvas, w, h): + """Redraw once the window stops changing size, not on every step.""" + if self._backdrop_job: + self.root.after_cancel(self._backdrop_job) + self._backdrop_job = None + if not canvas.find_all(): + self._draw_backdrop(canvas, w, h) + return + + def draw(): + self._backdrop_job = None + if canvas.winfo_exists(): + self._draw_backdrop(canvas, w, h) + self._backdrop_job = self.root.after(BACKDROP_REDRAW_MS, draw) + def _draw_backdrop(self, canvas, w, h): + """ + The shapes behind the login card. A Tk canvas draws without + smoothing, so they are painted larger with Pillow and scaled down; + without Pillow the plain canvas shapes still do. + """ canvas.delete("all") + try: + image = backdrop_image(w, h) + except Exception: + image = None + if image is None: + self._draw_backdrop_plain(canvas, w, h) + return + canvas.image = image # Tk keeps no reference of its own + canvas.create_image(0, 0, anchor="nw", image=image) + + @staticmethod + def _draw_backdrop_plain(canvas, w, h): 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="") @@ -760,10 +860,12 @@ def show_main_window(self): 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", + add_row = ctk.CTkFrame(left, fg_color="transparent", height=48) + add_row.grid(row=2, column=0, sticky="ew", pady=(12, 0)) + add = self._accent_button(add_row, "+ 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)) + even_width(add, add_row) ctk.CTkLabel(left, text=version_string(), font=F(11), text_color=MUTED).grid(row=3, column=0, pady=(8, 0)) @@ -990,7 +1092,7 @@ def _show_detail(self): 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_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, diff --git a/sourceFiles/tests/test_look.py b/sourceFiles/tests/test_look.py new file mode 100644 index 0000000..2cfa156 --- /dev/null +++ b/sourceFiles/tests/test_look.py @@ -0,0 +1,59 @@ +""" +The look: smoothed corners and the painted login backdrop. + +These guard the two things that made edges look rough: corners forced to +unsmoothed polygons, and backdrop shapes drawn straight on a Tk canvas. +""" +import pytest + +pytest.importorskip("customtkinter") +pytest.importorskip("PIL") + +import gui +import theme # noqa: F401 (importing it applies the drawing settings) +from theme import BG, GREEN + + +def _rgb(colour): + return tuple(int(colour[i:i + 2], 16) for i in (1, 3, 5)) + + +def _near(pixel, colour, slack=8): + """Scaling down overshoots a pure colour by a shade or two.""" + return all(abs(a - b) <= slack for a, b in zip(pixel, _rgb(colour))) + + +def test_corners_are_not_forced_to_polygons(): + from customtkinter.windows.widgets.core_rendering import DrawEngine + assert DrawEngine.preferred_drawing_method != "polygon_shapes" + + +def test_backdrop_has_the_size_asked_for(): + assert gui.backdrop_picture(900, 600).size == (900, 600) + + +@pytest.mark.parametrize("size", [(0, 0), (1, 1), (1, 400), (400, 1)]) +def test_no_backdrop_before_the_window_has_a_size(size): + assert gui.backdrop_picture(*size) is None + + +def test_backdrop_edges_are_smoothed(): + """Between the padlock's green and the background lie blended pixels.""" + w, h = 900, 600 + picture = gui.backdrop_picture(w, h) + x, y = round(w * 0.86), round(h * 0.2) + lock = picture.crop((x - 34, y - 40, x + 34, y + 42)) + colours = {c for _, c in lock.getcolors(maxcolors=100000)} + assert any(_near(c, GREEN) for c in colours) + assert any(_near(c, BG) for c in colours) + blends = [c for c in colours if not _near(c, GREEN) and not _near(c, BG)] + assert len(blends) > 20 + + +def test_padlock_body_has_round_corners(): + """The very corner of the body's box is background, not outline.""" + w, h = 900, 600 + picture = gui.backdrop_picture(w, h) + x, y = round(w * 0.86), round(h * 0.2) + assert _near(picture.getpixel((x - 26, y - 6)), BG) + assert _near(picture.getpixel((x, y - 4)), GREEN) # top edge, middle diff --git a/sourceFiles/theme.py b/sourceFiles/theme.py index 9f11cc5..9d46392 100644 --- a/sourceFiles/theme.py +++ b/sourceFiles/theme.py @@ -13,12 +13,10 @@ ctk.set_appearance_mode("dark") ctk.set_default_color_theme("green") -# CustomTkinter's Windows default draws rounded corners out of font glyphs, -# which costs ~60 ms per account row. Polygons -- its macOS default -- halve -# that, at the price of slightly less smoothed corners. Must be set before -# any widget is created. -from customtkinter.windows.widgets.core_rendering import DrawEngine -DrawEngine.preferred_drawing_method = "polygon_shapes" +# Rounded corners are drawn the way CustomTkinter prefers here: out of +# smoothed font glyphs. Until 3.0.0-alpha.4 polygons were forced instead, +# which are quicker (about 7 ms per account row, measured on CustomTkinter +# 6.0) but not smoothed at all -- every corner showed its pixel steps. BG = "#0A0C0B" # the window itself HEADER_BG = "#101311" # top bar, history panel diff --git a/sourceFiles/version.py b/sourceFiles/version.py index 4e0d14e..37159f6 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -12,7 +12,7 @@ import re -__version__ = "3.0.0-alpha.3" +__version__ = "3.0.0-alpha.4" #: The container a 3.0 account's vaults are written in (account_crypto). VAULT_FORMAT = "PMV3" diff --git a/sourceFiles/widgets.py b/sourceFiles/widgets.py index 09bd5d4..223a6c9 100644 --- a/sourceFiles/widgets.py +++ b/sourceFiles/widgets.py @@ -85,6 +85,29 @@ def danger_button(parent, text, command, width=130): ) +def even_width(widget, holder): + """ + Fill ``holder`` (a frame of the widget's height, with nothing else in + it) with ``widget``, an even number of pixels wide. Smoothed corners are + drawn for even sizes only; at an odd width a bright button shows a + one-pixel step down its right edge. + + The widget is placed, not packed: its width must not feed back into + the layout that decides the holder's. + """ + def fit(event): + try: + # winfo sizes are physical pixels; CTk wants its own points. + scale = widget._get_widget_scaling() + except Exception: + scale = 1 + want = (event.width // 2 * 2) / scale + if abs(widget.cget("width") - want) > 0.01: + widget.configure(width=want) + widget.place(x=0, y=0) + holder.bind("", fit, add="+") + + 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(