Modern flat widgets, coloured primary/danger/success buttons, themed canvas/text backgrounds, and a light/dark toggle remembered in settings.
970 lines
35 KiB
Python
970 lines
35 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 re
|
|
import threading
|
|
import tkinter as tk
|
|
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 ttkbootstrap.dialogs import Messagebox
|
|
|
|
from niimprint import (
|
|
MODELS,
|
|
BluetoothTransport,
|
|
PrinterClient,
|
|
PrinterError,
|
|
SerialTransport,
|
|
prepare_print,
|
|
)
|
|
|
|
PX_PER_MM = 8
|
|
SETTINGS_PATH = Path.home() / ".config" / "niimprint-label" / "settings.json"
|
|
DEFAULT_ADDR = "94:11:02:66:16:4D" # your D101 — change in the app, it is remembered
|
|
|
|
TEXT_FONTS = {
|
|
"bold": "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
|
"regular": "/usr/share/fonts/TTF/DejaVuSans.ttf",
|
|
}
|
|
EMOJI_FONT = "/usr/share/fonts/noto/NotoColorEmoji.ttf"
|
|
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),
|
|
]
|
|
|
|
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", [])],
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- 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=(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
|
|
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()
|
|
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):
|
|
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=420, height=260)
|
|
self.nb.pack(fill="both", expand=True)
|
|
self.emoji_index: dict[tk.Text, list[tuple[str, str]]] = {}
|
|
for label, _ in EMOJI_CATEGORIES:
|
|
self._make_emoji_tab(label, self.emoji_db[label])
|
|
self.search_tab = self._make_emoji_tab("Search", [])
|
|
self.nb.hide(self.search_tab.master)
|
|
|
|
def _make_emoji_tab(self, label, entries) -> tk.Text:
|
|
frame = ttk.Frame(self.nb)
|
|
self.nb.add(frame, text=label)
|
|
txt = tk.Text(
|
|
frame,
|
|
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 _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"Search ({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=22).grid(
|
|
row=1, column=1, columnspan=3, sticky="we", 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)
|
|
sep()
|
|
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 = 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=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("<<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)
|
|
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
|
|
|
|
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}")
|
|
|
|
# ---- 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,
|
|
"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()
|