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.
510 lines
20 KiB
Python
510 lines
20 KiB
Python
"""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()
|