Files
2026-07-06 00:24:30 -05:00

558 lines
21 KiB
Python

"""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("<Button-1>", click_fn)
w.bind("<Enter>", enter_fn)
w.bind("<Leave>", 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()