558 lines
21 KiB
Python
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()
|