"""Label designer for Niimbot printers. Items (text blocks and emoji) are placed freely on the label canvas: click to select, drag to move, arrow keys to nudge, rotate/resize from the side panel. """ import json import os import re import subprocess import sys import tempfile import threading import tkinter as tk import urllib.request import webbrowser from dataclasses import asdict, dataclass, field from pathlib import Path from tkinter import filedialog import emoji_data_python import ttkbootstrap as ttk from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageTk from serial.tools import list_ports from ttkbootstrap.dialogs import Messagebox from niimprint import ( MODELS, BluetoothTransport, PrinterClient, PrinterError, SerialTransport, prepare_print, ) APP_VERSION = "0.2.1" UPDATE_REPO = "gracious/NIIMBOT-D101" UPDATE_API = f"https://git.gracious.one/api/v1/repos/{UPDATE_REPO}/releases/latest" UPDATE_ASSET = "NiimbotLabel.exe" if sys.platform == "win32" else "NiimbotLabel" FROZEN = bool(getattr(sys, "frozen", False)) # running from a PyInstaller bundle PX_PER_MM = 8 SETTINGS_PATH = Path.home() / ".config" / "niimprint-label" / "settings.json" DEFAULT_ADDR = "" # printer Bluetooth MAC / serial port; remembered once entered def _asset_dir() -> Path: """Bundled assets: next to this file, or inside a PyInstaller bundle.""" base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) return base / "assets" / "fonts" def _find_font(name: str, *system_dirs: str) -> str: for d in (_asset_dir(), *map(Path, system_dirs)): if (d / name).is_file(): return str(d / name) raise FileNotFoundError( f"Font {name} not found (looked in assets/fonts and system dirs)" ) _FONT_DIRS = ( "/usr/share/fonts/TTF", "/usr/share/fonts/truetype/dejavu", "/usr/share/fonts/noto", "C:/Windows/Fonts", "/Library/Fonts", ) TEXT_FONTS = { "bold": _find_font("DejaVuSans-Bold.ttf", *_FONT_DIRS), "regular": _find_font("DejaVuSans.ttf", *_FONT_DIRS), } EMOJI_FONT = _find_font("NotoColorEmoji.ttf", *_FONT_DIRS) 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 = [ ("12 x 40 mm", 12, 40), ("12 x 30 mm", 12, 30), ("12 x 22 mm", 12, 22), ("15 x 30 mm", 15, 30), ("Custom", None, None), ] # (tab label, emoji-data category, tab icon) EMOJI_CATEGORIES = [ ("Smileys", "Smileys & Emotion", "😀"), ("People", "People & Body", "👍"), ("Animals", "Animals & Nature", "🐶"), ("Food", "Food & Drink", "🍎"), ("Travel", "Travel & Places", "🚗"), ("Activities", "Activities", "⚽"), ("Objects", "Objects", "💡"), ("Symbols", "Symbols", "❤️"), ("Flags", "Flags", "🏁"), ] def _rng(a: int, b: int) -> str: return f"{chr(a)}-{chr(b)}" # Matches one emoji cluster: base + optional VS16 / skin tone + ZWJ continuations _EMOJI_BASE = ( "[" + _rng(0x1F000, 0x1FAFF) + _rng(0x2600, 0x27BF) + _rng(0x2B00, 0x2BFF) + _rng(0x2300, 0x23FF) + _rng(0x2190, 0x21FF) + _rng(0x25A0, 0x25FF) + _rng(0x2900, 0x297F) + "".join(chr(c) for c in (0x3030, 0x303D, 0x3297, 0x3299, 0xA9, 0xAE, 0x2122)) + "]" ) _VS = f"[{chr(0xFE0F)}{chr(0xFE0E)}]?" _SKIN = f"[{_rng(0x1F3FB, 0x1F3FF)}]?" _ZWJ = chr(0x200D) EMOJI_RE = re.compile(f"(?:{_EMOJI_BASE}{_VS}{_SKIN}(?:{_ZWJ}{_EMOJI_BASE}{_VS})*)") # --------------------------------------------------------------------------- emoji db def load_emoji_db(): """Return {category label: [(char, name), ...]} ordered like the emoji keyboard.""" by_cat = {label: [] for label, _, _ in EMOJI_CATEGORIES} cat_label = {full: label for label, full, _ in EMOJI_CATEGORIES} for e in sorted(emoji_data_python.emoji_data, key=lambda e: e.sort_order): label = cat_label.get(e.category) if label and not e.obsoleted_by: by_cat[label].append((e.char, e.name.title())) return by_cat # --------------------------------------------------------------------------- render _emoji_font = None _emoji_cache: dict[tuple[str, int], Image.Image] = {} _text_font_cache: dict[tuple[bool, int], ImageFont.FreeTypeFont] = {} def text_font(bold: bool, size: int) -> ImageFont.FreeTypeFont: key = (bold, size) if key not in _text_font_cache: path = TEXT_FONTS["bold" if bold else "regular"] _text_font_cache[key] = ImageFont.truetype(path, size) return _text_font_cache[key] def _emoji_rgba(cluster: str) -> Image.Image: """Render one emoji cluster with Noto Color Emoji, tightly cropped, RGBA.""" global _emoji_font if _emoji_font is None: _emoji_font = ImageFont.truetype(EMOJI_FONT, EMOJI_FONT_SIZE) left, top, right, bottom = _emoji_font.getbbox(cluster) tile = Image.new("RGBA", (max(right, 1), max(bottom, 1)), (255, 255, 255, 0)) ImageDraw.Draw(tile).text((0, 0), cluster, font=_emoji_font, embedded_color=True) return tile.crop((left, top, right, bottom)) def render_emoji(cluster: str, height: int) -> Image.Image: """Render one emoji cluster as a grayscale tile of the given height.""" key = (cluster, height) if key in _emoji_cache: return _emoji_cache[key] tile = _emoji_rgba(cluster) # Flatten alpha onto white, then scale to line height bg = Image.new("RGB", tile.size, "white") bg.paste(tile, mask=tile.split()[3]) scale = height / tile.height tile = bg.resize((max(1, round(tile.width * scale)), height), Image.LANCZOS) # Thermal print is 1-bit: darken mid-tones so pale colours don't dither away gray = tile.convert("L").point(lambda v: round(255 * (v / 255) ** EMOJI_GAMMA)) _emoji_cache[key] = gray return gray def emoji_thumbnail(cluster: str, px: int = PICKER_PX) -> Image.Image: """Colour thumbnail for the picker, rendered by the same font that prints.""" tile = _emoji_rgba(cluster) tile.thumbnail((px, px), Image.LANCZOS) return tile def _tokenize(line: str): """Split a line into ("text", str) and ("emoji", str) runs.""" pos = 0 for m in EMOJI_RE.finditer(line): if m.start() > pos: yield ("text", line[pos : m.start()]) yield ("emoji", m.group()) pos = m.end() if pos < len(line): yield ("text", line[pos:]) def render_text_block(text: str, size: int, bold: bool, align: str) -> Image.Image: """Render multi-line text (with inline emoji) as a tight grayscale block.""" font = text_font(bold, size) ascent, descent = font.getmetrics() emoji_h = round(size * EMOJI_SCALE) line_h = max(ascent + descent, emoji_h) rows = [] for line in text.splitlines() or [""]: runs, width = [], 0 for kind, s in _tokenize(line): if kind == "text": w = round(font.getlength(s)) runs.append(("text", s, w)) else: tile = render_emoji(s, emoji_h) w = tile.width + 2 runs.append(("emoji", tile, w)) width += w rows.append((width, runs)) block_w = max(1, max(w for w, _ in rows)) block_h = line_h * len(rows) img = Image.new("L", (block_w, block_h), 255) draw = ImageDraw.Draw(img) y = 0 for row_w, runs in rows: if align == "left": x = 0 elif align == "right": x = block_w - row_w else: x = (block_w - row_w) // 2 for kind, obj, w in runs: if kind == "text": draw.text((x, y + (line_h - ascent) // 2), obj, font=font, fill=0) else: img.paste(obj, (x + 1, y + (line_h - obj.height) // 2)) x += w y += line_h return img @dataclass class Item: kind: str # "text" | "emoji" text: str x: int # centre, label px y: int size: int = 40 angle: int = 0 # degrees clockwise bold: bool = True align: str = "center" def sprite(self) -> Image.Image: if self.kind == "emoji": img = render_emoji(self.text, max(8, round(self.size * EMOJI_SCALE))) else: img = render_text_block(self.text, max(8, self.size), self.bold, self.align) if self.angle % 360: img = img.rotate( -self.angle, expand=True, fillcolor=255, resample=Image.BICUBIC ) return img @dataclass class Design: width_mm: int = 12 length_mm: int = 40 preset: str = "12 x 40 mm" items: list[Item] = field(default_factory=list) @property def size_px(self) -> tuple[int, int]: """Landscape (width, height): length along x, tape width along y.""" return self.length_mm * PX_PER_MM, self.width_mm * PX_PER_MM def render(self): """Return (grayscale image, [bbox per item]).""" w, h = self.size_px img = Image.new("L", (w, h), 255) boxes = [] for item in self.items: sprite = item.sprite() x0 = item.x - sprite.width // 2 y0 = item.y - sprite.height // 2 img.paste(sprite, (x0, y0), mask=ImageOps.invert(sprite)) boxes.append((x0, y0, x0 + sprite.width, y0 + sprite.height)) return img, boxes def to_dict(self) -> dict: return { "width_mm": self.width_mm, "length_mm": self.length_mm, "preset": self.preset, "items": [asdict(i) for i in self.items], } @classmethod def from_dict(cls, d: dict) -> "Design": return cls( width_mm=int(d.get("width_mm", 12)), length_mm=int(d.get("length_mm", 40)), preset=d.get("preset", "12 x 40 mm" if not d else "Custom"), items=[Item(**i) for i in d.get("items", [])], ) # --------------------------------------------------------------------------- devices def _run(cmd: list[str], timeout: float = 10) -> str: try: kw = {} if sys.platform == "win32": kw["creationflags"] = 0x08000000 # CREATE_NO_WINDOW return subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, **kw ).stdout except (OSError, subprocess.SubprocessError): return "" def find_devices() -> list[tuple[str, str, str]]: """Return [(conn, address, description)] for serial ports and paired BT devices.""" found = [] for port in list_ports.comports(): if port.description and port.description != "n/a": # skip legacy ttyS* found.append(("usb", port.device, port.description)) if sys.platform == "win32": ps = ( "Get-PnpDevice -Class Bluetooth -Status OK | ForEach-Object { " "$a = (Get-PnpDeviceProperty -InstanceId $_.InstanceId " "-KeyName DEVPKEY_Bluetooth_DeviceAddress -ErrorAction SilentlyContinue).Data; " 'if ($a) { "$a|$($_.FriendlyName)" } }' ) for line in _run(["powershell", "-NoProfile", "-Command", ps]).splitlines(): if "|" in line: raw, name = line.strip().split("|", 1) raw = raw.replace(":", "").replace("-", "").upper() if len(raw) == 12: mac = ":".join(raw[i : i + 2] for i in range(0, 12, 2)) found.append(("bluetooth", mac, name)) elif sys.platform.startswith("linux"): for line in _run(["bluetoothctl", "devices"]).splitlines(): parts = line.split(" ", 2) if len(parts) == 3 and parts[0] == "Device": found.append(("bluetooth", parts[1], parts[2])) return found def _looks_like_printer(desc: str) -> bool: d = desc.lower() return any(k in d for k in ("niimbot", "b21", "b1-", "b18", "d11", "d101", "d110")) # --------------------------------------------------------------------------- updates def _version_tuple(v: str) -> tuple[int, ...]: return tuple(int(x) for x in re.findall(r"\d+", v)[:3]) or (0,) def check_for_update() -> dict | None: """Query the releases API; return {version, url, asset_url, notes} if newer.""" req = urllib.request.Request( UPDATE_API, headers={"User-Agent": f"NiimbotLabel/{APP_VERSION}"} ) with urllib.request.urlopen(req, timeout=8) as resp: rel = json.load(resp) version = rel.get("tag_name", "").lstrip("v") if _version_tuple(version) <= _version_tuple(APP_VERSION): return None asset_url = next( ( a["browser_download_url"] for a in rel.get("assets", []) if a["name"] == UPDATE_ASSET ), None, ) return { "version": version, "url": rel.get("html_url", ""), "asset_url": asset_url, "notes": rel.get("body", ""), } def download(url: str, dest: Path, progress=None): req = urllib.request.Request( url, headers={"User-Agent": f"NiimbotLabel/{APP_VERSION}"} ) with urllib.request.urlopen(req, timeout=30) as resp, open(dest, "wb") as out: total = int(resp.headers.get("Content-Length") or 0) done = 0 while chunk := resp.read(256 * 1024): out.write(chunk) done += len(chunk) if progress: progress(done, total) def swap_executable_and_restart(new_file: Path): """Replace the running bundle with new_file once we exit, then relaunch.""" exe = Path(sys.executable) pid = os.getpid() if sys.platform == "win32": script = exe.parent / "NiimbotLabel-update.bat" script.write_text( "@echo off\r\n" f':wait\r\ntasklist /FI "PID eq {pid}" | find "{pid}" >nul && ' "(timeout /t 1 /nobreak >nul & goto wait)\r\n" f'move /y "{new_file}" "{exe}" >nul\r\n' f'start "" "{exe}"\r\n' 'del "%~f0"\r\n' ) subprocess.Popen( ["cmd", "/c", str(script)], creationflags=0x00000008 | 0x00000200, # DETACHED_PROCESS | NEW_PROCESS_GROUP close_fds=True, ) else: script = exe.parent / ".niimbotlabel-update.sh" script.write_text( "#!/bin/sh\n" f"while kill -0 {pid} 2>/dev/null; do sleep 0.5; done\n" f"mv -f '{new_file}' '{exe}' && chmod +x '{exe}'\n" f"rm -f '{script}'\n" f"exec '{exe}'\n" ) script.chmod(0o755) subprocess.Popen( ["/bin/sh", str(script)], start_new_session=True, close_fds=True ) # --------------------------------------------------------------------------- app class LabelApp(ttk.Window): def __init__(self): 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=(1200, 720), ) 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 self.design.items.append( Item("text", self.settings["text"], w // 2, h // 2) ) self.emoji_db = load_emoji_db() self.selected: Item | None = None self._boxes: list[tuple[int, int, int, int]] = [] self._render_job = None self._printer = None self._photo = None self._syncing = False self._drag = None self.zoom = 2 self.preset = tk.StringVar(value=self.design.preset) self.width_mm = tk.IntVar(value=self.design.width_mm) self.length_mm = tk.IntVar(value=self.design.length_mm) self.model = tk.StringVar(value=self.settings.get("model", "d101")) self.conn = tk.StringVar(value=self.settings.get("conn", "bluetooth")) self.addr = tk.StringVar(value=self.settings.get("addr", DEFAULT_ADDR)) self.density = tk.IntVar(value=self.settings.get("density", 3)) self.copies = tk.IntVar(value=1) self.status = tk.StringVar(value="Ready") self.search = tk.StringVar() # selected-item properties self.p_size = tk.IntVar(value=40) self.p_angle = tk.IntVar(value=0) self.p_bold = tk.BooleanVar(value=True) self.p_align = tk.StringVar(value="center") self.p_x = tk.IntVar(value=0) 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()) for var in ( self.p_size, self.p_angle, self.p_bold, self.p_align, self.p_x, self.p_y ): # fmt: skip var.trace_add("write", lambda *_: self._props_changed()) self.search.trace_add("write", lambda *_: self._search_changed()) for var in (self.model, self.conn, self.addr): var.trace_add("write", lambda *_: self._disconnect()) self._apply_preset() self.after(1500, self.check_updates) if not self.design.items: self.add_text("Hello 👋") self._select(self.design.items[-1]) self.protocol("WM_DELETE_WINDOW", self._on_close) # ---- UI construction def _build_ui(self): self.update_bar = ttk.Frame(self, padding=(12, 6), bootstyle="info") self.update_label = ttk.Label(self.update_bar, bootstyle="inverse-info") self.update_label.pack(side="left") ttk.Button( self.update_bar, text="Skip this version", bootstyle="info-link", command=lambda: self._dismiss_update(skip=True), ).pack(side="right") # fmt: skip ttk.Button( self.update_bar, text="Later", bootstyle="info-link", command=self._dismiss_update, ).pack(side="right") # fmt: skip self.update_notes_btn = ttk.Button( self.update_bar, text="Release notes", bootstyle="info-outline", command=lambda: webbrowser.open(self._update["url"]), ) # fmt: skip self.update_notes_btn.pack(side="right", padx=(0, 8)) self.update_btn = ttk.Button( self.update_bar, text="Update now", bootstyle="light", command=self.apply_update, ) self.update_btn.pack(side="right", padx=(0, 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, 12)) right = ttk.Frame(root) right.grid(row=0, column=1, sticky="nsew") right.columnconfigure(0, weight=1) right.rowconfigure(2, weight=1) self._build_size_box(left) self._build_emoji_picker(left) self._build_printer_box(left) self._build_toolbar(right) self._build_canvas(right) self._build_props(right) self._build_actions(right) def _build_size_box(self, parent): box = ttk.Labelframe(parent, text="Label size", padding=10) box.pack(fill="x") ttk.Combobox( box, textvariable=self.preset, values=[p[0] for p in LABEL_PRESETS], state="readonly", width=12, ).grid(row=0, column=0, columnspan=2, sticky="w") ttk.Label(box, text="W mm").grid(row=0, column=2, sticky="e", padx=(8, 2)) self.width_spin = ttk.Spinbox( box, from_=6, to=60, textvariable=self.width_mm, width=4 ) self.width_spin.grid(row=0, column=3) ttk.Label(box, text="L mm").grid(row=0, column=4, sticky="e", padx=(6, 2)) self.length_spin = ttk.Spinbox( box, from_=10, to=200, textvariable=self.length_mm, width=4 ) self.length_spin.grid(row=0, column=5) def _build_emoji_picker(self, parent): 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=" ", bootstyle="secondary") self.hover_name.pack(fill="x", pady=(4, 2)) self.nb = ttk.Notebook(box, width=400, height=300) self.nb.pack(fill="both", expand=True) self.emoji_index: dict[tk.Text, list[tuple[str, str]]] = {} self._tab_icons = [] for label, _, icon in EMOJI_CATEGORIES: photo = ImageTk.PhotoImage(emoji_thumbnail(icon, 22)) self._tab_icons.append(photo) self._make_emoji_tab(label, self.emoji_db[label], image=photo) self.search_tab = self._make_emoji_tab("🔍", []) self.nb.hide(self.search_tab.master) self.nb.bind("<>", self._tab_changed) def _make_emoji_tab(self, label, entries, image=None) -> tk.Text: frame = ttk.Frame(self.nb) frame.category = label if image is not None: self.nb.add(frame, image=image, padding=(6, 2)) else: self.nb.add(frame, text=label) txt = tk.Text( frame, width=1, # let the notebook decide the width height=1, wrap="char", cursor="hand2", spacing1=2, spacing3=2, padx=4, relief="flat", highlightthickness=0, ) sb = ttk.Scrollbar(frame, command=txt.yview) txt.configure(yscrollcommand=sb.set) sb.pack(side="right", fill="y") txt.pack(side="left", fill="both", expand=True) txt.bind("", self._picker_click) txt.bind("", self._picker_hover) txt.bind("", lambda e: self.hover_name.configure(text=" ")) self._fill_emoji_tab(txt, entries) return txt def _fill_emoji_tab(self, txt: tk.Text, entries): txt.configure(state="normal") txt.delete("1.0", "end") photos = [] for i, (char, _name) in enumerate(entries): photo = ImageTk.PhotoImage(emoji_thumbnail(char)) photos.append(photo) # keep references alive txt.image_create("end", image=photo, padx=3, pady=1) txt.tag_add(f"e{i}", "end-2c", "end-1c") txt.configure(state="disabled") txt.photos = photos self.emoji_index[txt] = list(entries) def _picker_entry(self, event): txt = event.widget for tag in txt.tag_names(f"@{event.x},{event.y}"): if tag.startswith("e") and tag[1:].isdigit(): return self.emoji_index[txt][int(tag[1:])] return None def _tab_changed(self, _event=None): frame = self.nametowidget(self.nb.select()) self.hover_name.configure(text=getattr(frame, "category", " ")) def _picker_hover(self, event): entry = self._picker_entry(event) self.hover_name.configure(text=entry[1] if entry else " ") def _picker_click(self, event): entry = self._picker_entry(event) if entry: self.add_emoji(entry[0]) return "break" def _search_changed(self): q = self.search.get().strip().lower() if not q: self.nb.hide(self.search_tab.master) return hits = [ (c, n) for entries in self.emoji_db.values() for c, n in entries if q in n.lower() ][:300] self._fill_emoji_tab(self.search_tab, hits) self.nb.add(self.search_tab.master, text=f"🔍 {len(hits)}") self.nb.select(self.search_tab.master) def _build_printer_box(self, parent): 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 ).grid(row=0, column=1, sticky="w") ttk.Label(box, text="Via").grid(row=0, column=2, sticky="w", padx=(8, 0)) ttk.Combobox( box, textvariable=self.conn, values=["bluetooth", "usb"], state="readonly", width=9, ).grid(row=0, column=3, sticky="w") ttk.Label(box, text="Address").grid(row=1, column=0, sticky="w") ttk.Entry(box, textvariable=self.addr, width=16).grid( row=1, column=1, columnspan=2, sticky="we", pady=(4, 0) ) ttk.Button( box, text="Find…", command=self.find_printer, bootstyle="secondary-outline" ).grid(row=1, column=3, sticky="e", padx=(6, 0), pady=(4, 0)) ttk.Label(box, text="Density").grid(row=2, column=0, sticky="w") ttk.Spinbox(box, from_=1, to=5, textvariable=self.density, width=4).grid( row=2, column=1, sticky="w", pady=(4, 0) ) ttk.Label(box, text="Copies").grid(row=2, column=2, sticky="w", padx=(8, 0)) ttk.Spinbox(box, from_=1, to=20, textvariable=self.copies, width=4).grid( row=2, column=3, sticky="w", pady=(4, 0) ) def _build_toolbar(self, parent): bar = ttk.Frame(parent) bar.grid(row=0, column=0, sticky="we") 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=8) btn("+ Text", lambda: self.add_text("Text"), "primary") btn("Duplicate", self.duplicate_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) sep() btn("Center H", lambda: self.center("h")) btn("Center V", lambda: self.center("v")) btn("Fit", self.fit_selected) def _build_canvas(self, parent): ttk.Label( parent, 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) self.canvas.bind("", lambda e: setattr(self, "_drag", None)) for key, dx, dy in ( ("Left", -1, 0), ("Right", 1, 0), ("Up", 0, -1), ("Down", 0, 1), ): self.canvas.bind(f"<{key}>", lambda e, dx=dx, dy=dy: self.nudge(dx, dy)) self.canvas.bind( f"", lambda e, dx=dx, dy=dy: self.nudge(dx * 10, dy * 10) ) self.canvas.bind("", lambda e: self.delete_selected()) self.canvas.bind("", lambda e: self.delete_selected()) 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=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=20, 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) grid = ttk.Frame(box) grid.grid(row=0, column=1, sticky="n", padx=(10, 0)) ttk.Label(grid, text="Size").grid(row=0, column=0, sticky="w") ttk.Spinbox(grid, from_=8, to=300, textvariable=self.p_size, width=5).grid( row=0, column=1, sticky="w" ) ttk.Label(grid, text="Angle").grid(row=0, column=2, sticky="w", padx=(10, 0)) ttk.Spinbox( grid, from_=-180, to=359, increment=15, textvariable=self.p_angle, width=5 ).grid(row=0, column=3, sticky="w") ttk.Label(grid, text="X").grid(row=1, column=0, sticky="w", pady=(4, 0)) ttk.Spinbox(grid, from_=-500, to=2000, textvariable=self.p_x, width=5).grid( row=1, column=1, sticky="w", pady=(4, 0) ) ttk.Label(grid, text="Y").grid(row=1, column=2, sticky="w", padx=(10, 0)) ttk.Spinbox(grid, from_=-500, to=2000, textvariable=self.p_y, width=5).grid( row=1, column=3, sticky="w", pady=(4, 0) ) style = ttk.Frame(grid) style.grid(row=2, column=0, columnspan=4, sticky="w", pady=(4, 0)) self.bold_check = ttk.Checkbutton(style, text="Bold", variable=self.p_bold) self.bold_check.pack(side="left") self.align_btns = [] for a in ("left", "center", "right"): b = ttk.Radiobutton(style, text=a.title(), value=a, variable=self.p_align) b.pack(side="left", padx=(6, 0)) self.align_btns.append(b) def _build_actions(self, parent): bar = ttk.Frame(parent) 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, ) 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) self.theme_btn = ttk.Button( bar, text="☾", width=3, bootstyle="secondary-link", command=self.toggle_theme, ) self.theme_btn.pack(side="right") for text, cmd in ( ("Save…", self.save_design), ("Open…", self.open_design), ("New", self.new_design), ): ttk.Button(bar, text=text, command=cmd, bootstyle="link").pack(side="right") ttk.Label(bar, textvariable=self.status, bootstyle="secondary").pack( side="right", padx=(0, 12) ) ttk.Button( bar, text=f"v{APP_VERSION}", bootstyle="secondary-link", command=lambda: self.check_updates(manual=True), ).pack(side="left", padx=(12, 0)) # fmt: skip # ---- 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 def _apply_preset(self): for name, w, length in LABEL_PRESETS: if name == self.preset.get() and w is not None: self.width_mm.set(w) self.length_mm.set(length) state = "disabled" break else: state = "normal" self.width_spin.configure(state=state) self.length_spin.configure(state=state) self.design.preset = self.preset.get() self._size_changed() def _size_changed(self): try: self.design.width_mm = max(1, self.width_mm.get()) self.design.length_mm = max(1, self.length_mm.get()) except tk.TclError: return w, h = self.design.size_px self.zoom = max(1, min(4, 720 // w)) self.canvas.configure(width=w * self.zoom, height=h * self.zoom) self.schedule_render() # ---- items def add_text(self, text: str): w, h = self.design.size_px item = Item("text", text, w // 2, h // 2, size=min(40, h // 2)) self.design.items.append(item) self._select(item) def add_emoji(self, char: str): w, h = self.design.size_px n = sum(1 for i in self.design.items if i.kind == "emoji") x = min(w - 20, 30 + n * 20) if n else w // 2 size = min(40, round(h / EMOJI_SCALE) - 8) item = Item("emoji", char, x, h // 2, size=size) self.design.items.append(item) self._select(item) def duplicate_selected(self): if self.selected: copy = Item(**asdict(self.selected)) copy.x += 10 copy.y += 10 self.design.items.append(copy) self._select(copy) def delete_selected(self): if self.selected in self.design.items: self.design.items.remove(self.selected) self._select(self.design.items[-1] if self.design.items else None) def rotate(self, delta: int): if self.selected: self.p_angle.set((self.selected.angle + delta) % 360) def center(self, axis: str): if self.selected: w, h = self.design.size_px if axis == "h": self.p_x.set(w // 2) else: self.p_y.set(h // 2) def fit_selected(self): """Resize the selected item to fill the label, and centre it.""" item = self.selected if not item: return w, h = self.design.size_px size = 300 while size > 8: item.size = size sp = item.sprite() if sp.width <= w - 8 and sp.height <= h - 8: break size -= max(1, size // 20) self.p_size.set(item.size) self.p_x.set(w // 2) self.p_y.set(h // 2) def nudge(self, dx: int, dy: int): if self.selected: self.p_x.set(self.selected.x + dx) self.p_y.set(self.selected.y + dy) def _select(self, item: Item | None): self.selected = item self._syncing = True try: self.prop_text.configure(state="normal") self.prop_text.delete("1.0", "end") if item: self.prop_text.insert("1.0", item.text) self.p_size.set(item.size) self.p_angle.set(item.angle) self.p_bold.set(item.bold) self.p_align.set(item.align) self.p_x.set(item.x) self.p_y.set(item.y) self.prop_text.edit_modified(False) is_text = bool(item and item.kind == "text") self.prop_text.configure(state="normal" if is_text else "disabled") self.bold_check.configure(state="normal" if is_text else "disabled") for b in self.align_btns: b.configure(state="normal" if is_text else "disabled") finally: self._syncing = False self.schedule_render() def _props_changed(self): if self._syncing or not self.selected: return try: self.selected.size = self.p_size.get() self.selected.angle = self.p_angle.get() self.selected.bold = self.p_bold.get() self.selected.align = self.p_align.get() self.selected.x = self.p_x.get() self.selected.y = self.p_y.get() except tk.TclError: return # spinbox mid-edit self.schedule_render() def _prop_text_modified(self, _event=None): if self.prop_text.edit_modified(): self.prop_text.edit_modified(False) if not self._syncing and self.selected and self.selected.kind == "text": self.selected.text = self.prop_text.get("1.0", "end-1c") self.schedule_render() # ---- canvas def _canvas_press(self, event): self.canvas.focus_set() x, y = event.x / self.zoom, event.y / self.zoom hit = None for item, box in reversed(list(zip(self.design.items, self._boxes))): if box[0] - 3 <= x <= box[2] + 3 and box[1] - 3 <= y <= box[3] + 3: hit = item break if hit is not self.selected: self._select(hit) if hit: self._drag = (event.x, event.y, hit.x, hit.y) def _canvas_drag(self, event): if not self._drag or not self.selected: return sx, sy, ox, oy = self._drag self.p_x.set(ox + round((event.x - sx) / self.zoom)) self.p_y.set(oy + round((event.y - sy) / self.zoom)) def schedule_render(self): if self._render_job: self.after_cancel(self._render_job) self._render_job = self.after(30, self.render_canvas) def render_canvas(self): self._render_job = None try: img, self._boxes = self.design.render() except (ValueError, OSError) as exc: self.status.set(f"Render error: {exc}") return preview = img.convert("1").convert("L") # dithered, as the printer sees it z = self.zoom self._photo = ImageTk.PhotoImage( preview.resize((img.width * z, img.height * z), Image.NEAREST) ) self.canvas.delete("all") self.canvas.create_image(0, 0, image=self._photo, anchor="nw") if self.selected in self.design.items: 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=self._sel_color, width=2, dash=(4, 3), ) # fmt: skip w, h = img.size model = MODELS[self.model.get()] warn = "" if h > model.max_width_px: warn = f" ⚠ too wide for {model.name.upper()} (max {model.max_width_px}px)" self.size_label.configure( text=f"{w} x {h} px ({self.design.length_mm} x {self.design.width_mm} mm)" f" · {len(self.design.items)} items{warn}" ) # ---- designs def new_design(self): if ( self.design.items and Messagebox.yesno("Clear all items?", "New label", parent=self) != "Yes" ): return self.design.items.clear() self._select(None) def save_design(self): path = filedialog.asksaveasfilename( defaultextension=".json", filetypes=[("Label design", "*.json")] ) if path: Path(path).write_text(json.dumps(self.design.to_dict(), indent=2)) self.status.set(f"Saved {Path(path).name}") def open_design(self): path = filedialog.askopenfilename(filetypes=[("Label design", "*.json")]) if not path: return try: self.design = Design.from_dict(json.loads(Path(path).read_text())) except (OSError, ValueError, TypeError) as 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) self.length_mm.set(self.design.length_mm) self._select(self.design.items[-1] if self.design.items else None) def save_png(self): path = filedialog.asksaveasfilename( defaultextension=".png", filetypes=[("PNG image", "*.png")] ) if path: self.design.render()[0].convert("1").save(path) self.status.set(f"Saved {Path(path).name}") # ---- device discovery def find_printer(self): """List serial ports and paired Bluetooth devices; pick one to fill in.""" self.status.set("Searching for devices…") self.update_idletasks() devices = find_devices() self.status.set("Ready") if not devices: Messagebox.show_info( "No serial ports or paired Bluetooth devices found.\n\n" "Pair the printer in your OS Bluetooth settings first, or plug in USB.", "Find printer", parent=self, ) return devices.sort(key=lambda d: (not _looks_like_printer(d[2]), d[0], d[2])) win = ttk.Toplevel(self) win.title("Select printer") win.transient(self) win.resizable(False, False) frame = ttk.Frame(win, padding=12) frame.pack(fill="both", expand=True) ttk.Label(frame, text="Serial ports and paired Bluetooth devices:").pack( anchor="w" ) cols = ("via", "address", "name") tree = ttk.Treeview(frame, columns=cols, show="headings", height=8) for c, w in zip(cols, (90, 170, 320)): tree.heading(c, text=c.title()) tree.column(c, width=w, anchor="w") for conn, addr, desc in devices: tag = "printer" if _looks_like_printer(desc) else "" tree.insert("", "end", values=(conn, addr, desc), tags=(tag,)) tree.tag_configure("printer", foreground=self.style.colors.success) tree.pack(fill="both", expand=True, pady=(6, 8)) if tree.get_children(): tree.selection_set(tree.get_children()[0]) def choose(_e=None): sel = tree.selection() if sel: conn, addr, _ = tree.item(sel[0], "values") self.conn.set(conn) self.addr.set(addr) self.status.set(f"Printer set to {addr}") win.destroy() tree.bind("", choose) btns = ttk.Frame(frame) btns.pack(fill="x") ttk.Button(btns, text="Use selected", command=choose, bootstyle="primary").pack( side="right" ) ttk.Button(btns, text="Cancel", command=win.destroy, bootstyle="link").pack( side="right", padx=(0, 6) ) win.grab_set() # ---- updates def check_updates(self, manual=False): def work(): try: info = check_for_update() except Exception as exc: # noqa: BLE001 — offline is fine self.after(0, self._update_checked, None, manual, str(exc)) return self.after(0, self._update_checked, info, manual, None) if manual: self.status.set("Checking for updates…") threading.Thread(target=work, daemon=True).start() def _update_checked(self, info, manual, error): if error: if manual: self.status.set("Update check failed") Messagebox.show_warning( f"Could not reach the releases page:\n{error}", "Updates", parent=self, ) return if not info: if manual: self.status.set(f"v{APP_VERSION} is the latest version") return if not manual and self.settings.get("skip_version") == info["version"]: return self._update = info self.status.set(f"Update available: v{info['version']}") self.update_label.configure( text=f"Version {info['version']} is available (you have {APP_VERSION})." ) can_self_update = FROZEN and info["asset_url"] self.update_btn.configure( text="Update now" if can_self_update else "Download", command=self.apply_update if can_self_update else lambda: webbrowser.open(info["url"]), ) self.update_bar.pack(fill="x", side="top", before=self.winfo_children()[1]) def _dismiss_update(self, skip=False): self.update_bar.pack_forget() if skip and getattr(self, "_update", None): self.settings["skip_version"] = self._update["version"] self._save_settings() def apply_update(self): info = self._update self.update_btn.configure(state="disabled") dest = Path(tempfile.gettempdir()) / f"{UPDATE_ASSET}.new" def progress(done, total): pct = f" {done * 100 // total}%" if total else "" self.after(0, self.status.set, f"Downloading v{info['version']}…{pct}") def work(): try: download(info["asset_url"], dest, progress) if sys.platform != "win32": dest.chmod(0o755) self.after(0, self._update_downloaded, dest, None) except Exception as exc: # noqa: BLE001 self.after(0, self._update_downloaded, dest, exc) threading.Thread(target=work, daemon=True).start() def _update_downloaded(self, dest: Path, exc): self.update_btn.configure(state="normal") if exc: self.status.set("Update failed") Messagebox.show_error(str(exc), "Update failed", parent=self) return self._save_settings() try: swap_executable_and_restart(dest) except OSError as exc: Messagebox.show_error( f"Downloaded to {dest} but could not replace the program:\n{exc}", "Update failed", parent=self, ) return self.destroy() # ---- printing def _connect(self) -> PrinterClient: if self._printer is None: addr = self.addr.get().strip() if self.conn.get() == "bluetooth": if not re.fullmatch(r"([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}", addr): raise ValueError("Enter the printer's Bluetooth MAC address") transport = BluetoothTransport(addr.upper()) else: transport = SerialTransport(port=addr or "auto") self._printer = PrinterClient(transport) return self._printer def _disconnect(self): self._printer = None def print_label(self): try: image = self.design.render()[0].convert("1") image, density = prepare_print( self.model.get(), image, self.density.get(), rotate=90 ) except (ValueError, tk.TclError) as exc: Messagebox.show_error(str(exc), "Cannot print", parent=self) return copies = max(1, self.copies.get()) self.print_btn.configure(state="disabled") self.status.set("Connecting…") self._save_settings() def work(): try: printer = self._connect() for i in range(copies): self.after(0, self.status.set, f"Printing {i + 1}/{copies}…") printer.print_image(image, density=density, model=self.model.get()) self.after(0, self._print_done, None) except Exception as exc: # noqa: BLE001 — surface everything to the UI self._disconnect() self.after(0, self._print_done, exc) threading.Thread(target=work, daemon=True).start() def _print_done(self, exc): self.print_btn.configure(state="normal") if exc is None: self.status.set("Printed") else: self.status.set("Print failed") is_prn = isinstance(exc, PrinterError) kind = "Printer error" if is_prn else type(exc).__name__ Messagebox.show_error(str(exc) or repr(exc), kind, parent=self) # ---- settings def _load_settings(self) -> dict: try: return json.loads(SETTINGS_PATH.read_text()) except (OSError, ValueError): return {} def _save_settings(self): data = { "model": self.model.get(), "conn": self.conn.get(), "addr": self.addr.get(), "density": self.density.get(), "theme": self.theme_mode, "skip_version": self.settings.get("skip_version"), "design": self.design.to_dict(), } try: SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) SETTINGS_PATH.write_text(json.dumps(data, indent=2)) except (OSError, tk.TclError): pass def _on_close(self): self._save_settings() self.destroy() if __name__ == "__main__": LabelApp().mainloop()