diff --git a/README.md b/README.md index 57574ab..8d73923 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,8 @@ Where that README names a Windows version, read the Linux one beside it: | 3.0.0-alpha.8 | 1.0.0-alpha.8 | security fix: signed vault keys (Server 2.0.0-alpha.5) | | 3.0.0 | 1.0.0 | SecretSpace 3, the first release: everything from the alpha rows (Server 2.0.0) | | 3.1.0-alpha.1, alpha.2 | 1.1.0-alpha.2 | the new look: light and dark, the Manrope typeface, vaults down the left of the main window | +| — | 1.1.0-alpha.3 | Linux only: the desktop is asked for light or dark once, not every 30 ms | +| 3.1.0-alpha.3 | 1.1.0-alpha.4 | icons drawn by the app, vault tiles in the Vaults window, no outlined cards | (0.2.0 added the `.deb` package, which has no Windows counterpart.) 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 3332407..c532b77 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 @@ -497,6 +500,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 @@ -675,6 +681,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)) @@ -777,6 +784,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 @@ -1034,12 +1058,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() @@ -1068,7 +1093,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") @@ -1090,25 +1116,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") @@ -1460,11 +1489,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 90038f2..7b08eb7 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 e21b97d..8856ca8 100644 --- a/sourceFiles/tests/test_look.py +++ b/sourceFiles/tests/test_look.py @@ -204,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 49285ba..1ff94ee 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -12,7 +12,7 @@ import re -__version__ = "1.1.0-alpha.3" +__version__ = "1.1.0-alpha.4" #: The container a 3.0 account's vaults are written in (account_crypto). VAULT_FORMAT = "PMV3"