93afa3c417
New three-column label format for Brother P-touch EDGE PT-E560BT asset tags:
logo | three static lines + variable field | QR code.
Layout and rendering
- ptouch_label.py: geometry engine with PDF and preview renderers. Both
consume the same geometry, so the preview matches the print exactly.
- Code symbols are snapped to whole printer dots per module. Fractional
modules are the usual reason a small QR code will not scan.
- QR is maximised to the printable height by default (16.4 mm, 4 dots per
module) instead of a fixed 16 mm box (12.3 mm, 3 dots per module).
- Code payload is composed as prefix + inventory + suffix. A legacy
{inventory} template is migrated on first save.
Hardware handling
- print_area.py reads the driver's real printable area via GetDeviceCaps.
The PT-E560BT head is 128 dots at 180 dpi, so the maximum print height is
18.06 mm even on 24 mm tape; the driver reports media as 65x24 mm but only
about 61x18 mm is reachable. Content is fitted to the reachable area,
otherwise the driver silently crops it.
- set_printer_resolution.py reads/switches the driver's resolution mode. The
driver offers 180x180 (symmetric) and 180x360 (anisotropic, 2x across the
tape); the anisotropic mode scaled output incorrectly.
GUI
- label_printer_ptouch_gui.py: 600x300 two-column panel - controls on the
left, live preview on the right. Advanced settings for logo, static lines,
code prefix/suffix and error correction.
- native_file_dialog.py: the standard Windows file picker, run in a
subprocess because Tk and Kivy both want the main event loop.
Printing
- Print failures are reported honestly. print_to_printer previously returned
True unconditionally, so a failed print claimed success and was logged as
SUCCESS.
- SumatraPDF exit codes are now checked (4 = no printer, 5 = driver error).
- The PDF is sent with noscale so driver-side scaling cannot distort the QR
module pitch.
Tests
- validate_ptouch_layout.py decodes the QR back out of the rendered preview
module by module, proving the preview and PDF are the same layout.
- test_ptouch_pipeline.py writes to a temp config so it cannot overwrite
operator settings.
- test_gui_layout.py exercises the real app class headlessly.
- test_print_reporting.py covers the failure paths.
Diagnostics: check_print_area.py, check_print_geometry.py, check_devmode.py,
print_calibration.py, print_measure_scale.py, capture_print_job.py.
851 lines
31 KiB
Python
851 lines
31 KiB
Python
"""
|
|
Kivy GUI for the Brother P-touch EDGE PT-E560BT (63 x 20 mm labels).
|
|
|
|
This is a new entry point alongside the original `label_printer_gui.py`, which
|
|
implements the older three-row format and is left untouched.
|
|
|
|
Window is a landscape 600 x 300 panel split into two columns:
|
|
|
|
+-----------------------------+---------------------------------+
|
|
| CONTROLS | LABEL PREVIEW |
|
|
| Inventory number | (63 x 20 mm true proportions) |
|
|
| [___________________] | |
|
|
| Printer | |
|
|
| [Brother PT-E560BT v] | |
|
|
| [ PRINT LABEL ] | |
|
|
| [ Advanced settings ] | |
|
|
+-----------------------------+---------------------------------+
|
|
|
|
The preview is produced by the same layout engine as the PDF, so the panel is a
|
|
faithful representation of the printed result rather than an approximation.
|
|
"""
|
|
|
|
from kivy.config import Config
|
|
|
|
# Window geometry must be set through Config before the window is created;
|
|
# assigning Window.size afterwards is ignored by the SDL2 backend on Windows.
|
|
WINDOW_SIZE = (600, 300)
|
|
WINDOW_MIN = (520, 280)
|
|
Config.set("graphics", "width", str(WINDOW_SIZE[0]))
|
|
Config.set("graphics", "height", str(WINDOW_SIZE[1]))
|
|
Config.set("graphics", "minimum_width", str(WINDOW_MIN[0]))
|
|
Config.set("graphics", "minimum_height", str(WINDOW_MIN[1]))
|
|
|
|
from kivy.app import App
|
|
from kivy.clock import Clock
|
|
from kivy.core.image import Image as CoreImage
|
|
from kivy.core.window import Window
|
|
from kivy.graphics import Color, Line, Rectangle
|
|
from kivy.metrics import dp
|
|
from kivy.uix.boxlayout import BoxLayout
|
|
from kivy.uix.button import Button
|
|
from kivy.uix.gridlayout import GridLayout
|
|
from kivy.uix.label import Label
|
|
from kivy.uix.popup import Popup
|
|
from kivy.uix.scrollview import ScrollView
|
|
from kivy.uix.spinner import Spinner
|
|
from kivy.uix.textinput import TextInput
|
|
|
|
import datetime
|
|
import io
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
|
|
from label_config import load_config, save_config
|
|
from print_area import apply_to_config as apply_print_area, query_print_area
|
|
from print_label import get_available_printers, print_ptouch_label
|
|
from ptouch_label import PtouchLabelRenderer, code_density_warning
|
|
|
|
|
|
def _printed_logo_size(config, source_size) -> tuple[float, float]:
|
|
"""Size the logo will occupy on the label, in mm, preserving aspect ratio.
|
|
|
|
The logo is fitted inside the logo cell, so this reports the real printed
|
|
footprint rather than the source pixel dimensions.
|
|
"""
|
|
src_w, src_h = source_size
|
|
box_w = config.logo_box_mm
|
|
box_h = config.logo_box_mm
|
|
if not src_w or not src_h:
|
|
return (0.0, 0.0)
|
|
ratio = min(box_w / src_w, box_h / src_h)
|
|
return (src_w * ratio, src_h * ratio)
|
|
|
|
|
|
class LogoThumb(BoxLayout):
|
|
"""Small preview of the selected logo, or a placeholder when none is set."""
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.size_hint_x = None
|
|
self.width = dp(48)
|
|
self._texture = None
|
|
with self.canvas.before:
|
|
Color(0.9, 0.9, 0.92, 1)
|
|
self._bg = Rectangle(pos=self.pos, size=self.size)
|
|
self.bind(pos=self._redraw, size=self._redraw)
|
|
|
|
def set_path(self, path):
|
|
"""Load a thumbnail from a file, or clear it when the path is empty."""
|
|
self._texture = None
|
|
if path and os.path.exists(path):
|
|
try:
|
|
from PIL import Image
|
|
|
|
img = Image.open(path)
|
|
img.load()
|
|
img = img.convert("RGBA")
|
|
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
|
|
img = Image.alpha_composite(background, img).convert("RGB")
|
|
img.thumbnail((128, 128))
|
|
buffer = io.BytesIO()
|
|
img.save(buffer, format="PNG")
|
|
buffer.seek(0)
|
|
self._texture = CoreImage(buffer, ext="png").texture
|
|
except Exception as exc: # noqa: BLE001 - a bad file must not break the UI
|
|
print(f"Logo thumbnail error: {exc}")
|
|
self._redraw()
|
|
|
|
def _redraw(self, *_args):
|
|
self._bg.pos = self.pos
|
|
self._bg.size = self.size
|
|
self.canvas.remove_group("thumb")
|
|
if self._texture is None:
|
|
return
|
|
|
|
available_w = max(1, self.width - dp(8))
|
|
available_h = max(1, self.height - dp(8))
|
|
scale = min(
|
|
available_w / self._texture.width, available_h / self._texture.height
|
|
)
|
|
draw_w = max(1, self._texture.width * scale)
|
|
draw_h = max(1, self._texture.height * scale)
|
|
|
|
with self.canvas:
|
|
Color(1, 1, 1, 1, group="thumb")
|
|
Rectangle(
|
|
texture=self._texture,
|
|
pos=(self.center_x - draw_w / 2, self.center_y - draw_h / 2),
|
|
size=(draw_w, draw_h),
|
|
group="thumb",
|
|
)
|
|
|
|
|
|
class PreviewPanel(BoxLayout):
|
|
"""Displays the rendered label on a dark card, scaled to fit."""
|
|
|
|
CARD_BG = (0.13, 0.13, 0.15, 1)
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self._texture = None
|
|
with self.canvas.before:
|
|
Color(*self.CARD_BG)
|
|
self._bg = Rectangle(pos=self.pos, size=self.size)
|
|
self.bind(pos=self._on_geometry, size=self._on_geometry)
|
|
|
|
def _on_geometry(self, *_args):
|
|
self._bg.pos = self.pos
|
|
self._bg.size = self.size
|
|
# Re-place the image so it stays centred when the window is resized.
|
|
self._redraw()
|
|
|
|
def show_image(self, pil_image):
|
|
"""Store a PIL image as a texture and draw it."""
|
|
buffer = io.BytesIO()
|
|
pil_image.save(buffer, format="PNG")
|
|
buffer.seek(0)
|
|
self._texture = CoreImage(buffer, ext="png").texture
|
|
self._redraw()
|
|
|
|
def _redraw(self):
|
|
"""Draw the stored texture centred and fitted inside the panel."""
|
|
self.canvas.remove_group("preview")
|
|
if self._texture is None:
|
|
return
|
|
|
|
available_w = max(1, self.width - dp(16))
|
|
available_h = max(1, self.height - dp(16))
|
|
scale = min(
|
|
available_w / self._texture.width, available_h / self._texture.height
|
|
)
|
|
draw_w = max(1, self._texture.width * scale)
|
|
draw_h = max(1, self._texture.height * scale)
|
|
x = self.center_x - draw_w / 2
|
|
y = self.center_y - draw_h / 2
|
|
|
|
with self.canvas:
|
|
Color(1, 1, 1, 1, group="preview")
|
|
Rectangle(
|
|
texture=self._texture,
|
|
pos=(x, y),
|
|
size=(draw_w, draw_h),
|
|
group="preview",
|
|
)
|
|
# Hairline outline makes the label edge visible against the card.
|
|
Color(0.45, 0.45, 0.5, 1, group="preview")
|
|
Line(
|
|
rectangle=(x, y, draw_w, draw_h),
|
|
width=dp(1),
|
|
group="preview",
|
|
)
|
|
|
|
|
|
class PtouchPrinterApp(App):
|
|
"""P-touch label printing front-end."""
|
|
|
|
PREVIEW_DPI = 300
|
|
PREVIEW_DEBOUNCE_S = 0.35
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.label_cfg = load_config()
|
|
self._preview_event = None
|
|
|
|
full_printers = get_available_printers()
|
|
self.printer_display_map = {}
|
|
self.available_printers = []
|
|
for full_name in full_printers:
|
|
display_name = self._shorten_printer_name(full_name)
|
|
if display_name in self.printer_display_map:
|
|
display_name = full_name[:20]
|
|
self.printer_display_map[display_name] = full_name
|
|
self.available_printers.append(display_name)
|
|
|
|
self.cleanup_old_pdfs()
|
|
self.cleanup_old_logs()
|
|
|
|
# -- helpers ----------------------------------------------------------
|
|
|
|
def _shorten_printer_name(self, name, max_len=22):
|
|
"""Shorten a printer name for the dropdown, keeping it unique."""
|
|
if name.startswith("\\\\"):
|
|
parts = name.strip("\\").split("\\")
|
|
short = parts[-1] if len(parts) >= 2 else name
|
|
else:
|
|
short = name
|
|
return short[:max_len]
|
|
|
|
def _get_full_printer_name(self, display_name):
|
|
return self.printer_display_map.get(display_name, display_name)
|
|
|
|
def cleanup_old_pdfs(self, days=5):
|
|
self._cleanup_dir("pdf_backup", (".pdf",), days)
|
|
|
|
def cleanup_old_logs(self, days=5):
|
|
self._cleanup_dir("logs", (".log", ".csv"), days)
|
|
|
|
def _cleanup_dir(self, folder, suffixes, days):
|
|
"""Delete files older than `days` from a folder, ignoring errors."""
|
|
if not os.path.exists(folder):
|
|
os.makedirs(folder, exist_ok=True)
|
|
return
|
|
cutoff = datetime.datetime.now().timestamp() - days * 86400
|
|
for name in os.listdir(folder):
|
|
if not name.endswith(suffixes):
|
|
continue
|
|
path = os.path.join(folder, name)
|
|
try:
|
|
if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
|
|
os.remove(path)
|
|
except OSError as exc:
|
|
print(f"Cleanup skipped {name}: {exc}")
|
|
|
|
def log_print_action(self, inventory, printer, pdf_path, success, warning=""):
|
|
"""Append a print event to the daily CSV log."""
|
|
os.makedirs("logs", exist_ok=True)
|
|
log_filename = os.path.join(
|
|
"logs", f"print_log_{datetime.datetime.now().strftime('%Y%m%d')}.csv"
|
|
)
|
|
|
|
def escape(value):
|
|
value = str(value)
|
|
if any(ch in value for ch in (",", '"', "\n")):
|
|
value = '"' + value.replace('"', '""') + '"'
|
|
return value
|
|
|
|
line = (
|
|
f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')},"
|
|
f"{'SUCCESS' if success else 'FAILED'},"
|
|
f"{escape(inventory)},{escape(printer)},{escape(pdf_path)},"
|
|
f"{escape(warning)}\n"
|
|
)
|
|
try:
|
|
exists = os.path.isfile(log_filename)
|
|
with open(log_filename, "a", encoding="utf-8") as handle:
|
|
if not exists:
|
|
handle.write("Timestamp,Status,Inventory,Printer,PDF File,Warning\n")
|
|
handle.write(line)
|
|
except OSError as exc:
|
|
print(f"Error saving log entry: {exc}")
|
|
|
|
# -- layout -----------------------------------------------------------
|
|
|
|
def build(self):
|
|
"""Assemble the two-column panel: controls on the left, preview right."""
|
|
self.title = "P-touch Label Printing"
|
|
|
|
root = BoxLayout(orientation="horizontal", spacing=dp(8), padding=dp(10))
|
|
|
|
# ---- column 1: controls ------------------------------------------
|
|
controls = BoxLayout(orientation="vertical", spacing=dp(6), size_hint_x=0.42)
|
|
|
|
controls.add_widget(
|
|
Label(
|
|
text="[b]P-touch Label Printing[/b]",
|
|
markup=True,
|
|
size_hint_y=None,
|
|
height=dp(22),
|
|
font_size="15sp",
|
|
halign="left",
|
|
valign="middle",
|
|
color=(1, 1, 1, 1),
|
|
text_size=(None, dp(22)),
|
|
)
|
|
)
|
|
|
|
controls.add_widget(self._field_label("Inventory number"))
|
|
self.inventory_input = TextInput(
|
|
multiline=False,
|
|
size_hint_y=None,
|
|
height=dp(42),
|
|
font_size="16sp",
|
|
padding=(dp(8), dp(8)),
|
|
write_tab=False,
|
|
)
|
|
self.inventory_input.bind(text=self._on_inventory_change)
|
|
self.inventory_input.bind(on_text_validate=self._on_enter)
|
|
controls.add_widget(self.inventory_input)
|
|
|
|
controls.add_widget(self._field_label("Printer"))
|
|
self.printer_spinner = Spinner(
|
|
text=self.available_printers[0] if self.available_printers else "PDF",
|
|
values=self.available_printers or ["PDF"],
|
|
size_hint_y=None,
|
|
height=dp(38),
|
|
font_size="13sp",
|
|
)
|
|
# The printable area differs per printer, so re-fit the preview on change.
|
|
self.printer_spinner.bind(text=lambda *_: self.update_preview())
|
|
controls.add_widget(self.printer_spinner)
|
|
|
|
# Push the buttons to the bottom of the column.
|
|
controls.add_widget(BoxLayout())
|
|
|
|
self.print_button = Button(
|
|
text="PRINT LABEL",
|
|
size_hint_y=None,
|
|
height=dp(46),
|
|
font_size="15sp",
|
|
bold=True,
|
|
background_color=(0.16, 0.62, 0.28, 1),
|
|
background_normal="",
|
|
)
|
|
self.print_button.bind(on_press=self.print_label)
|
|
controls.add_widget(self.print_button)
|
|
|
|
settings_button = Button(
|
|
text="Advanced settings",
|
|
size_hint_y=None,
|
|
height=dp(36),
|
|
font_size="13sp",
|
|
background_color=(0.24, 0.30, 0.45, 1),
|
|
background_normal="",
|
|
)
|
|
settings_button.bind(on_press=lambda *_: self.open_settings())
|
|
controls.add_widget(settings_button)
|
|
|
|
root.add_widget(controls)
|
|
|
|
# ---- column 2: preview ------------------------------------------
|
|
preview_col = BoxLayout(orientation="vertical", spacing=dp(4))
|
|
|
|
preview_col.add_widget(
|
|
self._field_label("Label preview (63 x 20 mm, true proportions)")
|
|
)
|
|
self.preview = PreviewPanel()
|
|
preview_col.add_widget(self.preview)
|
|
|
|
self.status_label = Label(
|
|
text="",
|
|
size_hint_y=None,
|
|
height=dp(34),
|
|
font_size="11sp",
|
|
markup=True,
|
|
halign="left",
|
|
valign="middle",
|
|
)
|
|
self.status_label.bind(
|
|
size=lambda widget, size: setattr(widget, "text_size", (size[0], None))
|
|
)
|
|
preview_col.add_widget(self.status_label)
|
|
|
|
root.add_widget(preview_col)
|
|
|
|
Clock.schedule_once(lambda *_: self.update_preview(), 0.1)
|
|
Clock.schedule_once(lambda *_: self.inventory_input.focus, 0.35)
|
|
return root
|
|
|
|
def _field_label(self, text):
|
|
"""A small caption label with a fixed height."""
|
|
label = Label(
|
|
text=text,
|
|
size_hint_y=None,
|
|
height=dp(18),
|
|
font_size="11sp",
|
|
halign="left",
|
|
valign="middle",
|
|
opacity=0.8,
|
|
)
|
|
label.bind(size=lambda w, s: setattr(w, "text_size", (s[0], None)))
|
|
return label
|
|
|
|
# -- preview ----------------------------------------------------------
|
|
|
|
def _on_inventory_change(self, *_args):
|
|
"""Debounce regeneration so typing does not re-render on every key."""
|
|
if self._preview_event is not None:
|
|
self._preview_event.cancel()
|
|
self._preview_event = Clock.schedule_once(
|
|
lambda *_: self.update_preview(), self.PREVIEW_DEBOUNCE_S
|
|
)
|
|
if hasattr(self, "payload_label") and self.payload_label is not None:
|
|
self._refresh_payload_label()
|
|
|
|
def _on_enter(self, *_args):
|
|
if self._preview_event is not None:
|
|
self._preview_event.cancel()
|
|
self.update_preview()
|
|
|
|
def _effective_config(self):
|
|
"""The config as it will actually be printed, fitted to the printer.
|
|
|
|
The preview must use this rather than the raw settings, otherwise the
|
|
preview would show the unfitted layout while the printer receives a
|
|
fitted one - which is precisely the mismatch that caused an earlier
|
|
'preview looks fine but prints wrong' report.
|
|
"""
|
|
cfg = self.label_cfg.copy()
|
|
printer = self._get_full_printer_name(self.printer_spinner.text)
|
|
if printer and printer != "PDF":
|
|
apply_print_area(cfg, printer)
|
|
return cfg
|
|
|
|
def _printer_area_note(self):
|
|
"""Short description of the printer's printable limits, for the status line."""
|
|
printer = self._get_full_printer_name(self.printer_spinner.text)
|
|
if not printer or printer == "PDF":
|
|
return ""
|
|
area = query_print_area(printer)
|
|
if area is None:
|
|
return ""
|
|
return (
|
|
f"printable {area.printable_width_mm:.1f} x {area.printable_height_mm:.1f} mm"
|
|
)
|
|
|
|
def update_preview(self):
|
|
"""Render the preview and surface any code-density warning."""
|
|
self._preview_event = None
|
|
inventory = self.inventory_input.text.strip()
|
|
cfg = self._effective_config()
|
|
try:
|
|
renderer = PtouchLabelRenderer(cfg)
|
|
image = renderer.render_preview(inventory, dpi=self.PREVIEW_DPI)
|
|
self.preview.show_image(image)
|
|
except Exception as exc: # noqa: BLE001 - preview must never crash the app
|
|
# Log the traceback too: a silent except here previously hid a real
|
|
# bug (config shadowed by Kivy's App.config) for an entire session.
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
self._set_status(f"[color=ff6666]Preview error: {exc}[/color]")
|
|
return
|
|
|
|
warning = code_density_warning(cfg, inventory)
|
|
if warning:
|
|
self._set_status(f"[color=ffb347]{warning}[/color]")
|
|
else:
|
|
payload = cfg.build_code_payload(inventory)
|
|
shown = payload if len(payload) <= 34 else payload[:31] + "..."
|
|
bitmap = renderer.code_bitmap(inventory)
|
|
note = self._printer_area_note()
|
|
size = f"label {cfg.page_width_mm:.0f}x{cfg.page_height_mm:.0f} mm"
|
|
if bitmap is not None:
|
|
size += (
|
|
f" | QR {bitmap.width_mm:.1f} mm, "
|
|
f"{bitmap.dots_per_module:.0f} dots/mod"
|
|
)
|
|
tail = f" | {note}" if note else ""
|
|
self._set_status(f"QR: [b]{shown}[/b]\n{size}{tail}")
|
|
|
|
def _set_status(self, markup):
|
|
self.status_label.text = markup
|
|
|
|
# -- settings ---------------------------------------------------------
|
|
|
|
def open_settings(self):
|
|
"""Advanced settings: logo, three static lines, QR string.
|
|
|
|
Sized to fill most of the window, since the window is only 300 px tall;
|
|
the form scrolls when it does not all fit.
|
|
"""
|
|
content = BoxLayout(orientation="vertical", spacing=dp(6), padding=dp(8))
|
|
scroll = ScrollView()
|
|
form = GridLayout(cols=1, size_hint_y=None, spacing=dp(4))
|
|
form.bind(minimum_height=form.setter("height"))
|
|
|
|
form.add_widget(self._field_label("Logo image (PNG / JPG)"))
|
|
logo_row = BoxLayout(size_hint_y=None, height=dp(34), spacing=dp(6))
|
|
self.logo_input = TextInput(
|
|
text=self.label_cfg.logo_path, multiline=False, font_size="11sp"
|
|
)
|
|
logo_row.add_widget(self.logo_input)
|
|
browse = Button(text="Browse...", size_hint_x=0.28, font_size="12sp")
|
|
browse.bind(on_press=lambda *_: self.browse_logo())
|
|
logo_row.add_widget(browse)
|
|
clear = Button(text="Clear", size_hint_x=0.18, font_size="12sp")
|
|
clear.bind(on_press=lambda *_: self.clear_logo())
|
|
logo_row.add_widget(clear)
|
|
form.add_widget(logo_row)
|
|
|
|
# A thumbnail so the operator can confirm the right file was picked.
|
|
logo_preview_row = BoxLayout(size_hint_y=None, height=dp(52), spacing=dp(8))
|
|
self.logo_thumb = LogoThumb()
|
|
logo_preview_row.add_widget(self.logo_thumb)
|
|
self.logo_status = Label(
|
|
text="",
|
|
font_size="11sp",
|
|
halign="left",
|
|
valign="middle",
|
|
markup=True,
|
|
)
|
|
self.logo_status.bind(
|
|
size=lambda w, s: setattr(w, "text_size", (s[0], None))
|
|
)
|
|
logo_preview_row.add_widget(self.logo_status)
|
|
form.add_widget(logo_preview_row)
|
|
self._refresh_logo_thumb()
|
|
|
|
form.add_widget(self._field_label("Static text lines (3)"))
|
|
self.static_inputs = []
|
|
for index in range(3):
|
|
current = (
|
|
self.label_cfg.static_lines[index]
|
|
if index < len(self.label_cfg.static_lines)
|
|
else ""
|
|
)
|
|
field = TextInput(
|
|
text=current,
|
|
multiline=False,
|
|
size_hint_y=None,
|
|
height=dp(32),
|
|
font_size="13sp",
|
|
)
|
|
self.static_inputs.append(field)
|
|
form.add_widget(field)
|
|
|
|
form.add_widget(
|
|
self._field_label("Code composition: fixed prefix + inventory + fixed suffix")
|
|
)
|
|
code_row = BoxLayout(size_hint_y=None, height=dp(32), spacing=dp(6))
|
|
|
|
self.prefix_input = TextInput(
|
|
text=self.label_cfg.qr_prefix,
|
|
multiline=False,
|
|
font_size="13sp",
|
|
hint_text="prefix",
|
|
)
|
|
self.prefix_input.bind(text=lambda *_: self.preview_with_template())
|
|
code_row.add_widget(self.prefix_input)
|
|
|
|
inv_tag = Label(
|
|
text="+ [inventory] +",
|
|
size_hint_x=None,
|
|
width=dp(96),
|
|
font_size="11sp",
|
|
halign="center",
|
|
valign="middle",
|
|
opacity=0.75,
|
|
)
|
|
inv_tag.bind(size=lambda w, s: setattr(w, "text_size", (s[0], None)))
|
|
code_row.add_widget(inv_tag)
|
|
|
|
self.suffix_input = TextInput(
|
|
text=self.label_cfg.qr_suffix,
|
|
multiline=False,
|
|
font_size="13sp",
|
|
hint_text="suffix",
|
|
)
|
|
self.suffix_input.bind(text=lambda *_: self.preview_with_template())
|
|
code_row.add_widget(self.suffix_input)
|
|
form.add_widget(code_row)
|
|
|
|
# Live preview of the composed payload, so the operator can see exactly
|
|
# what will be encoded before printing anything.
|
|
self.payload_label = Label(
|
|
text="",
|
|
size_hint_y=None,
|
|
height=dp(30),
|
|
font_size="11sp",
|
|
halign="left",
|
|
valign="middle",
|
|
markup=True,
|
|
)
|
|
self.payload_label.bind(
|
|
size=lambda w, s: setattr(w, "text_size", (s[0], None))
|
|
)
|
|
form.add_widget(self.payload_label)
|
|
|
|
form.add_widget(
|
|
self._field_label("QR error correction (L = shortest, H = most robust)")
|
|
)
|
|
self.ec_spinner = Spinner(
|
|
text=self.label_cfg.qr_error_correction,
|
|
values=["L", "M", "Q", "H"],
|
|
size_hint_y=None,
|
|
height=dp(32),
|
|
font_size="13sp",
|
|
)
|
|
self.ec_spinner.bind(text=lambda *_: self.preview_with_template())
|
|
form.add_widget(self.ec_spinner)
|
|
self._refresh_payload_label()
|
|
|
|
scroll.add_widget(form)
|
|
content.add_widget(scroll)
|
|
|
|
buttons = BoxLayout(size_hint_y=None, height=dp(40), spacing=dp(6))
|
|
save = Button(
|
|
text="Save",
|
|
background_color=(0.16, 0.62, 0.28, 1),
|
|
background_normal="",
|
|
)
|
|
cancel = Button(text="Cancel")
|
|
buttons.add_widget(save)
|
|
buttons.add_widget(cancel)
|
|
content.add_widget(buttons)
|
|
|
|
self._settings_popup = Popup(
|
|
title="Advanced settings", content=content, size_hint=(0.98, 1.0)
|
|
)
|
|
save.bind(on_press=lambda *_: self.apply_settings())
|
|
cancel.bind(on_press=lambda *_: self._settings_popup.dismiss())
|
|
self._settings_popup.bind(on_dismiss=self._on_settings_dismissed)
|
|
self._settings_popup.open()
|
|
|
|
def preview_with_template(self):
|
|
"""Reflect in-dialog code edits in the preview immediately."""
|
|
self.label_cfg.qr_prefix = self.prefix_input.text
|
|
self.label_cfg.qr_suffix = self.suffix_input.text
|
|
self.label_cfg.qr_error_correction = self.ec_spinner.text
|
|
self._refresh_payload_label()
|
|
self.update_preview()
|
|
|
|
def _refresh_payload_label(self):
|
|
"""Show exactly what the code will encode, using the current field value."""
|
|
self.label_cfg.qr_prefix = self.prefix_input.text
|
|
self.label_cfg.qr_suffix = self.suffix_input.text
|
|
sample = self.inventory_input.text.strip()
|
|
payload = self.label_cfg.build_code_payload(sample)
|
|
shown = payload if payload else "[i](empty - enter an inventory number)[/i]"
|
|
self.payload_label.text = f"Encodes: [b]{shown}[/b]"
|
|
|
|
def _on_settings_dismissed(self, *_args):
|
|
"""On close, reload from disk so unsaved edits are discarded."""
|
|
self._settings_popup = None
|
|
self.label_cfg = load_config()
|
|
self.update_preview()
|
|
|
|
def browse_logo(self):
|
|
"""Open the real Windows file dialog to choose a logo.
|
|
|
|
The dialog runs in a subprocess because Tk and Kivy both want the main
|
|
event loop; giving Tk its own process avoids that conflict.
|
|
"""
|
|
script = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"native_file_dialog.py")
|
|
if not os.path.exists(script):
|
|
self.show_popup(
|
|
"Missing helper",
|
|
"native_file_dialog.py was not found next to the application.",
|
|
)
|
|
return
|
|
|
|
args = [
|
|
sys.executable,
|
|
script,
|
|
"--dir", self.label_cfg.logo_path or "",
|
|
"--title", "Choose logo image",
|
|
]
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
args,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
)
|
|
except OSError as exc:
|
|
self.show_popup("Error", f"Could not open the file dialog:\n{exc}")
|
|
return
|
|
|
|
output = (result.stdout or "").strip().splitlines()
|
|
line = output[-1] if output else ""
|
|
print(f"Logo dialog result: {line or '(no output)'}")
|
|
|
|
if line.startswith("SELECTED:"):
|
|
path = line[len("SELECTED:"):]
|
|
self.logo_input.text = path
|
|
self.label_cfg.logo_path = path
|
|
save_config(self.label_cfg)
|
|
self.update_preview()
|
|
elif line == "CANCELLED":
|
|
pass
|
|
else:
|
|
detail = (result.stderr or "").strip().splitlines()
|
|
detail = detail[-1] if detail else "no output from the dialog"
|
|
self.show_popup("Error", f"File dialog failed:\n{detail}")
|
|
|
|
def clear_logo(self):
|
|
"""Remove the logo from the label."""
|
|
self.logo_input.text = ""
|
|
self.label_cfg.logo_path = ""
|
|
save_config(self.label_cfg)
|
|
self._refresh_logo_thumb()
|
|
self.update_preview()
|
|
|
|
def _refresh_logo_thumb(self):
|
|
"""Update the logo thumbnail and its status caption."""
|
|
path = self.label_cfg.logo_path
|
|
self.logo_thumb.set_path(path)
|
|
|
|
if not path:
|
|
self.logo_status.text = "[i]No logo set - the first column stays empty[/i]"
|
|
return
|
|
if not os.path.exists(path):
|
|
self.logo_status.text = f"[color=ff8888]File not found:[/color]\n{os.path.basename(path)}"
|
|
return
|
|
|
|
try:
|
|
from PIL import Image
|
|
|
|
with Image.open(path) as img:
|
|
mode = img.mode
|
|
size = img.size
|
|
print_dim = _printed_logo_size(self.label_cfg, size)
|
|
self.logo_status.text = (
|
|
f"{os.path.basename(path)}\n"
|
|
f"{size[0]}x{size[1]} {mode} -> prints {print_dim[0]:.1f}x{print_dim[1]:.1f} mm"
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
self.logo_status.text = f"[color=ff8888]Unreadable image:[/color]\n{exc}"
|
|
|
|
def apply_settings(self):
|
|
"""Persist settings to disk and refresh the preview."""
|
|
self.label_cfg.logo_path = self.logo_input.text.strip()
|
|
self.label_cfg.static_lines = [field.text for field in self.static_inputs]
|
|
self.label_cfg.qr_prefix = self.prefix_input.text
|
|
self.label_cfg.qr_suffix = self.suffix_input.text
|
|
# The composed prefix/suffix form supersedes any legacy template.
|
|
self.label_cfg.qr_template = ""
|
|
self.label_cfg.qr_error_correction = self.ec_spinner.text
|
|
|
|
try:
|
|
print(f"Settings saved to {save_config(self.label_cfg)}")
|
|
except OSError as exc:
|
|
self.show_popup("Error", f"Could not save settings: {exc}")
|
|
|
|
popup, self._settings_popup = self._settings_popup, None
|
|
if popup is not None:
|
|
popup.unbind(on_dismiss=self._on_settings_dismissed)
|
|
popup.dismiss()
|
|
self.update_preview()
|
|
|
|
# -- printing ---------------------------------------------------------
|
|
|
|
def print_label(self, _instance):
|
|
inventory = self.inventory_input.text.strip()
|
|
if not inventory:
|
|
self.show_popup("Missing value", "Enter an inventory number first.")
|
|
return
|
|
|
|
printer = self._get_full_printer_name(self.printer_spinner.text)
|
|
popup = self._busy_popup("Generating label...\nPlease wait")
|
|
|
|
def worker():
|
|
try:
|
|
success, pdf_path, warning = print_ptouch_label(
|
|
inventory, printer, self.label_cfg
|
|
)
|
|
self.log_print_action(
|
|
inventory, printer, pdf_path, success, warning or ""
|
|
)
|
|
Clock.schedule_once(lambda *_: popup.dismiss(), 0)
|
|
if success:
|
|
message = "Label sent to printer."
|
|
if warning:
|
|
message += f"\n\nNote: {warning}"
|
|
Clock.schedule_once(
|
|
lambda *_: self.show_popup("Success", message, auto_dismiss=not warning),
|
|
0.1,
|
|
)
|
|
Clock.schedule_once(lambda *_: self.reset_inventory(), 0.2)
|
|
else:
|
|
Clock.schedule_once(
|
|
lambda *_: self.show_popup("Error", "Printing failed."), 0.1
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
self.log_print_action(inventory, printer, "", False, str(exc))
|
|
Clock.schedule_once(lambda *_: popup.dismiss(), 0)
|
|
Clock.schedule_once(
|
|
lambda *_: self.show_popup("Error", f"Print error: {exc}"), 0.1
|
|
)
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
|
|
def _busy_popup(self, message):
|
|
"""A small modal shown while a print job runs."""
|
|
popup = Popup(
|
|
title="Please wait",
|
|
content=Label(text=message),
|
|
size_hint=(None, None),
|
|
size=(dp(260), dp(120)),
|
|
auto_dismiss=False,
|
|
)
|
|
popup.open()
|
|
return popup
|
|
|
|
def reset_inventory(self):
|
|
"""Clear the variable field, keeping printer selection and settings."""
|
|
self.inventory_input.text = ""
|
|
self.update_preview()
|
|
self.inventory_input.focus = True
|
|
|
|
def show_popup(self, title, message, auto_dismiss=False):
|
|
"""A fixed-size modal that fits the small landscape window."""
|
|
content = BoxLayout(orientation="vertical", padding=dp(8), spacing=dp(8))
|
|
body = Label(text=message, halign="center", valign="middle")
|
|
body.bind(size=lambda w, s: setattr(w, "text_size", (s[0], None)))
|
|
content.add_widget(body)
|
|
|
|
close = Button(text="OK", size_hint_y=None, height=dp(34))
|
|
content.add_widget(close)
|
|
|
|
popup = Popup(
|
|
title=title,
|
|
content=content,
|
|
size_hint=(None, None),
|
|
size=(dp(320), dp(180)),
|
|
title_size="14sp",
|
|
)
|
|
close.bind(on_press=popup.dismiss)
|
|
popup.open()
|
|
if auto_dismiss:
|
|
Clock.schedule_once(lambda *_: popup.dismiss(), 2.5)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
PtouchPrinterApp().run()
|