diff --git a/README.md b/README.md index 845a43f..0bc779b 100644 --- a/README.md +++ b/README.md @@ -213,8 +213,12 @@ with the Secret Key from your Emergency Kit. copied passwords are cleared after 30 seconds — but a clipboard manager (GNOME or KDE clipboard history, CopyQ, …) may keep its own copy, which no application can reach. -- **Fonts.** DejaVu Sans, which Ubuntu ships; elsewhere fontconfig picks the - nearest. +- **Fonts.** From 1.1 the app brings its own typeface, Manrope (SIL Open + Font License); it is copied to `~/.fonts` at the first start, which is + how the toolkit makes a font known. Codes and fingerprints use DejaVu + Sans Mono, which Ubuntu ships. +- **Light and dark** *(1.1)* follow the desktop where it reports one; + *Settings → Appearance* chooses one either way. - **A package instead of an installer.** The `.deb` does what the Windows installer does: a menu entry, data in your home folder, removal that keeps the vaults. Unlike the Windows installer it does not offer to move a vault @@ -249,6 +253,7 @@ Where that README names a Windows version, read the Linux one beside it: | 3.0.0-alpha.7 | 1.0.0-alpha.7 | recovering an account with the 12-word phrase, a new phrase (Server 2.0.0-alpha.4) | | 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 | (0.2.0 added the `.deb` package, which has no Windows counterpart.) diff --git a/deb/build_deb.py b/deb/build_deb.py index 7672b67..91a45e5 100644 --- a/deb/build_deb.py +++ b/deb/build_deb.py @@ -64,7 +64,8 @@ ] #: Files of sourceFiles/ that go into the package; the tests stay behind. -SOURCE_PATTERNS = (".py", ".png") +#: The typeface (Manrope) travels with the program, licence beside it. +SOURCE_PATTERNS = (".py", ".png", ".ttf", "-OFL.txt") LAUNCHER = f"""#!/bin/sh # -I: the user's PYTHONPATH and ~/.local packages must not replace the @@ -173,8 +174,16 @@ def copyright_text(pins): Files: * Copyright: usermaneGermane -License: none - No licence has been granted yet. +License: GPL-3.0-only + SecretSpace is free software under the GNU General Public License, + version 3 only. On Debian systems its text is in + /usr/share/common-licenses/GPL-3. + +Files: usr/lib/secretspace/sourceFiles/Manrope-*.ttf +Copyright: 2018 The Manrope Project Authors +License: OFL-1.1 + The SIL Open Font License, version 1.1; its text is in + /usr/lib/secretspace/sourceFiles/Manrope-OFL.txt. Files: usr/lib/secretspace/lib/* Comment: bundled Python libraries; each one's licence is in its diff --git a/deb/tests/test_build_deb.py b/deb/tests/test_build_deb.py index f6d1e8a..3f1f422 100644 --- a/deb/tests/test_build_deb.py +++ b/deb/tests/test_build_deb.py @@ -117,6 +117,15 @@ def test_maintainer_scripts_compile_and_clean(tree): assert "py3clean -p secretspace" in read(tree, "DEBIAN", "prerm") +def test_the_typeface_travels_with_its_licence(tree): + src = os.path.join(tree, "usr", "lib", "secretspace", "sourceFiles") + assert {"Manrope-Regular.ttf", "Manrope-Bold.ttf", + "Manrope-OFL.txt"} <= set(os.listdir(src)) + text = read(tree, "usr", "share", "doc", "secretspace", "copyright") + assert "OFL-1.1" in text and "GPL-3.0-only" in text + assert "No licence has been granted" not in text + + def test_copyright_lists_bundled_libraries(tree): assert "fakelib 1.0" in read(tree, "usr", "share", "doc", "secretspace", "copyright") diff --git a/sourceFiles/Manrope-Bold.ttf b/sourceFiles/Manrope-Bold.ttf new file mode 100644 index 0000000..674c865 Binary files /dev/null and b/sourceFiles/Manrope-Bold.ttf differ diff --git a/sourceFiles/Manrope-OFL.txt b/sourceFiles/Manrope-OFL.txt new file mode 100644 index 0000000..e271172 --- /dev/null +++ b/sourceFiles/Manrope-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/sourceFiles/Manrope-Regular.ttf b/sourceFiles/Manrope-Regular.ttf new file mode 100644 index 0000000..b70c078 Binary files /dev/null and b/sourceFiles/Manrope-Regular.ttf differ diff --git a/sourceFiles/appconfig.py b/sourceFiles/appconfig.py index e84afa7..1001f92 100644 --- a/sourceFiles/appconfig.py +++ b/sourceFiles/appconfig.py @@ -52,6 +52,12 @@ def autolock_minutes() -> int: return value if 0 <= value <= 24 * 60 else DEFAULT_AUTOLOCK_MINUTES +def appearance() -> str: + """Light, dark or what the system uses; see theme.APPEARANCES.""" + value = load_config().get("appearance") + return value if value in ("system", "light", "dark") else "system" + + def sort_order() -> str: """How the account list is ordered; see vault.SORT_ORDERS.""" value = load_config().get("sort_order") diff --git a/sourceFiles/dialogs.py b/sourceFiles/dialogs.py index 88dfac8..65e706c 100644 --- a/sourceFiles/dialogs.py +++ b/sourceFiles/dialogs.py @@ -27,7 +27,8 @@ import totp import vault from theme import (F, TEXT, MUTED, GREEN, GREEN_HOVER, INPUT_BG, BORDER, - CARD, CARD_HOVER, ON_ACCENT, MONO, DANGER, tile_colors) + CARD, CARD_HOVER, ON_ACCENT, MONO, DANGER, tile_colors, + apply_appearance) from vault import (add_account, validate_username, export_csv, export_json, update_entry, find_duplicate) from widgets import (Tooltip, dialog_shell, section, setting_row, option_menu, note, @@ -743,6 +744,23 @@ def set_autolock(choice): lock_menu.pack() lock_menu.set(label_for(appconfig.autolock_minutes())) + # ---- look ---- + look = section(wrap, "Appearance") + looks = {"system": "Same as the system", "light": "Light", "dark": "Dark"} + + def set_look(choice): + mode = next(k for k, v in looks.items() if v == choice) + appconfig.save_config(appearance=mode) + apply_appearance(mode) + + look_menu = option_menu( + setting_row(look, "Light or dark", + "Follows your system unless you choose one here.", hint, + first=True), + list(looks.values()), set_look, width=180) + look_menu.pack() + look_menu.set(looks[appconfig.appearance()]) + # ---- sync ---- syn = section(wrap, "Sync") server = app.account.server diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index 8d52b9a..3332407 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -32,11 +32,13 @@ import vaults_ui import recovery_ui import totp -from theme import (F, BG, HEADER_BG, CARD, CARD_HOVER, SELECTED, INPUT_BG, +from theme import (F, BG, CARD, CARD_HOVER, SELECTED, INPUT_BG, BORDER, TEXT, MUTED, GREEN, GREEN_HOVER, DANGER, - ON_ACCENT, WARN, MONO, + ON_ACCENT, WARN, MONO, DECOR_DISC_A, DECOR_DISC_B, + DECOR_DOT, DECOR_INK, pick, apply_appearance, + strength_color, tile_colors, asset_candidates) -from widgets import (Tooltip, even_width, ghost_button, accent_button, field, +from widgets import (Tooltip, ghost_button, accent_button, field, strength_widget, modal, fit_modal, safe_grab, confirm, STRENGTH_SEGMENTS) from password_generator import estimate_strength @@ -92,7 +94,8 @@ def backdrop_picture(w, h): if w < 2 or h < 2: return None k = BACKDROP_OVERSAMPLE - img = Image.new("RGB", (w * k, h * k), BG) + img = Image.new("RGB", (w * k, h * k), pick(BG)) + green = pick(GREEN) d = ImageDraw.Draw(img) def box(x0, y0, x1, y1): @@ -109,30 +112,30 @@ def stroke(points, fill, width=4): 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") + dot(w * 0.1, h * 0.16, r1, pick(DECOR_DISC_A)) + dot(w * 0.93, h * 0.9, r2, pick(DECOR_DISC_B)) # 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, + 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) + 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) + 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" + ink = pick(DECOR_INK) 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") + dot(w * fx, h * fy, 4, pick(DECOR_DOT)) return img.resize((w, h), Image.LANCZOS) @@ -158,12 +161,91 @@ def _initials(name): return (name[:2] or "?").upper() +class VaultNav: + """ + The vaults down the left side of the main window: one row each, with its + tile, its name and how many entries it has. It answers to the few calls + an option menu takes (``configure(values=)``, ``cget("values")``, + ``get``, ``set``), which is what the rest of the window speaks. + """ + + def __init__(self, parent, command): + self.frame = ctk.CTkFrame(parent, fg_color="transparent") + self.frame.grid_columnconfigure(0, weight=1) + self._command = command + self._values, self._notes, self._current = [], {}, None + self._rows = {} + + def winfo_exists(self): + return self.frame.winfo_exists() + + def cget(self, key): + if key != "values": + raise ValueError(key) + return list(self._values) + + def get(self): + return self._current + + def configure(self, values=None, notes=None): + """``notes``: what stands at the right of each row (a count).""" + values = self._values if values is None else list(values) + notes = self._notes if notes is None else dict(notes) + if values == self._values and notes == self._notes and self._rows: + return + self._values, self._notes = values, notes + for child in self.frame.winfo_children(): + child.destroy() + self._rows = {} + for i, label in enumerate(values): + self._rows[label] = self._row(i, label) + self._paint() + + def set(self, label): + self._current = label + self._paint() + + def _dropdown_callback(self, label): + self.set(label) + self._command(label) + + def _paint(self): + for label, row in self._rows.items(): + row.configure(fg_color=CARD if label == self._current + else "transparent") + + def _row(self, index, label): + row = ctk.CTkFrame(self.frame, fg_color="transparent", + corner_radius=12, height=44) + row.grid(row=index, column=0, sticky="ew", pady=2) + row.grid_columnconfigure(1, weight=1) + if label == ALL_VAULTS: + bg, ink, letter = GREEN, ON_ACCENT, "≡" + else: + bg, ink, _ = tile_colors(label) + letter = label[:1].upper() or "?" + tile = ctk.CTkLabel(row, text=letter, width=28, height=28, + corner_radius=8, fg_color=bg, text_color=ink, + font=F(12, "bold")) + tile.grid(row=0, column=0, padx=(10, 10), pady=8) + name = ctk.CTkLabel(row, text=_clip(label, 16), anchor="w", + font=F(13, "bold"), text_color=TEXT) + name.grid(row=0, column=1, sticky="w") + note = ctk.CTkLabel(row, text=self._notes.get(label, ""), font=F(11), + text_color=MUTED) + note.grid(row=0, column=2, padx=(6, 12)) + for w in (row, tile, name, note): + w.bind("", + lambda e, label=label: self._dropdown_callback(label)) + return row + + class PasswordManagerGUI: def __init__(self, root): self.root = root self.root.title(window_title()) - self.root.geometry("1180x760") - self.root.minsize(980, 640) + self.root.geometry("1320x800") + self.root.minsize(1120, 640) self.root.configure(fg_color=BG) # Every vault of the open account, by id, as account.vault_ids() @@ -590,14 +672,15 @@ def show_login_screen(self): # 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 = tk.Canvas(self.root, bg=pick(BG), highlightthickness=0, + bd=0) backdrop.place(relx=0, rely=0, relwidth=1, relheight=1) backdrop.bind("", 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) + bg_color=BG) card.place(relx=0.5, rely=0.5, anchor="center") pad = ctk.CTkFrame(card, fg_color="transparent") pad.pack(padx=40, pady=36) @@ -713,27 +796,28 @@ def _draw_backdrop(self, canvas, w, h): @staticmethod def _draw_backdrop_plain(canvas, w, h): + green, ink = pick(GREEN), pick(DECOR_INK) 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="") + h * 0.16 + r1, fill=pick(DECOR_DISC_A), outline="") canvas.create_oval(w * 0.93 - r2, h * 0.9 - r2, w * 0.93 + r2, - h * 0.9 + r2, fill="#101A14", outline="") + h * 0.9 + r2, fill=pick(DECOR_DISC_B), 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, + 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) + 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", + canvas.create_oval(x - 40, y - 18, x - 4, y + 18, outline=ink, 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) + canvas.create_line(x - 4, y, x + 44, y, fill=ink, width=4) + canvas.create_line(x + 30, y, x + 30, y + 16, fill=ink, width=4) + canvas.create_line(x + 42, y, x + 42, y + 12, fill=ink, 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="") + fill=pick(DECOR_DOT), outline="") def _draw_login_tiles(self, users): """One tile per account on this PC, plus one to create another.""" @@ -950,142 +1034,130 @@ def go(): # ================================================================== # Main window # ================================================================== + def _nav_button(self, parent, text, command): + """A quiet row of the side bar.""" + 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")) + def show_main_window(self): self._clear() self.root.grid_columnconfigure(0, weight=1) self.root.grid_rowconfigure(1, weight=1) - # ---- 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)) + # Three columns: the vaults, the entries of the one picked, and the + # selected entry. + shell = ctk.CTkFrame(self.root, fg_color="transparent") + shell.grid(row=1, column=0, sticky="nsew", padx=16, pady=16) + shell.grid_rowconfigure(0, weight=1) + shell.grid_columnconfigure(1, weight=1, minsize=320) + shell.grid_columnconfigure(2, weight=2, minsize=520) + + # ---- side bar ---- + side = ctk.CTkFrame(shell, fg_color="transparent", width=204) + side.grid(row=0, column=0, sticky="ns", padx=(0, 16)) + side.pack_propagate(False) + + brand = ctk.CTkFrame(side, fg_color="transparent") + brand.pack(fill="x", padx=8, pady=(6, 18)) self._logo(brand, small=True).pack(side="left") ctk.CTkLabel(brand, text="SecretSpace", font=F(17, "bold"), text_color=TEXT).pack(side="left", padx=(10, 0)) + # 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.pack(fill="x", pady=(6, 0)) + Tooltip(manage, "Add, rename, share or delete vaults") + + # Bottom up: the account, then the places you go occasionally. + ctk.CTkLabel(side, text=version_string(), font=F(11), + text_color=MUTED).pack(side="bottom", pady=(6, 0)) + me = ctk.CTkFrame(side, fg_color=CARD, corner_radius=14) + me.pack(side="bottom", fill="x") 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), + who = ctk.CTkFrame(me, fg_color="transparent") + who.pack(fill="x", padx=12, pady=(12, 8)) + self._circle(who, _initials(self.current_user), 34, bg, ink, + F(12, "bold"))[0].pack(side="left") + ctk.CTkLabel(who, text=_clip(self.current_user, 16), font=F(13, "bold"), text_color=TEXT).pack( - side="left", padx=(0, 14)) - Tooltip(chip, "The account you unlocked. Lock to switch to " - "another.") - - self.search_entry = ctk.CTkEntry( - 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) - - controls = ctk.CTkFrame(bar, fg_color="transparent") - controls.grid(row=0, column=3, padx=(0, 24)) - syn = self._ghost_button(controls, "Sync", self.sync_now, width=70) - syn.pack(side="left", padx=(0, 8)) + side="left", padx=(10, 0)) + Tooltip(who, "The account you unlocked. Lock to switch to another.") + pair = ctk.CTkFrame(me, fg_color="transparent") + 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.grid(row=0, column=0, sticky="ew", padx=(0, 4)) Tooltip(syn, "Sync this account with its server (Settings → Sync)") - imp = self._ghost_button(controls, "Import", self.import_dialog, width=80) - imp.pack(side="left", padx=(0, 8)) - Tooltip(imp, "Import a PassMan 2.x vault, or a CSV or JSON file") - 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.pack(side="left", padx=(0, 8)) - Tooltip(gear, "Settings") - lock = self._ghost_button(controls, "Lock", self.lock_vault, width=70) - lock.pack(side="left") + lock = self._small_button(pair, "Lock", self.lock_vault) + lock.grid(row=0, column=1, sticky="ew", padx=(4, 0)) Tooltip(lock, "Lock the account and sign out") - # ---- 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) + gear = self._nav_button(side, "Settings", self.settings_dialog) + gear.pack(side="bottom", fill="x", pady=(0, 10)) + imp = self._nav_button(side, "Import", self.import_dialog) + 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) + 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_btn.pack(side="bottom", fill="x") + Tooltip(self.archive_btn, "Entries you removed, kept so they can be " + "restored") - left = ctk.CTkFrame(body, fg_color="transparent") - left.grid(row=0, column=0, sticky="nsew", padx=(0, 20)) + # ---- the entries ---- + left = ctk.CTkFrame(shell, fg_color="transparent") + left.grid(row=0, column=1, sticky="nsew", padx=(0, 16)) left.grid_rowconfigure(2, weight=1) left.grid_columnconfigure(0, weight=1) - # Which vault the list shows, and the way to the Vaults window. - vault_row = ctk.CTkFrame(left, fg_color="transparent") - vault_row.grid(row=0, column=0, sticky="ew", pady=(0, 10)) - vault_row.grid_columnconfigure(0, weight=1) - self.vault_menu = ctk.CTkOptionMenu( - vault_row, height=34, corner_radius=10, values=[ALL_VAULTS], - fg_color=INPUT_BG, button_color=BORDER, - button_hover_color=CARD_HOVER, text_color=TEXT, font=F(12, "bold"), - dropdown_fg_color=CARD, dropdown_hover_color=CARD_HOVER, - dropdown_text_color=TEXT, anchor="w", command=self._on_vault_pick) - self.vault_menu.grid(row=0, column=0, sticky="ew") - Tooltip(self.vault_menu, "Which vault the list shows. New entries go " - "to the vault picked here.") - manage = ctk.CTkButton( - vault_row, text="Vaults", command=self.vaults_dialog, width=76, - height=34, corner_radius=10, fg_color="transparent", - border_width=1, border_color=BORDER, hover_color=CARD_HOVER, - text_color=TEXT, font=F(12, "bold")) - manage.grid(row=0, column=1, padx=(8, 0)) - Tooltip(manage, "Add, rename or delete vaults") - - chips = ctk.CTkFrame(left, fg_color="transparent") - chips.grid(row=1, 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( - 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") + top = ctk.CTkFrame(left, fg_color="transparent") + top.grid(row=0, column=0, sticky="ew") + top.grid_columnconfigure(0, weight=1) + self.search_entry = ctk.CTkEntry( + top, height=46, corner_radius=12, fg_color=CARD, + border_color=CARD, font=F(14), + placeholder_text="Search (Ctrl+F)") + self.search_entry.grid(row=0, column=0, sticky="ew") + self.search_entry.bind("", self._on_search) + add = ctk.CTkButton( + top, text="+", command=self.add_account_dialog, width=46, + height=46, corner_radius=12, fg_color=GREEN, + hover_color=GREEN_HOVER, text_color=ON_ACCENT, font=F(22, "bold")) + add.grid(row=0, column=1, padx=(10, 0)) + Tooltip(add, "Add an entry") + + info = ctk.CTkFrame(left, fg_color="transparent") + info.grid(row=1, column=0, sticky="ew", pady=(12, 6)) + self.all_chip = ctk.CTkLabel(info, text="", font=F(12, "bold"), + text_color=MUTED) + self.all_chip.pack(side="left", padx=(6, 0)) sort_menu = ctk.CTkOptionMenu( - chips, width=160, height=34, corner_radius=10, + info, width=160, height=32, corner_radius=10, values=[SORT_LABELS[o] for o in SORT_ORDERS], - fg_color=INPUT_BG, button_color=BORDER, + fg_color=CARD, button_color=CARD, 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") - list_card = ctk.CTkFrame(left, fg_color=CARD, corner_radius=20, - border_width=1, border_color=BORDER) - list_card.grid(row=2, column=0, sticky="nsew") - self.list_frame = ctk.CTkScrollableFrame(list_card, fg_color=CARD, + # Rows are cards of their own, straight on the window colour. + self.list_frame = ctk.CTkScrollableFrame(left, fg_color="transparent", corner_radius=0) - self.list_frame.pack(fill="both", expand=True, padx=6, pady=8) + self.list_frame.grid(row=2, column=0, sticky="nsew") self.list_frame.grid_columnconfigure(0, weight=1) - add_row = ctk.CTkFrame(left, fg_color="transparent", height=48) - add_row.grid(row=3, 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")) - even_width(add, add_row) - ctk.CTkLabel(left, text=version_string(), font=F(11), - text_color=MUTED).grid(row=4, 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.detail = ctk.CTkFrame(shell, fg_color=CARD, corner_radius=20) + self.detail.grid(row=0, column=2, sticky="nsew") self.refresh_account_list() self._start_idle_timer() @@ -1130,9 +1202,17 @@ def _refresh_vault_menu(self): if self.vault_menu is None or not self.vault_menu.winfo_exists(): return names = [self.vault_name(v) for v in self.vaults] + notes, everything = {}, 0 + for vault_id, name in zip(self.vaults, names): + count = len(active_accounts(self.vaults[vault_id])) + everything += count + notes[name] = (f"shared · {count}" + if self.account.is_shared(vault_id) else str(count)) + notes[ALL_VAULTS] = str(everything) # One vault needs no "all of them". self.vault_menu.configure( - values=names if len(names) < 2 else [ALL_VAULTS] + names) + values=names if len(names) < 2 else [ALL_VAULTS] + names, + notes=notes) self.vault_menu.set(self.vault_name(self.vault_filter) if self.vault_filter in self.vaults else (ALL_VAULTS if len(names) > 1 else names[0])) @@ -1168,7 +1248,8 @@ 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.all_chip.configure(text=f" All {total} ") + self.all_chip.configure( + text=f"{total} entr{'y' if total == 1 else 'ies'}") self.archive_btn.configure(text=f"Archive {archived}" if archived else "Archive") @@ -1217,7 +1298,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=2, padx=2) + cached[1].grid(row=i, column=0, sticky="ew", pady=3, padx=2) cached[2] = i else: self._rows[acc["id"]] = [_row_signature(acc, @@ -1253,14 +1334,14 @@ def _add_row(self, i, acc): 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(row=i, column=0, sticky="ew", pady=3, 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) + tile.grid(row=0, column=0, padx=(12, 12), pady=11) info = ctk.CTkFrame(row, fg_color="transparent") info.grid(row=0, column=1, sticky="w") @@ -1369,11 +1450,13 @@ def _show_detail(self): # In a vault other people are in, a change has a name. if line and acc.get("updated_by") and self.account.is_shared(home): line += " by " + _clip(acc["updated_by"], 24) + # The vault's name on a line of its own: beside the date it ran + # under the buttons. if len(self.vaults) > 1: - line += (" · " if line else "") + _clip(self.vault_name( - self._entry_vault.get(acc["id"])), 24) + line += ("\n" if line else "") + "In " + _clip(self.vault_name( + self._entry_vault.get(acc["id"])), 28) ctk.CTkLabel(names, text=line, font=F(12), text_color=MUTED, - anchor="w").pack(anchor="w") + anchor="w", justify="left").pack(anchor="w") actions = ctk.CTkFrame(band, fg_color="transparent") actions.grid(row=0, column=2, padx=(8, 22)) if writable: @@ -1561,7 +1644,8 @@ def copy_code(): return box def _strength_row(self, parent, password): - name, frac, color = estimate_strength(password) + name, frac, _ = estimate_strength(password) + color = strength_color(name) 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"), @@ -1684,6 +1768,7 @@ def _on_close(self): if __name__ == "__main__": + apply_appearance(appconfig.appearance()) root = ctk.CTk() app = PasswordManagerGUI(root) root.mainloop() diff --git a/sourceFiles/sharing_ui.py b/sourceFiles/sharing_ui.py index 177f272..e04240e 100644 --- a/sourceFiles/sharing_ui.py +++ b/sourceFiles/sharing_ui.py @@ -24,9 +24,10 @@ import changelog from history_ui import local_time from sync_ui import _in_background -from theme import F, TEXT, MUTED, CARD, BORDER, WARN, DANGER, GREEN, MONO +from theme import (F, TEXT, MUTED, CARD, BORDER, WARN, DANGER, GREEN, MONO, + tile_colors) from widgets import (dialog_shell, ghost_button, accent_button, small_button, - field, error_label, note, option_menu, Tooltip) + field, error_label, note, option_menu, Tooltip, avatar) ROLE_LABELS = {accounts.ROLE_OWNER: "Owner", accounts.ROLE_EDITOR: "Editor", accounts.ROLE_READER: "Reader"} @@ -92,13 +93,15 @@ def members_dialog(app, vault_id, changed=None): account = app.account name = app.vault_name(vault_id) owner = account.vault_role(vault_id) == accounts.ROLE_OWNER - top = app._modal(f"Members of “{name}”", 620, 660) + top = app._modal(f"Members of “{name}”", 620, 680) + tile_bg, tile_ink, band = tile_colors(name) shell = dialog_shell( top, f"Members of “{name}”", "Everyone here can open this vault. Owners manage the members; " "editors change entries; readers only read." if owner else f"You are {_a(role_label(account.vault_role(vault_id)).lower())} of " - "this vault. Its owners manage the members.", scroll=True) + "this vault. Its owners manage the members.", scroll=True, + tile=(name[:1].upper() or "?", tile_bg, tile_ink), band=band) listing = shell.body listing.grid_columnconfigure(0, weight=1) state = {"members": [], "busy": False} @@ -117,17 +120,29 @@ def show(members): return for child in listing.winfo_children(): child.destroy() - fp = ctk.CTkFrame(listing, fg_color="transparent") - fp.grid(row=0, column=0, sticky="ew", padx=4, pady=(10, 6)) - ctk.CTkLabel(fp, text="Your fingerprint", font=F(12, "bold"), + fp = ctk.CTkFrame(listing, fg_color=CARD, corner_radius=14) + fp.grid(row=0, column=0, sticky="ew", padx=2, pady=(10, 4)) + fp.grid_columnconfigure(0, weight=1) + words = ctk.CTkFrame(fp, fg_color="transparent") + words.grid(row=0, column=0, sticky="w", padx=(16, 8), pady=(10, 0)) + ctk.CTkLabel(words, text="Your fingerprint", font=F(12), text_color=MUTED).pack(anchor="w") - ctk.CTkLabel(fp, text=account.fingerprint, + ctk.CTkLabel(words, text=account.fingerprint, font=ctk.CTkFont(family=MONO, size=15), text_color=TEXT).pack(anchor="w") + + def copy_fingerprint(): + # The app's own copy: the clipboard differs per system. + app._copy(account.fingerprint, "Fingerprint copied") + + small_button(fp, "Copy", copy_fingerprint, width=70).grid( + row=0, column=1, padx=(0, 14), pady=(10, 0)) ctk.CTkLabel(fp, text="Read it to someone who is about to share a " "vault with you, so they know the key is yours.", - font=F(11), text_color=MUTED, wraplength=shell.text_width, - justify="left").pack(anchor="w") + font=F(11), text_color=MUTED, + wraplength=shell.text_width - 40, + justify="left").grid(row=1, column=0, columnspan=2, + sticky="w", padx=16, pady=(2, 10)) for i, member in enumerate(members, start=1): _member_row(listing, i, member) status(f"{len(members)} member{'s' if len(members) != 1 else ''}") @@ -168,12 +183,13 @@ def done(payload): _with_server(app, both, done, failed) def _member_row(parent, index, member): - 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=4) - row.grid_columnconfigure(0, weight=1) + row.grid_columnconfigure(1, weight=1) + avatar(row, member["username"]).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=10) + info.grid(row=0, column=1, sticky="w", padx=(12, 8), pady=10) who = member["username"] + (" (you)" if member["me"] else "") ctk.CTkLabel(info, text=who, anchor="w", font=F(14, "bold"), text_color=TEXT).pack(anchor="w") @@ -192,7 +208,7 @@ def _member_row(parent, index, member): if member["me"] or (member["verified"] and not owner): return buttons = ctk.CTkFrame(row, fg_color="transparent") - buttons.grid(row=0, column=1, padx=(0, 14)) + buttons.grid(row=0, column=2, padx=(0, 14)) who = member["username"] if not member["verified"]: check = small_button(buttons, "Check", lambda: check_person(who), diff --git a/sourceFiles/tests/test_look.py b/sourceFiles/tests/test_look.py index 2cfa156..25037fd 100644 --- a/sourceFiles/tests/test_look.py +++ b/sourceFiles/tests/test_look.py @@ -11,10 +11,11 @@ import gui import theme # noqa: F401 (importing it applies the drawing settings) -from theme import BG, GREEN +from theme import BG, GREEN, pick def _rgb(colour): + colour = pick(colour) # the pair's colour for the mode in use return tuple(int(colour[i:i + 2], 16) for i in (1, 3, 5)) @@ -57,3 +58,143 @@ def test_padlock_body_has_round_corners(): 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 + + +# ---- light and dark --------------------------------------------------------- +PAIRS = ("BG", "HEADER_BG", "CARD", "CARD_HOVER", "SELECTED", "INPUT_BG", + "BORDER", "TEXT", "MUTED", "GREEN", "GREEN_HOVER", "ACCENT_DEEP", + "DANGER", "DANGER_HOVER", "ON_ACCENT", "WARN") + + +@pytest.fixture +def mode(): + """Set an appearance for one test, and put the old one back.""" + import customtkinter as ctk + before = ctk.get_appearance_mode() + yield theme.apply_appearance + ctk.set_appearance_mode(before) + + +@pytest.mark.parametrize("name", PAIRS) +def test_every_colour_is_a_light_and_dark_pair(name): + light, dark = getattr(theme, name) + for colour in (light, dark): + assert len(colour) == 7 and colour.startswith("#") + int(colour[1:], 16) + + +def test_pick_follows_the_mode(mode): + mode("light") + assert pick(theme.BG) == theme.BG[0] and not theme.is_dark() + mode("dark") + assert pick(theme.BG) == theme.BG[1] and theme.is_dark() + assert pick("#123456") == "#123456" # a plain colour stays + + +def test_an_unknown_appearance_means_system(mode): + import customtkinter as ctk + mode("purple") + assert ctk.get_appearance_mode() in ("Light", "Dark") + + +@pytest.mark.parametrize("which", ["light", "dark"]) +def test_backdrop_is_painted_in_the_mode_in_use(mode, which): + mode(which) + picture = gui.backdrop_picture(900, 600) + assert _near(picture.getpixel((450, 300)), theme.BG) + + +def _luminance(colour): + def lin(v): + v /= 255 + return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4 + r, g, b = (int(colour[i:i + 2], 16) for i in (1, 3, 5)) + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) + + +def _contrast(a, b): + hi, lo = sorted((_luminance(a), _luminance(b)), reverse=True) + return (hi + 0.05) / (lo + 0.05) + + +@pytest.mark.parametrize("i", [0, 1]) +@pytest.mark.parametrize("ink,ground", [ + ("TEXT", "BG"), ("TEXT", "CARD"), ("TEXT", "INPUT_BG"), ("MUTED", "BG"), + ("MUTED", "CARD"), ("MUTED", "INPUT_BG"), ("ON_ACCENT", "GREEN"), + ("GREEN", "CARD"), ("WARN", "CARD"), ("TEXT", "SELECTED")]) +def test_text_is_readable_on_its_ground(ink, ground, i): + """WCAG AA for body text: 4.5 to 1, in light (0) and dark (1).""" + assert _contrast(getattr(theme, ink)[i], getattr(theme, ground)[i]) >= 4.5 + + +def test_tile_letters_are_readable(): + for tile, letter, band in theme.TILE_PALETTE: + assert _contrast(tile, letter) >= 4.5 + assert len(band) == 2 + + +def test_the_bundled_font_is_used(): + """The files are in the checkout, so the fallback must not be in use.""" + import os + here = os.path.dirname(os.path.abspath(theme.__file__)) + assert all(os.path.isfile(os.path.join(here, f)) for f in theme.FONT_FILES) + assert theme.FONT == "Manrope" + + +# ---- the vault list at the left of the main window -------------------------- +@pytest.fixture(scope="module") +def window(): + """One Tk window for the module: Tk does not like a second one.""" + import customtkinter as ctk + try: + root = ctk.CTk() + except Exception: # no display (a bare CI runner) + pytest.skip("no display") + root.withdraw() + yield root + root.destroy() + + +@pytest.fixture +def nav(window): + picked = [] + return gui.VaultNav(window, picked.append), picked + + +def test_vault_nav_speaks_like_the_menu_it_replaced(nav): + menu, picked = nav + menu.configure(values=["All vaults", "Personal", "Work"], + notes={"All vaults": "5", "Personal": "3", "Work": "2"}) + menu.set("All vaults") + assert menu.cget("values") == ["All vaults", "Personal", "Work"] + assert menu.get() == "All vaults" and picked == [] # set() only shows + menu._dropdown_callback("Work") # a click + assert menu.get() == "Work" and picked == ["Work"] + assert menu.winfo_exists() + + +def test_vault_nav_marks_the_picked_row_only(nav): + menu, _ = nav + menu.configure(values=["Personal", "Work"], notes={}) + menu.set("Work") + marked = [label for label, row in menu._rows.items() + if row.cget("fg_color") == theme.CARD] + assert marked == ["Work"] + + +def test_vault_nav_keeps_its_rows_when_nothing_changed(nav): + menu, _ = nav + menu.configure(values=["Personal", "Work"], notes={"Work": "2"}) + before = dict(menu._rows) + menu.configure(values=["Personal", "Work"], notes={"Work": "2"}) + assert menu._rows == before # not rebuilt + menu.configure(values=["Personal", "Work"], notes={"Work": "3"}) + assert menu._rows != before # a count changed + + +@pytest.mark.parametrize("name", ["Weak", "Fair", "Good", "Strong"]) +@pytest.mark.parametrize("i", [0, 1]) +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 diff --git a/sourceFiles/theme.py b/sourceFiles/theme.py index 270d315..8cac636 100644 --- a/sourceFiles/theme.py +++ b/sourceFiles/theme.py @@ -3,6 +3,10 @@ Everything visual that more than one module needs lives here, so the colours are defined in exactly one place. + +Every colour is a ``(light, dark)`` pair. CustomTkinter widgets take the pair +as it is and follow the appearance mode by themselves; a plain Tk widget or a +Pillow drawing needs one colour, which :func:`pick` chooses. """ import os @@ -10,7 +14,10 @@ import customtkinter as ctk -ctk.set_appearance_mode("dark") +#: What Settings offers. "system" follows the desktop. +APPEARANCES = ("system", "light", "dark") + +ctk.set_appearance_mode("system") ctk.set_default_color_theme("green") # Rounded corners are drawn the way CustomTkinter prefers here: out of @@ -18,50 +25,57 @@ # 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 -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 = "#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" +# light dark +BG = ("#EDF0F4", "#15161A") # the window itself +HEADER_BG = ("#E6EAEF", "#1A1C21") # top bar, history panel +CARD = ("#FFFFFF", "#1F2127") # cards and panels on top of BG +CARD_HOVER = ("#E9EDF2", "#2A2D36") +SELECTED = ("#DDF1E6", "#1F3128") # the selected row in the account list +INPUT_BG = ("#F4F6F9", "#191B20") +BORDER = ("#DCE1E8", "#2E313A") +TEXT = ("#16181D", "#F0F1F4") +MUTED = ("#586170", "#A2A8B3") +GREEN = ("#0E7A47", "#56E39F") +GREEN_HOVER = ("#0B6239", "#3FCB87") +ACCENT_DEEP = ("#0E7A47", "#177A49") # a selected choice under light text +DANGER = ("#C0362F", "#E5534B") +DANGER_HOVER = ("#9E2A24", "#C23B34") +ON_ACCENT = ("#FFFFFF", "#05230F") # text drawn on top of the green accent +WARN = ("#8A5300", "#F2B955") -# 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"), -] +# How strong a password is, by the name estimate_strength gives it. +STRENGTH = { + "Weak": DANGER, + "Fair": WARN, + "Good": ("#1F8A55", "#67C090"), + "Strong": GREEN, +} +# The drawn login backdrop (Pillow / a Tk canvas: use pick()). +DECOR_DISC_A = ("#DCE8E1", "#17211B") +DECOR_DISC_B = ("#E1E6EB", "#1A1D21") +DECOR_DOT = ("#BFD3C7", "#26392E") +DECOR_INK = ("#3F9A6C", "#7BE0A6") -def F(size, weight="normal"): - """Shorthand for a themed font.""" - return ctk.CTkFont(family=FONT, size=size, weight=weight) - +#: The app's typeface, bundled beside the program (SIL Open Font License, +#: Manrope-OFL.txt). FONT falls back when the files are missing. +FONT_FILES = ("Manrope-Regular.ttf", "Manrope-Bold.ttf") +# CustomTkinter makes a font known on Linux by copying it to ~/.fonts, as +# it does with its own. DejaVu ships with Ubuntu and most other desktops; +# where it is missing, fontconfig picks the nearest. +FALLBACK_FONT = "DejaVu Sans" +MONO = "DejaVu Sans Mono" -def tile_colors(s): - """Stable per-site (tile, letter, band) colours.""" - if not s: - return TILE_PALETTE[0] - return TILE_PALETTE[sum(ord(c) for c in s) % len(TILE_PALETTE)] +# Per-site tile colours: (tile, letter, header band). A pastel tile with a +# dark letter in both modes; only the band behind an open entry differs. +TILE_PALETTE = [ + ("#D5F2DD", "#0E4023", ("#E2F6E8", "#1D3527")), + ("#FFE0C2", "#5C2E05", ("#FFEBD8", "#3D2C1B")), + ("#E7DBFF", "#35206B", ("#EFE7FF", "#2B2547")), + ("#FFD9E0", "#641328", ("#FFE6EA", "#3F2228")), + ("#CFE3FF", "#12305E", ("#DCE9FF", "#22324D")), + ("#D6EEF2", "#0C3F49", ("#E3F4F7", "#1C3338")), +] def asset_candidates(name): @@ -75,3 +89,53 @@ def asset_candidates(name): paths.append(name) paths.append(os.path.join("dist", name)) return paths + + +def _load_fonts(): + """Make the bundled typeface known to Tk. True when every file loaded.""" + for name in FONT_FILES: + path = next((p for p in asset_candidates(name) if os.path.isfile(p)), + None) + try: + if path is None or not ctk.FontManager.load_font(path): + return False + except Exception: + return False + return True + + +FONT = "Manrope" if _load_fonts() else FALLBACK_FONT + + +def F(size, weight="normal"): + """Shorthand for a themed font.""" + return ctk.CTkFont(family=FONT, size=size, weight=weight) + + +def is_dark(): + return ctk.get_appearance_mode() == "Dark" + + +def pick(color): + """One colour out of a ``(light, dark)`` pair, for what is not a + CustomTkinter widget. A plain colour is returned as it is.""" + if isinstance(color, (tuple, list)): + return color[1] if is_dark() else color[0] + return color + + +def apply_appearance(mode): + """Switch to "system", "light" or "dark"; anything else means system.""" + ctk.set_appearance_mode(mode if mode in APPEARANCES else "system") + + +def strength_color(name): + """The colour of a strength's name and its meter, in both modes.""" + return STRENGTH.get(name, MUTED) + + +def tile_colors(s): + """Stable per-site (tile, letter, band) colours.""" + if not s: + return TILE_PALETTE[0] + return TILE_PALETTE[sum(ord(c) for c in s) % len(TILE_PALETTE)] diff --git a/sourceFiles/version.py b/sourceFiles/version.py index dc7870a..b422354 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -12,7 +12,7 @@ import re -__version__ = "1.0.0" +__version__ = "1.1.0-alpha.2" #: 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 223a6c9..e16a08a 100644 --- a/sourceFiles/widgets.py +++ b/sourceFiles/widgets.py @@ -15,7 +15,7 @@ import customtkinter as ctk -from theme import (F, FONT, BG, HEADER_BG, CARD, TEXT, MUTED, GREEN, +from theme import (tile_colors, strength_color, 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 @@ -47,7 +47,7 @@ def _show(self, _=None): tw.wm_overrideredirect(True) tw.wm_geometry(f"+{x}+{y}") tw.attributes("-topmost", True) - lbl = tk.Label(tw, text=self.text, bg="#0E0E10", fg="#EDEDED", + lbl = tk.Label(tw, text=self.text, bg="#16181D", fg="#F0F1F4", padx=9, pady=4, font=(FONT, 9), bd=0) lbl.pack() @@ -132,7 +132,7 @@ def field_label(parent, text): 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, + fg_color=CARD, border_width=1, border_color=BORDER, show=show, font=font or F(14), placeholder_text=placeholder) @@ -199,7 +199,8 @@ def strength_widget(parent): label.pack(side="left", padx=(6, 0)) def update(password): - name, frac, color = estimate_strength(password) + name, frac, _ = estimate_strength(password) + color = strength_color(name) 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) @@ -217,8 +218,24 @@ def _rule(parent): fg_color=BORDER).pack(fill="x") +def avatar(parent, name, size=40): + """A person's initial on a round pastel tile, the colour fixed by the + name.""" + bg, ink, _ = tile_colors(name) + # A frame of a fixed size with the letter placed in it: a label with + # rounded corners grows sideways and the circle becomes an oval. + disc = ctk.CTkFrame(parent, width=size, height=size, + corner_radius=size // 2, fg_color=bg) + disc.pack_propagate(False) + disc.grid_propagate(False) + ctk.CTkLabel(disc, text=(name[:1].upper() or "?"), fg_color="transparent", + text_color=ink, font=F(size * 2 // 5, "bold")).place( + relx=0.5, rely=0.5, anchor="center") + return disc + + def dialog_shell(top, title, subtitle="", tile=None, danger=False, - scroll=False, fit_exact=True): + scroll=False, fit_exact=True, band=None): """ Header band, body and footer bar for a dialog -- the top bar and the details card of the main window, in small. @@ -229,6 +246,8 @@ def dialog_shell(top, title, subtitle="", tile=None, danger=False, buttons stay put. ``fit_exact`` the window takes the height its content needs, so short dialogs lose their empty middle. Scrolling ones keep theirs. + ``band`` the header's colour, for a dialog about one thing that has + a tile (a vault, an entry): the band of its tile colours. Returns a namespace: ``body`` (build into it), ``buttons`` (the footer row), ``heading`` and ``tile`` (to change them live), ``text_width`` @@ -243,7 +262,7 @@ def dialog_shell(top, title, subtitle="", tile=None, danger=False, 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 = ctk.CTkFrame(top, fg_color=band or 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)) @@ -263,10 +282,12 @@ def dialog_shell(top, title, subtitle="", tile=None, danger=False, justify="left", wraplength=text_width) heading.pack(anchor="w") if subtitle: - ctk.CTkLabel(words, text=subtitle, font=F(12), text_color=MUTED, + ctk.CTkLabel(words, text=subtitle, font=F(12), + text_color=TEXT if band else MUTED, anchor="w", justify="left", wraplength=text_width).pack(anchor="w", pady=(3, 0)) - _rule(head) + if not band: + _rule(head) if scroll: body = ctk.CTkScrollableFrame(top, fg_color="transparent") @@ -285,8 +306,10 @@ def section(parent, title, danger=False, first=False): 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)) + # A card needs no outline on the window colour; a dangerous one keeps + # its red line. card = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=16, - border_width=1, border_color=DANGER if danger else BORDER) + border_width=1 if danger else 0, border_color=DANGER) card.pack(fill="x") return card