Files
Gracious 6f6aaca1bf Add printer discovery and self-update; v0.2.1
- Find… button lists serial ports and paired Bluetooth devices (bluetoothctl
  on Linux, PnP device properties on Windows) and fills in the connection
- Startup check against the releases API; update bar with Update now /
  Release notes / Later / Skip this version; manual check via the version
  link. Standalone builds download the new executable and replace
  themselves via a small handoff script, then relaunch
- README: connection setup and updater notes
2026-09-21 08:59:05 +01:00

1335 lines
49 KiB
Python

"""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("<<NotebookTabChanged>>", 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("<Button-1>", self._picker_click)
txt.bind("<Motion>", self._picker_hover)
txt.bind("<Leave>", 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("<ButtonPress-1>", self._canvas_press)
self.canvas.bind("<B1-Motion>", self._canvas_drag)
self.canvas.bind("<ButtonRelease-1>", 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"<Shift-{key}>", lambda e, dx=dx, dy=dy: self.nudge(dx * 10, dy * 10)
)
self.canvas.bind("<Delete>", lambda e: self.delete_selected())
self.canvas.bind("<BackSpace>", 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("<<Modified>>", 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("<Double-1>", 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()