Restyle label designer with ttkbootstrap (flatly/darkly themes)

Modern flat widgets, coloured primary/danger/success buttons, themed
canvas/text backgrounds, and a light/dark toggle remembered in settings.
This commit is contained in:
Gracious
2026-09-21 08:22:14 +01:00
parent dfe26919ad
commit 76e6732285
4 changed files with 112 additions and 45 deletions
+108 -44
View File
@@ -10,10 +10,12 @@ import threading
import tkinter as tk import tkinter as tk
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog
import emoji_data_python import emoji_data_python
import ttkbootstrap as ttk
from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageTk from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageTk
from ttkbootstrap.dialogs import Messagebox
from niimprint import ( from niimprint import (
MODELS, MODELS,
@@ -37,6 +39,8 @@ 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_GAMMA = 3.0 # >1 darkens emoji so they survive 1-bit dithering
EMOJI_SCALE = 1.15 # emoji height relative to text size EMOJI_SCALE = 1.15 # emoji height relative to text size
PICKER_PX = 26 # thumbnail size in the emoji picker 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) # (name, width mm, length mm) — width is the short side (tape width)
LABEL_PRESETS = [ LABEL_PRESETS = [
@@ -271,12 +275,16 @@ class Design:
# --------------------------------------------------------------------------- app # --------------------------------------------------------------------------- app
class LabelApp(tk.Tk): class LabelApp(ttk.Window):
def __init__(self): def __init__(self):
super().__init__()
self.title("Niimbot Label Designer")
self.minsize(1100, 640)
self.settings = self._load_settings() 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", {})) self.design = Design.from_dict(self.settings.get("design", {}))
if not self.design.items and "text" in self.settings: # migrate old settings if not self.design.items and "text" in self.settings: # migrate old settings
w, h = self.design.size_px w, h = self.design.size_px
@@ -312,6 +320,7 @@ class LabelApp(tk.Tk):
self.p_y = tk.IntVar(value=0) self.p_y = tk.IntVar(value=0)
self._build_ui() self._build_ui()
self._apply_theme_colors()
self.preset.trace_add("write", lambda *_: self._apply_preset()) self.preset.trace_add("write", lambda *_: self._apply_preset())
for var in (self.width_mm, self.length_mm): for var in (self.width_mm, self.length_mm):
var.trace_add("write", lambda *_: self._size_changed()) var.trace_add("write", lambda *_: self._size_changed())
@@ -331,13 +340,13 @@ class LabelApp(tk.Tk):
# ---- UI construction # ---- UI construction
def _build_ui(self): def _build_ui(self):
root = ttk.Frame(self, padding=8) root = ttk.Frame(self, padding=12)
root.pack(fill="both", expand=True) root.pack(fill="both", expand=True)
root.columnconfigure(1, weight=1) root.columnconfigure(1, weight=1)
root.rowconfigure(0, weight=1) root.rowconfigure(0, weight=1)
left = ttk.Frame(root) left = ttk.Frame(root)
left.grid(row=0, column=0, sticky="nsw", padx=(0, 8)) left.grid(row=0, column=0, sticky="nsw", padx=(0, 12))
right = ttk.Frame(root) right = ttk.Frame(root)
right.grid(row=0, column=1, sticky="nsew") right.grid(row=0, column=1, sticky="nsew")
right.columnconfigure(0, weight=1) right.columnconfigure(0, weight=1)
@@ -352,7 +361,7 @@ class LabelApp(tk.Tk):
self._build_actions(right) self._build_actions(right)
def _build_size_box(self, parent): def _build_size_box(self, parent):
box = ttk.LabelFrame(parent, text="Label", padding=6) box = ttk.Labelframe(parent, text="Label size", padding=10)
box.pack(fill="x") box.pack(fill="x")
ttk.Combobox( ttk.Combobox(
box, box,
@@ -373,16 +382,16 @@ class LabelApp(tk.Tk):
self.length_spin.grid(row=0, column=5) self.length_spin.grid(row=0, column=5)
def _build_emoji_picker(self, parent): def _build_emoji_picker(self, parent):
box = ttk.LabelFrame(parent, text="Emoji — click to add to label", padding=4) box = ttk.Labelframe(parent, text="Emoji — click to add", padding=8)
box.pack(fill="both", expand=True, pady=(8, 0)) box.pack(fill="both", expand=True, pady=(10, 0))
top = ttk.Frame(box) top = ttk.Frame(box)
top.pack(fill="x") top.pack(fill="x")
ttk.Label(top, text="Search").pack(side="left") ttk.Label(top, text="Search").pack(side="left")
ttk.Entry(top, textvariable=self.search, width=24).pack( ttk.Entry(top, textvariable=self.search, width=24).pack(
side="left", fill="x", expand=True, padx=(4, 0) side="left", fill="x", expand=True, padx=(4, 0)
) )
self.hover_name = ttk.Label(box, text=" ", foreground="#555") self.hover_name = ttk.Label(box, text=" ", bootstyle="secondary")
self.hover_name.pack(fill="x") self.hover_name.pack(fill="x", pady=(4, 2))
self.nb = ttk.Notebook(box, width=420, height=260) self.nb = ttk.Notebook(box, width=420, height=260)
self.nb.pack(fill="both", expand=True) self.nb.pack(fill="both", expand=True)
@@ -461,8 +470,8 @@ class LabelApp(tk.Tk):
self.nb.select(self.search_tab.master) self.nb.select(self.search_tab.master)
def _build_printer_box(self, parent): def _build_printer_box(self, parent):
box = ttk.LabelFrame(parent, text="Printer", padding=6) box = ttk.Labelframe(parent, text="Printer", padding=10)
box.pack(fill="x", pady=(8, 0)) box.pack(fill="x", pady=(10, 0))
ttk.Label(box, text="Model").grid(row=0, column=0, sticky="w") ttk.Label(box, text="Model").grid(row=0, column=0, sticky="w")
ttk.Combobox( ttk.Combobox(
box, textvariable=self.model, values=list(MODELS), state="readonly", width=6 box, textvariable=self.model, values=list(MODELS), state="readonly", width=6
@@ -492,15 +501,17 @@ class LabelApp(tk.Tk):
bar = ttk.Frame(parent) bar = ttk.Frame(parent)
bar.grid(row=0, column=0, sticky="we") bar.grid(row=0, column=0, sticky="we")
def btn(text, cmd, **kw): def btn(text, cmd, style="secondary-outline", **kw):
ttk.Button(bar, text=text, command=cmd, **kw).pack(side="left", padx=(0, 4)) ttk.Button(bar, text=text, command=cmd, bootstyle=style, **kw).pack(
side="left", padx=(0, 4)
)
def sep(): def sep():
ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=6) ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8)
btn("+ Text", lambda: self.add_text("Text")) btn("+ Text", lambda: self.add_text("Text"), "primary")
btn("Duplicate", self.duplicate_selected) btn("Duplicate", self.duplicate_selected)
btn("Delete", self.delete_selected) btn("Delete", self.delete_selected, "danger-outline")
sep() sep()
btn("⟲ 90°", lambda: self.rotate(-90), width=6) btn("⟲ 90°", lambda: self.rotate(-90), width=6)
btn("⟳ 90°", lambda: self.rotate(90), width=6) btn("⟳ 90°", lambda: self.rotate(90), width=6)
@@ -509,19 +520,27 @@ class LabelApp(tk.Tk):
btn("Center V", lambda: self.center("v")) btn("Center V", lambda: self.center("v"))
btn("Fit", self.fit_selected) btn("Fit", self.fit_selected)
sep() sep()
btn("Open…", self.open_design) btn("New", self.new_design, "link")
btn("Save", self.save_design) btn("Open", self.open_design, "link")
btn("New", self.new_design) 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): def _build_canvas(self, parent):
ttk.Label( ttk.Label(
parent, parent,
text="Drag items to move · arrow keys nudge (Shift = 10 px) · Delete removes", text="Drag items to move · arrow keys nudge (Shift = 10 px) · Delete removes",
foreground="#555", bootstyle="secondary",
).grid(row=1, column=0, sticky="w", pady=(6, 2)) ).grid(row=1, column=0, sticky="w", pady=(10, 4))
holder = tk.Frame(parent, bg="#777") self.holder = tk.Frame(parent)
holder.grid(row=2, column=0, sticky="nsew") self.holder.grid(row=2, column=0, sticky="nsew")
self.canvas = tk.Canvas(holder, bg="#777", highlightthickness=0) self.canvas = tk.Canvas(self.holder, highlightthickness=0)
self.canvas.place(relx=0.5, rely=0.5, anchor="center") self.canvas.place(relx=0.5, rely=0.5, anchor="center")
self.canvas.bind("<ButtonPress-1>", self._canvas_press) self.canvas.bind("<ButtonPress-1>", self._canvas_press)
self.canvas.bind("<B1-Motion>", self._canvas_drag) self.canvas.bind("<B1-Motion>", self._canvas_drag)
@@ -538,14 +557,22 @@ class LabelApp(tk.Tk):
) )
self.canvas.bind("<Delete>", lambda e: self.delete_selected()) self.canvas.bind("<Delete>", lambda e: self.delete_selected())
self.canvas.bind("<BackSpace>", lambda e: self.delete_selected()) self.canvas.bind("<BackSpace>", lambda e: self.delete_selected())
self.size_label = ttk.Label(parent, text="") self.size_label = ttk.Label(parent, text="", bootstyle="secondary")
self.size_label.grid(row=3, column=0, sticky="w") self.size_label.grid(row=3, column=0, sticky="w", pady=(4, 0))
def _build_props(self, parent): def _build_props(self, parent):
box = ttk.LabelFrame(parent, text="Selected item", padding=6) box = ttk.Labelframe(parent, text="Selected item", padding=10)
box.grid(row=4, column=0, sticky="we", pady=(6, 0)) box.grid(row=4, column=0, sticky="we", pady=(10, 0))
box.columnconfigure(0, weight=1) box.columnconfigure(0, weight=1)
self.prop_text = tk.Text(box, height=3, width=40, font=("DejaVu Sans", 12)) 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.grid(row=0, column=0, sticky="we")
self.prop_text.bind("<<Modified>>", self._prop_text_modified) self.prop_text.bind("<<Modified>>", self._prop_text_modified)
@@ -579,13 +606,48 @@ class LabelApp(tk.Tk):
def _build_actions(self, parent): def _build_actions(self, parent):
bar = ttk.Frame(parent) bar = ttk.Frame(parent)
bar.grid(row=5, column=0, sticky="we", pady=(8, 0)) bar.grid(row=5, column=0, sticky="we", pady=(12, 0))
self.print_btn = ttk.Button(bar, text="Print", command=self.print_label) self.print_btn = ttk.Button(
self.print_btn.pack(side="left") bar,
ttk.Button(bar, text="Save PNG…", command=self.save_png).pack( text="🖨 Print",
side="left", padx=(6, 0) command=self.print_label,
bootstyle="success",
width=12,
) )
ttk.Label(bar, textvariable=self.status).pack(side="right") 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 # ---- label size
@@ -768,7 +830,7 @@ class LabelApp(tk.Tk):
x0, y0, x1, y1 = self._boxes[self.design.items.index(self.selected)] x0, y0, x1, y1 = self._boxes[self.design.items.index(self.selected)]
self.canvas.create_rectangle( self.canvas.create_rectangle(
x0 * z - 2, y0 * z - 2, x1 * z + 2, y1 * z + 2, x0 * z - 2, y0 * z - 2, x1 * z + 2, y1 * z + 2,
outline="#1e88e5", width=2, dash=(4, 3), outline=self._sel_color, width=2, dash=(4, 3),
) # fmt: skip ) # fmt: skip
w, h = img.size w, h = img.size
model = MODELS[self.model.get()] model = MODELS[self.model.get()]
@@ -783,8 +845,9 @@ class LabelApp(tk.Tk):
# ---- designs # ---- designs
def new_design(self): def new_design(self):
if self.design.items and not messagebox.askyesno( if (
"New label", "Clear all items?" self.design.items
and Messagebox.yesno("Clear all items?", "New label", parent=self) != "Yes"
): ):
return return
self.design.items.clear() self.design.items.clear()
@@ -805,7 +868,7 @@ class LabelApp(tk.Tk):
try: try:
self.design = Design.from_dict(json.loads(Path(path).read_text())) self.design = Design.from_dict(json.loads(Path(path).read_text()))
except (OSError, ValueError, TypeError) as exc: except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Cannot open", str(exc)) Messagebox.show_error(str(exc), "Cannot open", parent=self)
return return
self.preset.set(self.design.preset) self.preset.set(self.design.preset)
self.width_mm.set(self.design.width_mm) self.width_mm.set(self.design.width_mm)
@@ -844,7 +907,7 @@ class LabelApp(tk.Tk):
self.model.get(), image, self.density.get(), rotate=90 self.model.get(), image, self.density.get(), rotate=90
) )
except (ValueError, tk.TclError) as exc: except (ValueError, tk.TclError) as exc:
messagebox.showerror("Cannot print", str(exc)) Messagebox.show_error(str(exc), "Cannot print", parent=self)
return return
copies = max(1, self.copies.get()) copies = max(1, self.copies.get())
self.print_btn.configure(state="disabled") self.print_btn.configure(state="disabled")
@@ -872,7 +935,7 @@ class LabelApp(tk.Tk):
self.status.set("Print failed") self.status.set("Print failed")
is_prn = isinstance(exc, PrinterError) is_prn = isinstance(exc, PrinterError)
kind = "Printer error" if is_prn else type(exc).__name__ kind = "Printer error" if is_prn else type(exc).__name__
messagebox.showerror(kind, str(exc) or repr(exc)) Messagebox.show_error(str(exc) or repr(exc), kind, parent=self)
# ---- settings # ---- settings
@@ -888,6 +951,7 @@ class LabelApp(tk.Tk):
"conn": self.conn.get(), "conn": self.conn.get(),
"addr": self.addr.get(), "addr": self.addr.get(),
"density": self.density.get(), "density": self.density.get(),
"theme": self.theme_mode,
"design": self.design.to_dict(), "design": self.design.to_dict(),
} }
try: try:
+1
View File
@@ -10,6 +10,7 @@ pyserial = "^3.5"
pillow = "^10.1.0" pillow = "^10.1.0"
click = "^8.1.7" click = "^8.1.7"
emoji-data-python = "^1.6" emoji-data-python = "^1.6"
ttkbootstrap = "^1.10"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
+1
View File
@@ -95,5 +95,6 @@ python label_app.py
- **Fit** resizes the item to fill the label; **Center H/V** aligns it - **Fit** resizes the item to fill the label; **Center H/V** aligns it
- The canvas shows the dithered 1-bit image exactly as the printer will produce it - The canvas shows the dithered 1-bit image exactly as the printer will produce it
- **Save…/Open…** store designs as JSON; the last design and printer settings are restored on launch - **Save…/Open…** store designs as JSON; the last design and printer settings are restored on launch
- ☾/☀ in the toolbar switches between light and dark themes ([ttkbootstrap](https://ttkbootstrap.readthedocs.io))
Fonts: DejaVu Sans (`/usr/share/fonts/TTF/`) for text and Noto Color Emoji (`/usr/share/fonts/noto/`) for emoji; edit `TEXT_FONTS` / `EMOJI_FONT` at the top of the file for other distros. Emoji are rendered by Pillow with the same font that prints, so the picker is WYSIWYG. Fonts: DejaVu Sans (`/usr/share/fonts/TTF/`) for text and Noto Color Emoji (`/usr/share/fonts/noto/`) for emoji; edit `TEXT_FONTS` / `EMOJI_FONT` at the top of the file for other distros. Emoji are rendered by Pillow with the same font that prints, so the picker is WYSIWYG.
+1
View File
@@ -2,3 +2,4 @@ click==8.1.7
pillow==10.1.0 pillow==10.1.0
pyserial==3.5 pyserial==3.5
emoji-data-python==1.6.0 emoji-data-python==1.6.0
ttkbootstrap==1.20.4