""" 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()