diff --git a/label_app.py b/label_app.py index ff89cf1..b7b5ee2 100644 --- a/label_app.py +++ b/label_app.py @@ -10,10 +10,12 @@ import threading import tkinter as tk from dataclasses import asdict, dataclass, field from pathlib import Path -from tkinter import filedialog, messagebox, ttk +from tkinter import filedialog import emoji_data_python +import ttkbootstrap as ttk from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageTk +from ttkbootstrap.dialogs import Messagebox from niimprint import ( MODELS, @@ -37,6 +39,8 @@ EMOJI_FONT_SIZE = 109 # Noto Color Emoji is a bitmap font; only this size loads EMOJI_GAMMA = 3.0 # >1 darkens emoji so they survive 1-bit dithering EMOJI_SCALE = 1.15 # emoji height relative to text size PICKER_PX = 26 # thumbnail size in the emoji picker +THEMES = {"light": "flatly", "dark": "darkly"} +UI_FONT = ("DejaVu Sans", 10) # (name, width mm, length mm) — width is the short side (tape width) LABEL_PRESETS = [ @@ -271,12 +275,16 @@ class Design: # --------------------------------------------------------------------------- app -class LabelApp(tk.Tk): +class LabelApp(ttk.Window): def __init__(self): - super().__init__() - self.title("Niimbot Label Designer") - self.minsize(1100, 640) self.settings = self._load_settings() + self.theme_mode = self.settings.get("theme", "light") + super().__init__( + title="Niimbot Label Designer", + themename=THEMES.get(self.theme_mode, "flatly"), + minsize=(1100, 680), + ) + self.option_add("*Font", UI_FONT) self.design = Design.from_dict(self.settings.get("design", {})) if not self.design.items and "text" in self.settings: # migrate old settings w, h = self.design.size_px @@ -312,6 +320,7 @@ class LabelApp(tk.Tk): self.p_y = tk.IntVar(value=0) self._build_ui() + self._apply_theme_colors() self.preset.trace_add("write", lambda *_: self._apply_preset()) for var in (self.width_mm, self.length_mm): var.trace_add("write", lambda *_: self._size_changed()) @@ -331,13 +340,13 @@ class LabelApp(tk.Tk): # ---- UI construction def _build_ui(self): - root = ttk.Frame(self, padding=8) + root = ttk.Frame(self, padding=12) root.pack(fill="both", expand=True) root.columnconfigure(1, weight=1) root.rowconfigure(0, weight=1) left = ttk.Frame(root) - left.grid(row=0, column=0, sticky="nsw", padx=(0, 8)) + left.grid(row=0, column=0, sticky="nsw", padx=(0, 12)) right = ttk.Frame(root) right.grid(row=0, column=1, sticky="nsew") right.columnconfigure(0, weight=1) @@ -352,7 +361,7 @@ class LabelApp(tk.Tk): self._build_actions(right) def _build_size_box(self, parent): - box = ttk.LabelFrame(parent, text="Label", padding=6) + box = ttk.Labelframe(parent, text="Label size", padding=10) box.pack(fill="x") ttk.Combobox( box, @@ -373,16 +382,16 @@ class LabelApp(tk.Tk): self.length_spin.grid(row=0, column=5) def _build_emoji_picker(self, parent): - box = ttk.LabelFrame(parent, text="Emoji — click to add to label", padding=4) - box.pack(fill="both", expand=True, pady=(8, 0)) + box = ttk.Labelframe(parent, text="Emoji — click to add", padding=8) + box.pack(fill="both", expand=True, pady=(10, 0)) top = ttk.Frame(box) top.pack(fill="x") ttk.Label(top, text="Search").pack(side="left") ttk.Entry(top, textvariable=self.search, width=24).pack( side="left", fill="x", expand=True, padx=(4, 0) ) - self.hover_name = ttk.Label(box, text=" ", foreground="#555") - self.hover_name.pack(fill="x") + self.hover_name = ttk.Label(box, text=" ", bootstyle="secondary") + self.hover_name.pack(fill="x", pady=(4, 2)) self.nb = ttk.Notebook(box, width=420, height=260) self.nb.pack(fill="both", expand=True) @@ -461,8 +470,8 @@ class LabelApp(tk.Tk): self.nb.select(self.search_tab.master) def _build_printer_box(self, parent): - box = ttk.LabelFrame(parent, text="Printer", padding=6) - box.pack(fill="x", pady=(8, 0)) + box = ttk.Labelframe(parent, text="Printer", padding=10) + box.pack(fill="x", pady=(10, 0)) ttk.Label(box, text="Model").grid(row=0, column=0, sticky="w") ttk.Combobox( box, textvariable=self.model, values=list(MODELS), state="readonly", width=6 @@ -492,15 +501,17 @@ class LabelApp(tk.Tk): bar = ttk.Frame(parent) bar.grid(row=0, column=0, sticky="we") - def btn(text, cmd, **kw): - ttk.Button(bar, text=text, command=cmd, **kw).pack(side="left", padx=(0, 4)) + def btn(text, cmd, style="secondary-outline", **kw): + ttk.Button(bar, text=text, command=cmd, bootstyle=style, **kw).pack( + side="left", padx=(0, 4) + ) def sep(): - ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=6) + ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8) - btn("+ Text", lambda: self.add_text("Text")) + btn("+ Text", lambda: self.add_text("Text"), "primary") btn("Duplicate", self.duplicate_selected) - btn("Delete", self.delete_selected) + btn("Delete", self.delete_selected, "danger-outline") sep() btn("⟲ 90°", lambda: self.rotate(-90), width=6) btn("⟳ 90°", lambda: self.rotate(90), width=6) @@ -509,19 +520,27 @@ class LabelApp(tk.Tk): btn("Center V", lambda: self.center("v")) btn("Fit", self.fit_selected) sep() - btn("Open…", self.open_design) - btn("Save…", self.save_design) - btn("New", self.new_design) + btn("New", self.new_design, "link") + btn("Open…", self.open_design, "link") + btn("Save…", self.save_design, "link") + self.theme_btn = ttk.Button( + bar, + text="☾", + width=3, + bootstyle="secondary-link", + command=self.toggle_theme, + ) + self.theme_btn.pack(side="right") def _build_canvas(self, parent): ttk.Label( parent, - text="Drag items to move · arrow keys nudge (Shift = 10px) · Delete removes", - foreground="#555", - ).grid(row=1, column=0, sticky="w", pady=(6, 2)) - holder = tk.Frame(parent, bg="#777") - holder.grid(row=2, column=0, sticky="nsew") - self.canvas = tk.Canvas(holder, bg="#777", highlightthickness=0) + text="Drag items to move · arrow keys nudge (Shift = 10 px) · Delete removes", + bootstyle="secondary", + ).grid(row=1, column=0, sticky="w", pady=(10, 4)) + self.holder = tk.Frame(parent) + self.holder.grid(row=2, column=0, sticky="nsew") + self.canvas = tk.Canvas(self.holder, highlightthickness=0) self.canvas.place(relx=0.5, rely=0.5, anchor="center") self.canvas.bind("", self._canvas_press) self.canvas.bind("", self._canvas_drag) @@ -538,14 +557,22 @@ class LabelApp(tk.Tk): ) self.canvas.bind("", lambda e: self.delete_selected()) self.canvas.bind("", lambda e: self.delete_selected()) - self.size_label = ttk.Label(parent, text="") - self.size_label.grid(row=3, column=0, sticky="w") + self.size_label = ttk.Label(parent, text="", bootstyle="secondary") + self.size_label.grid(row=3, column=0, sticky="w", pady=(4, 0)) def _build_props(self, parent): - box = ttk.LabelFrame(parent, text="Selected item", padding=6) - box.grid(row=4, column=0, sticky="we", pady=(6, 0)) + box = ttk.Labelframe(parent, text="Selected item", padding=10) + box.grid(row=4, column=0, sticky="we", pady=(10, 0)) box.columnconfigure(0, weight=1) - self.prop_text = tk.Text(box, height=3, width=40, font=("DejaVu Sans", 12)) + self.prop_text = tk.Text( + box, + height=3, + width=40, + font=("DejaVu Sans", 12), + relief="flat", + padx=6, + pady=4, + ) self.prop_text.grid(row=0, column=0, sticky="we") self.prop_text.bind("<>", self._prop_text_modified) @@ -579,13 +606,48 @@ class LabelApp(tk.Tk): def _build_actions(self, parent): bar = ttk.Frame(parent) - bar.grid(row=5, column=0, sticky="we", pady=(8, 0)) - self.print_btn = ttk.Button(bar, text="Print", command=self.print_label) - self.print_btn.pack(side="left") - ttk.Button(bar, text="Save PNG…", command=self.save_png).pack( - side="left", padx=(6, 0) + bar.grid(row=5, column=0, sticky="we", pady=(12, 0)) + self.print_btn = ttk.Button( + bar, + text="🖨 Print", + command=self.print_label, + bootstyle="success", + width=12, ) - ttk.Label(bar, textvariable=self.status).pack(side="right") + self.print_btn.pack(side="left", ipady=4) + ttk.Button( + bar, text="Save PNG…", command=self.save_png, bootstyle="secondary-outline" + ).pack(side="left", padx=(8, 0), ipady=4) + ttk.Label(bar, textvariable=self.status, bootstyle="secondary").pack( + side="right" + ) + + # ---- theme + + def toggle_theme(self): + self.theme_mode = "dark" if self.theme_mode == "light" else "light" + self.style.theme_use(THEMES[self.theme_mode]) + self._apply_theme_colors() + self.render_canvas() + + def _apply_theme_colors(self): + """Colour the plain-Tk widgets (Text, Canvas) to match the ttk theme.""" + c = self.style.colors + dark = self.theme_mode == "dark" + self.theme_btn.configure(text="☀" if dark else "☾") + canvas_bg = c.dark if dark else c.light + self.holder.configure(bg=canvas_bg) + self.canvas.configure(bg=canvas_bg) + text_opts = dict( + bg=c.inputbg, + fg=c.inputfg, + insertbackground=c.inputfg, + selectbackground=c.primary, + ) + self.prop_text.configure(**text_opts) + for txt in self.emoji_index: + txt.configure(**text_opts) + self._sel_color = c.info # ---- label size @@ -768,7 +830,7 @@ class LabelApp(tk.Tk): x0, y0, x1, y1 = self._boxes[self.design.items.index(self.selected)] self.canvas.create_rectangle( x0 * z - 2, y0 * z - 2, x1 * z + 2, y1 * z + 2, - outline="#1e88e5", width=2, dash=(4, 3), + outline=self._sel_color, width=2, dash=(4, 3), ) # fmt: skip w, h = img.size model = MODELS[self.model.get()] @@ -783,8 +845,9 @@ class LabelApp(tk.Tk): # ---- designs def new_design(self): - if self.design.items and not messagebox.askyesno( - "New label", "Clear all items?" + if ( + self.design.items + and Messagebox.yesno("Clear all items?", "New label", parent=self) != "Yes" ): return self.design.items.clear() @@ -805,7 +868,7 @@ class LabelApp(tk.Tk): try: self.design = Design.from_dict(json.loads(Path(path).read_text())) except (OSError, ValueError, TypeError) as exc: - messagebox.showerror("Cannot open", str(exc)) + Messagebox.show_error(str(exc), "Cannot open", parent=self) return self.preset.set(self.design.preset) self.width_mm.set(self.design.width_mm) @@ -844,7 +907,7 @@ class LabelApp(tk.Tk): self.model.get(), image, self.density.get(), rotate=90 ) except (ValueError, tk.TclError) as exc: - messagebox.showerror("Cannot print", str(exc)) + Messagebox.show_error(str(exc), "Cannot print", parent=self) return copies = max(1, self.copies.get()) self.print_btn.configure(state="disabled") @@ -872,7 +935,7 @@ class LabelApp(tk.Tk): self.status.set("Print failed") is_prn = isinstance(exc, PrinterError) kind = "Printer error" if is_prn else type(exc).__name__ - messagebox.showerror(kind, str(exc) or repr(exc)) + Messagebox.show_error(str(exc) or repr(exc), kind, parent=self) # ---- settings @@ -888,6 +951,7 @@ class LabelApp(tk.Tk): "conn": self.conn.get(), "addr": self.addr.get(), "density": self.density.get(), + "theme": self.theme_mode, "design": self.design.to_dict(), } try: diff --git a/pyproject.toml b/pyproject.toml index b668c90..9de8208 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ pyserial = "^3.5" pillow = "^10.1.0" click = "^8.1.7" emoji-data-python = "^1.6" +ttkbootstrap = "^1.10" [build-system] requires = ["poetry-core"] diff --git a/readme.md b/readme.md index 360efb6..702ab2e 100644 --- a/readme.md +++ b/readme.md @@ -95,5 +95,6 @@ python label_app.py - **Fit** resizes the item to fill the label; **Center H/V** aligns it - The canvas shows the dithered 1-bit image exactly as the printer will produce it - **Save…/Open…** store designs as JSON; the last design and printer settings are restored on launch +- ☾/☀ in the toolbar switches between light and dark themes ([ttkbootstrap](https://ttkbootstrap.readthedocs.io)) Fonts: DejaVu Sans (`/usr/share/fonts/TTF/`) for text and Noto Color Emoji (`/usr/share/fonts/noto/`) for emoji; edit `TEXT_FONTS` / `EMOJI_FONT` at the top of the file for other distros. Emoji are rendered by Pillow with the same font that prints, so the picker is WYSIWYG. diff --git a/requirements.txt b/requirements.txt index dc2844d..ac96fba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ click==8.1.7 pillow==10.1.0 pyserial==3.5 emoji-data-python==1.6.0 +ttkbootstrap==1.20.4