"""Compact Mode window — floating parts lookup for packing station use. Standalone CTkToplevel with clipboard watcher, configurable layout (vertical / horizontal), label printing, and optional always-on-top pinning. """ from __future__ import annotations import logging import re import customtkinter as ctk from .data import PartsRepo from . import theme as T log = logging.getLogger(__name__) _SKU_RE = re.compile(r'\b(\d{6,}-\d{3,}[LR]?)\b') _ADDR_RE = re.compile(r'[A-Za-z][A-Za-z\s]+,\s*[A-Z]{2}\s+\d{5}') _WIN_W_VERT = 420 _WIN_H_VERT = 640 _WIN_W_HORIZ = 800 _WIN_H_HORIZ = 440 class CompactModeWindow(ctk.CTkToplevel): """Compact floating parts-lookup window.""" def __init__( self, parent, repo: PartsRepo | None, labels, state: dict, save_state, ) -> None: super().__init__(parent) self._repo = repo self._labels = labels self._state = state self._save_state = save_state self._auto_paste = bool(state.get("compact_auto_paste", True)) self._auto_clear = bool(state.get("compact_auto_clear", False)) self._horizontal = bool(state.get("compact_horizontal", False)) self._pinned = bool(state.get("compact_pinned", False)) self._results: list[dict] = [] self._sel_part: dict | None = None self._clip_last: str = "" self._clip_job: str | None = None self._hint_job: str | None = None self.title("Compact Mode — Parts Lookup") self.minsize(340, 400) if self._pinned: self.attributes("-topmost", True) self._apply_window_geometry() self._build() self._start_clipboard_watcher() self.lift() self.focus_set() # ── Geometry ────────────────────────────────────────────────────────────── def _apply_window_geometry(self) -> None: if self._horizontal: self.geometry(f"{_WIN_W_HORIZ}x{_WIN_H_HORIZ}") else: self.geometry(f"{_WIN_W_VERT}x{_WIN_H_VERT}") # ── Build ───────────────────────────────────────────────────────────────── def _build(self) -> None: self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) if self._horizontal: self._build_horizontal() else: self._build_vertical() def _teardown_layout(self) -> None: for child in self.winfo_children(): child.destroy() def _build_vertical(self) -> None: outer = ctk.CTkFrame(self, fg_color=T.WINDOW_BG, corner_radius=0) outer.grid(row=0, column=0, sticky="nsew") outer.columnconfigure(0, weight=1) outer.rowconfigure(3, weight=1) # results list expands # Row 0: search entry self._search_var = ctk.StringVar() self._search_var.trace_add("write", self._on_search_change) self._search_entry = ctk.CTkEntry( outer, textvariable=self._search_var, placeholder_text="Search SKU or description...", font=T.FONT_LABEL, height=32, ) self._search_entry.grid(row=0, column=0, sticky="ew", padx=8, pady=(8, 4)) # Row 1: Clear + Print Label buttons btn_row = ctk.CTkFrame(outer, fg_color="transparent") btn_row.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 4)) btn_row.columnconfigure(0, weight=1) btn_row.columnconfigure(1, weight=1) ctk.CTkButton( btn_row, text="Clear", command=self.clear_search, height=30, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=5, ).grid(row=0, column=0, sticky="ew", padx=(0, 3)) ctk.CTkButton( btn_row, text="Print Label", command=self.print_label, height=30, font=T.FONT_LABEL, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, text_color=T.ACCENT_TEXT, border_width=1, border_color=T.ACCENT_BORDER, corner_radius=5, ).grid(row=0, column=1, sticky="ew", padx=(3, 0)) # Row 2: clipboard hint (hidden when empty) self._clip_hint = ctk.CTkLabel( outer, text="", font=T.FONT_STATUS, text_color=T.TEXT_MUTED, anchor="w", ) self._clip_hint.grid(row=2, column=0, sticky="w", padx=10) # Row 3: results list (expands) self._results_frame = ctk.CTkScrollableFrame( outer, fg_color=T.PANEL_BG, corner_radius=6, ) self._results_frame.grid(row=3, column=0, sticky="nsew", padx=8, pady=(0, 4)) self._results_frame.columnconfigure(0, weight=1) # Row 4: Ship-to Address addr_card = ctk.CTkFrame(outer, fg_color=T.PANEL_BG, corner_radius=6) addr_card.grid(row=4, column=0, sticky="ew", padx=8, pady=(0, 4)) addr_card.columnconfigure(0, weight=1) ctk.CTkLabel( addr_card, text="Ship-to Address", font=T.FONT_LABEL, text_color=T.TEXT_MUTED, anchor="w", ).grid(row=0, column=0, sticky="w", padx=10, pady=(8, 2)) self._address_box = ctk.CTkTextbox( addr_card, height=80, font=T.FONT_LABEL, fg_color=T.PANEL_BG, border_width=1, border_color=T.BORDER, corner_radius=4, ) self._address_box.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 6)) ctk.CTkButton( addr_card, text="Print Address", command=self.print_address, height=32, font=T.FONT_LABEL, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, text_color=T.ACCENT_TEXT, border_width=1, border_color=T.ACCENT_BORDER, corner_radius=5, ).grid(row=2, column=0, sticky="ew", padx=8, pady=(0, 8)) # Row 5: bottom bar — Auto Paste toggle + gear bottom = ctk.CTkFrame(outer, fg_color=T.PANEL_BG, corner_radius=0, height=36) bottom.grid(row=5, column=0, sticky="ew") bottom.pack_propagate(False) bottom.columnconfigure(0, weight=1) self._auto_paste_switch = ctk.CTkSwitch( bottom, text="Auto Paste", font=T.FONT_STATUS, text_color=T.TEXT_MUTED, command=self._on_auto_paste_toggle, ) if self._auto_paste: self._auto_paste_switch.select() self._auto_paste_switch.pack(side="left", padx=(10, 0), pady=8) ctk.CTkButton( bottom, text="⚙", width=28, height=24, font=T.FONT_LABEL, fg_color="transparent", hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_MUTED, border_width=0, corner_radius=4, command=self.show_label_settings, ).pack(side="right", padx=(0, 8), pady=6) def _build_horizontal(self) -> None: outer = ctk.CTkFrame(self, fg_color=T.WINDOW_BG, corner_radius=0) outer.grid(row=0, column=0, sticky="nsew") outer.columnconfigure(0, weight=1) outer.columnconfigure(1, weight=1) outer.rowconfigure(1, weight=1) left = ctk.CTkFrame(outer, fg_color="transparent") left.grid(row=0, column=0, sticky="nsew", padx=(0, 2)) left.columnconfigure(0, weight=1) left.rowconfigure(3, weight=1) self._search_var = ctk.StringVar() self._search_var.trace_add("write", self._on_search_change) self._search_entry = ctk.CTkEntry( left, textvariable=self._search_var, placeholder_text="Search SKU or description...", font=T.FONT_LABEL, height=32, ) self._search_entry.grid(row=0, column=0, sticky="ew", padx=8, pady=(8, 4)) btn_row = ctk.CTkFrame(left, fg_color="transparent") btn_row.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 4)) btn_row.columnconfigure(0, weight=1) btn_row.columnconfigure(1, weight=1) ctk.CTkButton( btn_row, text="Clear", command=self.clear_search, height=30, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=5, ).grid(row=0, column=0, sticky="ew", padx=(0, 3)) ctk.CTkButton( btn_row, text="Print Label", command=self.print_label, height=30, font=T.FONT_LABEL, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, text_color=T.ACCENT_TEXT, border_width=1, border_color=T.ACCENT_BORDER, corner_radius=5, ).grid(row=0, column=1, sticky="ew", padx=(3, 0)) self._clip_hint = ctk.CTkLabel( left, text="", font=T.FONT_STATUS, text_color=T.TEXT_MUTED, anchor="w", ) self._clip_hint.grid(row=2, column=0, sticky="w", padx=10) self._results_frame = ctk.CTkScrollableFrame( left, fg_color=T.PANEL_BG, corner_radius=6, ) self._results_frame.grid(row=3, column=0, sticky="nsew", padx=8, pady=(0, 8)) self._results_frame.columnconfigure(0, weight=1) right = ctk.CTkFrame(outer, fg_color="transparent") right.grid(row=0, column=1, sticky="nsew") right.columnconfigure(0, weight=1) right.rowconfigure(1, weight=1) ctk.CTkLabel( right, text="Ship-to Address", font=T.FONT_LABEL, text_color=T.TEXT_MUTED, anchor="w", ).grid(row=0, column=0, sticky="w", padx=10, pady=(8, 2)) self._address_box = ctk.CTkTextbox( right, font=T.FONT_LABEL, fg_color=T.PANEL_BG, border_width=1, border_color=T.BORDER, corner_radius=4, ) self._address_box.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 6)) ctk.CTkButton( right, text="Print Address", command=self.print_address, height=32, font=T.FONT_LABEL, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, text_color=T.ACCENT_TEXT, border_width=1, border_color=T.ACCENT_BORDER, corner_radius=5, ).grid(row=2, column=0, sticky="ew", padx=8, pady=(0, 8)) # ── Toggle actions ──────────────────────────────────────────────────────── def _on_auto_paste_toggle(self) -> None: self._auto_paste = not self._auto_paste self._state["compact_auto_paste"] = self._auto_paste self._save_state(self._state) # ── Search ──────────────────────────────────────────────────────────────── def _on_search_change(self, *_) -> None: self.after(80, self._run_search) def _run_search(self) -> None: query = self._search_var.get().strip() if not query or self._repo is None: self._render_results([]) return results = self._repo.search(query, limit=10) self._render_results(results) def _render_results(self, results: list[dict]) -> None: self._results = results self._cm_rows: list[dict] = [] for child in self._results_frame.winfo_children(): child.destroy() self._results_frame.columnconfigure(0, weight=1) for i, part in enumerate(results): card = ctk.CTkFrame( self._results_frame, fg_color=T.CARD_BG, border_color=T.BORDER, border_width=1, corner_radius=6, cursor="hand2", ) card.grid(row=i, column=0, sticky="ew", padx=8, pady=(0, 4)) card.columnconfigure(0, weight=1) sku = part.get("stock_number", "") or part.get("STOCK NUMBER", "") desc = part.get("description", "") or part.get("DESCRIPTION", "") sku_lbl = ctk.CTkLabel( card, text=sku, font=T.FONT_RESULT_TITLE, text_color=T.TEXT_PRIMARY, anchor="w", ) sku_lbl.grid(row=0, column=0, sticky="w", padx=14, pady=(9, 1)) desc_lbl = ctk.CTkLabel( card, text=desc[:72] + ("…" if len(desc) > 72 else ""), font=T.FONT_RESULT, text_color=T.TEXT_MUTED, anchor="w", wraplength=260, justify="left", ) desc_lbl.grid(row=1, column=0, sticky="w", padx=14, pady=(0, 9)) row_widgets = {"card": card, "sku_lbl": sku_lbl, "desc_lbl": desc_lbl} self._cm_rows.append(row_widgets) def _make_handlers(idx, p): def _click(_e=None): self._on_result_click(p, idx) def _enter(_e=None): if self._cm_sel_idx != idx: card.configure(fg_color=T.CARD_BG_HOVER) def _leave(_e=None): if self._cm_sel_idx != idx: card.configure(fg_color=T.CARD_BG) return _click, _enter, _leave click_fn, enter_fn, leave_fn = _make_handlers(i, part) for w in (card, sku_lbl, desc_lbl): w.bind("", click_fn) w.bind("", enter_fn) w.bind("", leave_fn) self._cm_sel_idx: int | None = None if results: self._on_result_click(results[0], 0) def _cm_highlight(self, index: int) -> None: for i, row_w in enumerate(self._cm_rows): selected = (i == index) card = row_w["card"] sku_lbl = row_w["sku_lbl"] desc_lbl = row_w["desc_lbl"] if selected: card.configure(fg_color=T.ACCENT, border_color=T.ACCENT) sku_lbl.configure(text_color=T.ACCENT_TEXT) desc_lbl.configure(text_color=T.ACCENT_TEXT) else: card.configure(fg_color=T.CARD_BG, border_color=T.BORDER) sku_lbl.configure(text_color=T.TEXT_PRIMARY) desc_lbl.configure(text_color=T.TEXT_MUTED) def _on_result_click(self, part: dict, index: int | None = None) -> None: self._sel_part = part if index is not None: self._cm_sel_idx = index self._cm_highlight(index) addr = self.assembled_address() if addr: try: self._address_box.delete("1.0", "end") self._address_box.insert("1.0", addr) except Exception: pass def assembled_address(self) -> str: if not self._sel_part: return "" lines = [] for key in ("address1", "address2", "city", "state", "zip"): val = self._sel_part.get(key, "") if val: lines.append(str(val)) return "\n".join(lines) # ── Clipboard watcher ───────────────────────────────────────────────────── def _start_clipboard_watcher(self) -> None: try: self._clip_last = self.clipboard_get() except Exception: self._clip_last = "" self._schedule_clipboard_poll() def _schedule_clipboard_poll(self) -> None: try: self._clip_job = self.after(500, self._poll_clipboard) except Exception: pass def _poll_clipboard(self) -> None: try: text = self.clipboard_get() except Exception: text = self._clip_last if text != self._clip_last: self._clip_last = text self._handle_clipboard(text) self._schedule_clipboard_poll() def _handle_clipboard(self, text: str) -> None: if not self._auto_paste: return sku_m = _SKU_RE.search(text) if sku_m: sku = sku_m.group(1) self._show_clipboard_hint(f"Pasted SKU: {sku}") if self._auto_clear: self._search_var.set("") self._search_var.set(sku) try: self._search_entry.icursor("end") except Exception: pass return addr_m = _ADDR_RE.search(text) if addr_m: self._show_clipboard_hint("Address detected") try: self._address_box.delete("1.0", "end") self._address_box.insert("1.0", text.strip()) except Exception: pass def _show_clipboard_hint(self, msg: str) -> None: try: self._clip_hint.configure(text=msg) if self._hint_job: self.after_cancel(self._hint_job) self._hint_job = self.after(3000, self._hide_clipboard_hint) except Exception: pass def _hide_clipboard_hint(self) -> None: try: self._clip_hint.configure(text="") except Exception: pass # ── Print actions ───────────────────────────────────────────────────────── def print_label(self) -> None: if not self._labels or not self._sel_part: return sku = self._sel_part.get("sku", "") desc = self._sel_part.get("description", "") try: self._labels.print_label(sku, desc) except Exception as exc: log.warning("Compact mode print_label failed: %s", exc) def print_address(self) -> None: if not self._labels: return try: address = self._address_box.get("1.0", "end").strip() except Exception: address = "" if not address: return try: self._labels.print_address_label(address) except Exception as exc: log.warning("Compact mode print_address failed: %s", exc) def show_label_settings(self) -> None: if not self._labels: return popup = ctk.CTkToplevel(self) popup.title("Label Printer Settings") popup.geometry("360x200") popup.grab_set() popup.columnconfigure(0, weight=1) ctk.CTkLabel( popup, text="Select Label Printer", font=T.FONT_LABEL, text_color=T.TEXT_PRIMARY, ).grid(row=0, column=0, pady=(16, 4)) printers: list[str] = [] try: printers = self._labels.available_printers() except Exception: pass current = "" try: current = self._labels.get_printer() or "" except Exception: pass var = ctk.StringVar(value=current) if printers: ctk.CTkOptionMenu( popup, values=printers, variable=var, font=T.FONT_LABEL, width=280, ).grid(row=1, column=0, pady=6) else: ctk.CTkLabel( popup, text="No printers found", font=T.FONT_LABEL, text_color=T.TEXT_MUTED, ).grid(row=1, column=0, pady=6) def _apply() -> None: self._set_printer(var.get()) popup.destroy() btn_row = ctk.CTkFrame(popup, fg_color="transparent") btn_row.grid(row=2, column=0, pady=8) ctk.CTkButton(btn_row, text="Apply", width=80, command=_apply).pack( side="left", padx=4) ctk.CTkButton( btn_row, text="Preferences...", width=108, command=self._open_printer_preferences, ).pack(side="left", padx=4) def _set_printer(self, name: str) -> None: if self._labels and name: try: self._labels.set_printer(name) except Exception as exc: log.warning("Could not set printer: %s", exc) def _open_printer_preferences(self) -> None: if self._labels: try: self._labels.open_preferences() except Exception as exc: log.warning("Could not open printer preferences: %s", exc) # ── Search layout sync (called after resize) ────────────────────────────── def _on_window_resize(self, event) -> None: pass def _sync_search_layout(self) -> None: pass def _apply_search_layout(self) -> None: pass # ── Public ──────────────────────────────────────────────────────────────── def clear_search(self) -> None: try: self._search_var.set("") self._search_entry.focus_set() except Exception: pass def destroy(self) -> None: for job in (self._clip_job, self._hint_job): if job: try: self.after_cancel(job) except Exception: pass super().destroy()