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:
+108
-44
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user