Add Tkinter label designer with emoji picker
label_app.py: label size presets, multi-line text with auto-fit / bold / alignment, tabbed emoji picker (rendered via Noto Color Emoji, gamma-darkened and dithered for the thermal head), live 1-bit preview, save PNG, print in a background thread. Settings persist in ~/.config/niimprint-label/. main.py is the earlier minimal GUI it supersedes.
This commit is contained in:
+509
@@ -0,0 +1,509 @@
|
||||
"""Simple label designer for Niimbot printers: text + emoji, live preview, print."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageTk
|
||||
|
||||
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
|
||||
|
||||
# (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": "😀 😃 😄 😁 😆 😅 😂 🙂 😉 😊 😍 😘 😎 🤔 😐 😴 😷 🤒 😭 😡 🤯 🥳 🤠 🤡 👻 💀 👽 🤖 😺",
|
||||
"People": "👍 👎 👋 ✌️ 🤞 👌 🙏 💪 👀 🧠 👶 👧 👦 👩 👨 👵 👴 🧑💻 👩🔬 👨🍳 🧑🔧 👮 🕵️ 💁",
|
||||
"Home": "🏠 🛏️ 🛋️ 🚪 🪟 🧹 🧺 🧼 🧽 🪥 🧻 🪣 🔑 🔒 🔓 💡 🔌 🔋 🪛 🔧 🔨 🪚 🧰 📦 🗑️ ♻️",
|
||||
"Food": "🍎 🍌 🍇 🍓 🍒 🥕 🌽 🥦 🧄 🧅 🍞 🧀 🥚 🥛 🍗 🥩 🍕 🍔 🌮 🍝 🍚 🍪 🍰 ☕ 🍵 🧃 🍺 🍷 🧂 🍯",
|
||||
"Objects": "📱 💻 🖥️ ⌨️ 🖱️ 🖨️ 📷 🎧 📚 📖 📝 ✏️ 📎 📌 📏 ✂️ 🗂️ 📁 📅 ⏰ 💊 🩹 🧪 🔬 🔭 🎁 🎈 🧸 🎮 🎲",
|
||||
"Nature": "🌞 🌙 ⭐ ⚡ 🔥 💧 ❄️ 🌈 🌸 🌻 🌵 🌲 🍀 🐶 🐱 🐭 🐰 🦊 🐻 🐼 🐸 🐢 🐝 🦋 🐟",
|
||||
"Symbols": "❤️ 🧡 💛 💚 💙 💜 🖤 ✅ ❌ ⚠️ ⛔ 🚫 ❗ ❓ 💯 🔴 🟢 🔵 ⬆️ ⬇️ ➡️ ⬅️ ♥️ ★ ☆ ✔ ✖ ☠",
|
||||
"Transport": "🚗 🚕 🚌 🚲 🛵 🏍️ 🚂 ✈️ 🚀 ⛵ 🚁 🛒 🧳 🗺️ 🧭",
|
||||
}
|
||||
|
||||
# Matches one emoji cluster: base + optional VS16 + any ZWJ-joined continuations
|
||||
_EMOJI_BASE = (
|
||||
r"[\U0001F000-\U0001FAFF\u2600-\u27BF\u2B00-\u2BFF\u2300-\u23FF\u2190-\u21FF"
|
||||
r"\u25A0-\u25FF\u2900-\u297F\u3030\u303D\u3297\u3299\u00A9\u00AE\u2122]"
|
||||
)
|
||||
EMOJI_RE = re.compile(
|
||||
rf"(?:{_EMOJI_BASE}[\uFE0F\uFE0E]?[\U0001F3FB-\U0001F3FF]?"
|
||||
rf"(?:\u200D{_EMOJI_BASE}[\uFE0F\uFE0E]?)*)"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- render
|
||||
|
||||
|
||||
_emoji_font = None
|
||||
_emoji_cache: dict[tuple[str, int], Image.Image] = {}
|
||||
|
||||
|
||||
def render_emoji(cluster: str, height: int) -> Image.Image:
|
||||
"""Render one emoji cluster as a grayscale tile of the given height."""
|
||||
global _emoji_font
|
||||
key = (cluster, height)
|
||||
if key in _emoji_cache:
|
||||
return _emoji_cache[key]
|
||||
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)
|
||||
tile = tile.crop((left, top, right, bottom))
|
||||
# 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 _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 _layout(lines, font, font_size):
|
||||
"""Return (rows, block_w, block_h). Each row is (width, [(kind, obj, w)])."""
|
||||
emoji_h = round(font_size * 1.15)
|
||||
ascent, descent = font.getmetrics()
|
||||
line_h = max(ascent + descent, emoji_h)
|
||||
rows = []
|
||||
for line in lines:
|
||||
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((w for w, _ in rows), default=0)
|
||||
return rows, block_w, line_h * len(rows), line_h
|
||||
|
||||
|
||||
def render_label(
|
||||
text: str,
|
||||
width_px: int,
|
||||
height_px: int,
|
||||
font_size: int = 40,
|
||||
bold: bool = True,
|
||||
align: str = "center",
|
||||
auto_fit: bool = True,
|
||||
padding: int = 4,
|
||||
) -> Image.Image:
|
||||
"""Render text (with emoji) into a landscape grayscale label image."""
|
||||
lines = text.splitlines() or [""]
|
||||
font_path = TEXT_FONTS["bold" if bold else "regular"]
|
||||
size = font_size
|
||||
while True:
|
||||
font = ImageFont.truetype(font_path, size)
|
||||
rows, block_w, block_h, line_h = _layout(lines, font, size)
|
||||
fits = block_w <= width_px - 2 * padding and block_h <= height_px - 2 * padding
|
||||
if fits or not auto_fit or size <= 8:
|
||||
break
|
||||
size -= 1
|
||||
|
||||
img = Image.new("L", (width_px, height_px), 255)
|
||||
draw = ImageDraw.Draw(img)
|
||||
ascent, _ = font.getmetrics()
|
||||
y = (height_px - block_h) // 2
|
||||
for row_w, runs in rows:
|
||||
if align == "left":
|
||||
x = padding
|
||||
elif align == "right":
|
||||
x = width_px - padding - row_w
|
||||
else:
|
||||
x = (width_px - row_w) // 2
|
||||
for kind, obj, w in runs:
|
||||
if kind == "text":
|
||||
# Baseline-align text within the row
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- app
|
||||
|
||||
|
||||
class LabelApp(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("Niimbot Label Designer")
|
||||
self.minsize(900, 560)
|
||||
self.settings = self._load_settings()
|
||||
self._preview_job = None
|
||||
self._printer = None
|
||||
self._preview_photo = None
|
||||
|
||||
self.preset = tk.StringVar(value=self.settings.get("preset", "12 x 40 mm"))
|
||||
self.width_mm = tk.IntVar(value=self.settings.get("width_mm", 12))
|
||||
self.length_mm = tk.IntVar(value=self.settings.get("length_mm", 40))
|
||||
self.font_size = tk.IntVar(value=self.settings.get("font_size", 40))
|
||||
self.auto_fit = tk.BooleanVar(value=self.settings.get("auto_fit", True))
|
||||
self.bold = tk.BooleanVar(value=self.settings.get("bold", True))
|
||||
self.align = tk.StringVar(value=self.settings.get("align", "center"))
|
||||
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._build_ui()
|
||||
for var in (
|
||||
self.width_mm, self.length_mm, self.font_size, self.auto_fit,
|
||||
self.bold, self.align,
|
||||
): # fmt: skip
|
||||
var.trace_add("write", lambda *_: self.schedule_preview())
|
||||
self.preset.trace_add("write", lambda *_: self._apply_preset())
|
||||
self._apply_preset()
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
# ---- UI construction
|
||||
|
||||
def _build_ui(self):
|
||||
root = ttk.Frame(self, padding=10)
|
||||
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, 10))
|
||||
right = ttk.Frame(root)
|
||||
right.grid(row=0, column=1, sticky="nsew")
|
||||
right.rowconfigure(1, weight=1)
|
||||
right.columnconfigure(0, weight=1)
|
||||
|
||||
# Label size
|
||||
size_box = ttk.LabelFrame(left, text="Label", padding=6)
|
||||
size_box.pack(fill="x")
|
||||
ttk.Combobox(
|
||||
size_box,
|
||||
textvariable=self.preset,
|
||||
values=[p[0] for p in LABEL_PRESETS],
|
||||
state="readonly",
|
||||
width=14,
|
||||
).grid(row=0, column=0, columnspan=4, sticky="w")
|
||||
ttk.Label(size_box, text="Width mm").grid(row=1, column=0, sticky="w")
|
||||
self.width_spin = ttk.Spinbox(
|
||||
size_box, from_=6, to=60, textvariable=self.width_mm, width=5
|
||||
)
|
||||
self.width_spin.grid(row=1, column=1)
|
||||
ttk.Label(size_box, text="Length mm").grid(row=1, column=2, sticky="w")
|
||||
self.length_spin = ttk.Spinbox(
|
||||
size_box, from_=10, to=200, textvariable=self.length_mm, width=5
|
||||
)
|
||||
self.length_spin.grid(row=1, column=3)
|
||||
|
||||
# Text style
|
||||
style_box = ttk.LabelFrame(left, text="Text", padding=6)
|
||||
style_box.pack(fill="x", pady=(8, 0))
|
||||
ttk.Label(style_box, text="Size").grid(row=0, column=0, sticky="w")
|
||||
ttk.Spinbox(
|
||||
style_box, from_=8, to=200, textvariable=self.font_size, width=5
|
||||
).grid(row=0, column=1, sticky="w")
|
||||
ttk.Checkbutton(style_box, text="Auto-fit", variable=self.auto_fit).grid(
|
||||
row=0, column=2, sticky="w", padx=(8, 0)
|
||||
)
|
||||
ttk.Checkbutton(style_box, text="Bold", variable=self.bold).grid(
|
||||
row=0, column=3, sticky="w"
|
||||
)
|
||||
align_row = ttk.Frame(style_box)
|
||||
align_row.grid(row=1, column=0, columnspan=4, sticky="w", pady=(4, 0))
|
||||
for a in ("left", "center", "right"):
|
||||
ttk.Radiobutton(
|
||||
align_row, text=a.title(), value=a, variable=self.align
|
||||
).pack(side="left")
|
||||
|
||||
# Emoji picker
|
||||
emoji_box = ttk.LabelFrame(left, text="Emoji (click to insert)", padding=4)
|
||||
emoji_box.pack(fill="both", expand=True, pady=(8, 0))
|
||||
nb = ttk.Notebook(emoji_box)
|
||||
nb.pack(fill="both", expand=True)
|
||||
for name, emojis in EMOJI_CATEGORIES.items():
|
||||
tab = ttk.Frame(nb)
|
||||
nb.add(tab, text=name)
|
||||
for i, e in enumerate(emojis.split()):
|
||||
tk.Button(
|
||||
tab,
|
||||
text=e,
|
||||
font=("Noto Color Emoji", 14),
|
||||
width=2,
|
||||
relief="flat",
|
||||
command=lambda e=e: self.insert_emoji(e),
|
||||
).grid(row=i // 8, column=i % 8, padx=1, pady=1)
|
||||
|
||||
# Printer
|
||||
prn_box = ttk.LabelFrame(left, text="Printer", padding=6)
|
||||
prn_box.pack(fill="x", pady=(8, 0))
|
||||
ttk.Label(prn_box, text="Model").grid(row=0, column=0, sticky="w")
|
||||
ttk.Combobox(
|
||||
prn_box,
|
||||
textvariable=self.model,
|
||||
values=list(MODELS),
|
||||
state="readonly",
|
||||
width=6,
|
||||
).grid(row=0, column=1, sticky="w")
|
||||
ttk.Label(prn_box, text="Via").grid(row=0, column=2, sticky="w", padx=(8, 0))
|
||||
ttk.Combobox(
|
||||
prn_box,
|
||||
textvariable=self.conn,
|
||||
values=["bluetooth", "usb"],
|
||||
state="readonly",
|
||||
width=9,
|
||||
).grid(row=0, column=3, sticky="w")
|
||||
ttk.Label(prn_box, text="Address").grid(row=1, column=0, sticky="w")
|
||||
ttk.Entry(prn_box, textvariable=self.addr, width=22).grid(
|
||||
row=1, column=1, columnspan=3, sticky="we", pady=(4, 0)
|
||||
)
|
||||
ttk.Label(prn_box, text="Density").grid(row=2, column=0, sticky="w")
|
||||
ttk.Spinbox(prn_box, from_=1, to=5, textvariable=self.density, width=4).grid(
|
||||
row=2, column=1, sticky="w", pady=(4, 0)
|
||||
)
|
||||
ttk.Label(prn_box, text="Copies").grid(row=2, column=2, sticky="w", padx=(8, 0))
|
||||
ttk.Spinbox(prn_box, from_=1, to=20, textvariable=self.copies, width=4).grid(
|
||||
row=2, column=3, sticky="w", pady=(4, 0)
|
||||
)
|
||||
for var in (self.model, self.conn, self.addr):
|
||||
var.trace_add("write", lambda *_: self._disconnect())
|
||||
|
||||
# Right side: editor + preview + actions
|
||||
ttk.Label(right, text="Label text (one line per row):").grid(
|
||||
row=0, column=0, sticky="w"
|
||||
)
|
||||
self.text = tk.Text(
|
||||
right, height=5, font=("DejaVu Sans", 14), wrap="word", undo=True
|
||||
)
|
||||
self.text.grid(row=1, column=0, sticky="nsew")
|
||||
self.text.insert("1.0", self.settings.get("text", "Hello 👋"))
|
||||
self.text.bind("<<Modified>>", self._on_text_modified)
|
||||
|
||||
ttk.Label(right, text="Preview (actual print, 2x zoom):").grid(
|
||||
row=2, column=0, sticky="w", pady=(8, 0)
|
||||
)
|
||||
self.preview = tk.Label(right, bg="#888", anchor="center")
|
||||
self.preview.grid(row=3, column=0, sticky="we", ipady=10)
|
||||
self.size_label = ttk.Label(right, text="")
|
||||
self.size_label.grid(row=4, column=0, sticky="w")
|
||||
|
||||
actions = ttk.Frame(right)
|
||||
actions.grid(row=5, column=0, sticky="we", pady=(10, 0))
|
||||
self.print_btn = ttk.Button(actions, text="Print", command=self.print_label)
|
||||
self.print_btn.pack(side="left")
|
||||
ttk.Button(actions, text="Save PNG…", command=self.save_png).pack(
|
||||
side="left", padx=(6, 0)
|
||||
)
|
||||
ttk.Button(actions, text="Clear", command=self.clear_text).pack(
|
||||
side="left", padx=(6, 0)
|
||||
)
|
||||
ttk.Label(actions, textvariable=self.status).pack(side="right")
|
||||
|
||||
# ---- helpers
|
||||
|
||||
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.schedule_preview()
|
||||
|
||||
def _px_size(self) -> tuple[int, int]:
|
||||
"""Landscape (width, height) in px: length along x, tape width along y."""
|
||||
return self.length_mm.get() * PX_PER_MM, self.width_mm.get() * PX_PER_MM
|
||||
|
||||
def _on_text_modified(self, _event=None):
|
||||
if self.text.edit_modified():
|
||||
self.text.edit_modified(False)
|
||||
self.schedule_preview()
|
||||
|
||||
def insert_emoji(self, emoji: str):
|
||||
self.text.insert("insert", emoji)
|
||||
self.text.focus_set()
|
||||
|
||||
def clear_text(self):
|
||||
self.text.delete("1.0", "end")
|
||||
|
||||
def schedule_preview(self):
|
||||
if self._preview_job:
|
||||
self.after_cancel(self._preview_job)
|
||||
self._preview_job = self.after(150, self.update_preview)
|
||||
|
||||
def current_image(self) -> Image.Image:
|
||||
w, h = self._px_size()
|
||||
return render_label(
|
||||
self.text.get("1.0", "end-1c"),
|
||||
w,
|
||||
h,
|
||||
font_size=max(8, self.font_size.get()),
|
||||
bold=self.bold.get(),
|
||||
align=self.align.get(),
|
||||
auto_fit=self.auto_fit.get(),
|
||||
)
|
||||
|
||||
def update_preview(self):
|
||||
self._preview_job = None
|
||||
try:
|
||||
img = self.current_image().convert("1") # dithered, as the printer sees it
|
||||
except (tk.TclError, ValueError):
|
||||
return # spinbox mid-edit
|
||||
zoom = img.resize((img.width * 2, img.height * 2), Image.NEAREST)
|
||||
self._preview_photo = ImageTk.PhotoImage(zoom)
|
||||
self.preview.configure(image=self._preview_photo)
|
||||
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{warn}")
|
||||
|
||||
def save_png(self):
|
||||
path = filedialog.asksaveasfilename(
|
||||
defaultextension=".png", filetypes=[("PNG image", "*.png")]
|
||||
)
|
||||
if path:
|
||||
self.current_image().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.current_image().convert("1")
|
||||
image, density = prepare_print(
|
||||
self.model.get(), image, self.density.get(), rotate=90
|
||||
)
|
||||
except (ValueError, tk.TclError) as exc:
|
||||
messagebox.showerror("Cannot print", str(exc))
|
||||
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.showerror(kind, str(exc) or repr(exc))
|
||||
|
||||
# ---- settings
|
||||
|
||||
def _load_settings(self) -> dict:
|
||||
try:
|
||||
return json.loads(SETTINGS_PATH.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
def _save_settings(self):
|
||||
data = {
|
||||
"preset": self.preset.get(),
|
||||
"width_mm": self.width_mm.get(),
|
||||
"length_mm": self.length_mm.get(),
|
||||
"font_size": self.font_size.get(),
|
||||
"auto_fit": self.auto_fit.get(),
|
||||
"bold": self.bold.get(),
|
||||
"align": self.align.get(),
|
||||
"model": self.model.get(),
|
||||
"conn": self.conn.get(),
|
||||
"addr": self.addr.get(),
|
||||
"density": self.density.get(),
|
||||
"text": self.text.get("1.0", "end-1c"),
|
||||
}
|
||||
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()
|
||||
Reference in New Issue
Block a user