Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ package replaces it in one step (`Conflicts`/`Replaces: passman`), and your
vaults stay in `~/.local/share/passman`, which SecretSpace keeps using —
nothing is moved or renamed. The vault files are the same.

**Version 0.10.0 — early.** It installs from a `.deb` on Ubuntu 22.04 or
**Version 0.11.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.

Expand Down Expand Up @@ -149,6 +149,7 @@ Where that README names a Windows version, read the Linux one beside it:
| 2.10.0 | 0.8.0 | 2FA codes, behind the master password |
| 2.11.0 | 0.9.0 | sync on unlock, a setting that is off by default |
| 2.12.0 | 0.10.0 | renamed to SecretSpace (package `secretspace`, replaces `passman`) |
| 2.13.0 | 0.11.0 | a smoother look: smoothed corners, redrawn sign-in backdrop |

(0.2.0 added the `.deb` package, which has no Windows counterpart.)

Expand Down
112 changes: 107 additions & 5 deletions sourceFiles/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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
Expand All @@ -48,6 +48,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",
Expand All @@ -69,6 +72,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 ""
Expand Down Expand Up @@ -112,6 +175,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
Expand Down Expand Up @@ -207,6 +271,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
Expand Down Expand Up @@ -347,7 +414,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("<Configure>",
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)
Expand Down Expand Up @@ -424,8 +492,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="")
Expand Down Expand Up @@ -740,10 +840,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))

Expand Down Expand Up @@ -970,7 +1072,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,
Expand Down
59 changes: 59 additions & 0 deletions sourceFiles/tests/test_look.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 4 additions & 6 deletions sourceFiles/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("green")

# CustomTkinter's default on Windows and Linux 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 0.11.0 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
Expand Down
2 changes: 1 addition & 1 deletion sourceFiles/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
format changes.
"""

__version__ = "0.10.0"
__version__ = "0.11.0"

#: Container version SecretSpace writes today (see crypto_utils.MAGIC).
VAULT_FORMAT = "PMV2"
Expand Down
23 changes: 23 additions & 0 deletions sourceFiles/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<Configure>", 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(
Expand Down
Loading