Files
CSG-Picker-Shipper-Helper/app/ui_parts_tab.py
T
2026-07-06 00:24:30 -05:00

1850 lines
75 KiB
Python

"""Parts tab — packer workflow.
Performance / smoothness notes
------------------------------
- Search is debounced (80 ms) so typing fast doesn't queue a re-render per
keystroke.
- Result rows are a pre-created pool of _ResultRow widgets that we just
reconfigure on every render. Each row uses two left-anchored CTkLabels
inside a CTkFrame so text is properly left-aligned and the row's hit area
matches the visible card.
- Each rounded-corner CTk widget is backed by a tk.Canvas that has to repaint
on every resize. The outer results / detail panels use sharp corners
(corner_radius=0) to reduce the number of canvases repainting during a
window drag. The visible "cards" inside still have rounded corners since
they're the actual visual pieces.
- The detail panel lives inside a CTkScrollableFrame so packers can see every
field even on shorter monitors.
"""
from __future__ import annotations
import logging
import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog
from typing import Callable
import customtkinter as ctk
from PIL import Image
from .cut_files import CutFileManager, open_file
from .data import PartsRepo
from .images import ImageCache
from . import theme as T
log = logging.getLogger(__name__)
IMAGE_W = 320
IMAGE_H = 220
SEARCH_DEBOUNCE_MS = 80
# ── Shared helpers ─────────────────────────────────────────────────────────────
def _bind_context_menu(widget) -> None:
menu = tk.Menu(widget, tearoff=0)
menu.add_command(label="Cut", command=lambda: widget.event_generate("<<Cut>>"))
menu.add_command(label="Copy", command=lambda: widget.event_generate("<<Copy>>"))
menu.add_command(label="Paste", command=lambda: widget.event_generate("<<Paste>>"))
menu.add_separator()
menu.add_command(label="Select All", command=lambda: widget.event_generate("<<SelectAll>>"))
def _show(event):
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
widget.bind("<Button-3>", _show)
def _flash_button(btn, color, *, restore_text: str, flash_text: str,
duration_ms: int = 1200) -> None:
orig = btn.cget("text_color")
btn.configure(text=flash_text, text_color=color)
btn.after(duration_ms, lambda: btn.configure(text=restore_text, text_color=orig))
def _format_price(raw: str) -> str:
if not raw:
return "—"
raw = raw.strip().lstrip("$")
try:
return f"${float(raw):,.2f}"
except ValueError:
return raw or "—"
def _copy_to_clipboard(text: str) -> bool:
try:
import pyperclip
pyperclip.copy(text)
return True
except Exception as exc:
log.debug("Clipboard copy failed: %s", exc)
return False
def _truncate(text: str, n: int) -> str:
if len(text) <= n:
return text
return text[:n] + "..."
# ── _CopyRow ───────────────────────────────────────────────────────────────────
class _CopyRow(ctk.CTkFrame):
"""Label + value + Copy button row."""
def __init__(self, parent, label: str, *, wraplength: int = 0, **kwargs) -> None:
kwargs.setdefault("fg_color", "transparent")
super().__init__(parent, **kwargs)
self.columnconfigure(1, weight=1)
ctk.CTkLabel(self, text=label, font=T.FONT_LABEL,
text_color=T.TEXT_MUTED, width=90, anchor="w").grid(
row=0, column=0, sticky="w", padx=(0, 6))
lbl_kw = dict(text="", font=T.FONT_BODY, text_color=T.TEXT_PRIMARY,
anchor="w", justify="left")
if wraplength:
lbl_kw["wraplength"] = wraplength
self._val_lbl = ctk.CTkLabel(self, **lbl_kw)
self._val_lbl.grid(row=0, column=1, sticky="w")
self._copy_btn = ctk.CTkButton(
self, text="Copy", width=54, height=24, 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,
command=self._on_copy,
)
self._copy_btn.grid(row=0, column=2, padx=(8, 0))
def set_value(self, value: str) -> None:
self._val_lbl.configure(text=value or "—")
self._raw = value
def _on_copy(self) -> None:
if _copy_to_clipboard(getattr(self, "_raw", "")):
_flash_button(self._copy_btn, T.COPY_OK,
restore_text="Copy", flash_text="Copied")
# ── _FactTile ──────────────────────────────────────────────────────────────────
class _FactTile(ctk.CTkFrame):
"""Label on top, big value below."""
def __init__(self, parent, label: str, **kwargs) -> None:
kwargs.setdefault("fg_color", T.CARD_BG)
kwargs.setdefault("border_color", T.BORDER)
kwargs.setdefault("border_width", 1)
kwargs.setdefault("corner_radius", 6)
super().__init__(parent, **kwargs)
self.columnconfigure(0, weight=1)
ctk.CTkLabel(self, text=label, font=T.FONT_LABEL,
text_color=T.TEXT_MUTED).grid(
row=0, column=0, sticky="w", padx=10, pady=(8, 2))
self._lbl = ctk.CTkLabel(self, text="—", font=T.FONT_FACT_VALUE,
text_color=T.TEXT_PRIMARY)
self._lbl.grid(row=1, column=0, sticky="w", padx=10, pady=(0, 8))
def set_value(self, value: str) -> None:
self._lbl.configure(text=value or "—")
# ── _CollapsibleFrame ──────────────────────────────────────────────────────────
class _CollapsibleFrame(ctk.CTkFrame):
"""A collapsible frame that toggles visibility of its content on click."""
def __init__(self, parent, title: str, *, start_open: bool = True,
**kwargs) -> None:
kwargs.setdefault("fg_color", T.PANEL_BG)
kwargs.setdefault("border_color", T.BORDER)
kwargs.setdefault("border_width", 1)
kwargs.setdefault("corner_radius", 8)
super().__init__(parent, **kwargs)
self.columnconfigure(0, weight=1)
self._open = start_open
hdr = ctk.CTkFrame(self, fg_color="transparent", cursor="hand2")
hdr.grid(row=0, column=0, sticky="ew", padx=12, pady=(8, 6))
hdr.columnconfigure(0, weight=1)
ctk.CTkLabel(hdr, text=title, font=T.FONT_BODY_BOLD,
text_color=T.TEXT_PRIMARY).grid(row=0, column=0, sticky="w")
self._arrow = ctk.CTkLabel(hdr, text="▾" if start_open else "▸",
font=T.FONT_BODY_BOLD, text_color=T.TEXT_MUTED)
self._arrow.grid(row=0, column=1, sticky="e")
self.content = ctk.CTkFrame(self, fg_color="transparent")
self.content.grid(row=1, column=0, sticky="ew")
self.content.columnconfigure(0, weight=1)
if not start_open:
self.content.grid_remove()
hdr.bind("<Button-1>", self.toggle)
self._arrow.bind("<Button-1>", self.toggle)
def toggle(self, _e=None) -> None:
self._open = not self._open
if self._open:
self.content.grid()
self._arrow.configure(text="▾")
else:
self.content.grid_remove()
self._arrow.configure(text="▸")
def _on_enter(self, _e=None) -> None:
pass
def _on_leave(self, _e=None) -> None:
pass
# ── _PartDetailsPopup ──────────────────────────────────────────────────────────
class _PartDetailsPopup:
"""Floating detail popup shown on hover.
Created lazily on first show. Stays visible while the mouse is anywhere
inside it; auto-hides 300 ms after the mouse leaves.
"""
_HIDE_MS = 300
def __init__(self, master: ctk.CTkFrame) -> None:
self._master = master
self._win: ctk.CTkToplevel | None = None
self._after_id: str | None = None
# Label refs populated by _ensure_window
self._sku_lbl: ctk.CTkLabel | None = None
self._desc_lbl: ctk.CTkLabel | None = None
self._tiles: dict[str, ctk.CTkLabel] = {}
# ── public ────────────────────────────────────────────────────────────────
def set_values(self, part: dict) -> None:
self._ensure_window()
sku = part.get("stock_number", "") or part.get("STOCK NUMBER", "")
desc = part.get("description", "") or part.get("DESCRIPTION", "")
grade = part.get("grade", "") or part.get("GRADE", "")
net = part.get("customer_price", "") or part.get("Customer Price", "")
yard = part.get("yard_location", "") or part.get("YARD LOCATION", "")
if self._sku_lbl:
self._sku_lbl.configure(text=sku or "(no SKU)")
if self._desc_lbl:
self._desc_lbl.configure(text=_truncate(desc, 80))
self._tiles.get("net", None) and self._tiles["net"].configure(
text=_format_price(net))
self._tiles.get("grade", None) and self._tiles["grade"].configure(
text=grade or "—")
self._tiles.get("facts", None) and self._tiles["facts"].configure(
text=yard or "—")
def show_near(self, anchor: tk.Widget, part: dict) -> None:
"""Place the popup near `anchor_widget` and show it instantly."""
self._cancel_hide()
self.set_values(part)
win = self._ensure_window()
if not win:
return
win.update_idletasks()
aw = anchor.winfo_width()
ax = anchor.winfo_rootx()
ay = anchor.winfo_rooty()
pw = win.winfo_reqwidth()
ph = win.winfo_reqheight()
sw = anchor.winfo_screenwidth()
sh = anchor.winfo_screenheight()
x = ax + aw + 4
y = ay
if x + pw > sw:
x = ax - pw - 4
if y + ph > sh:
y = sh - ph - 8
y = max(0, y)
win.geometry(f"+{x}+{y}")
win.deiconify()
win.lift()
win.attributes("-topmost", True)
def hide_soon(self) -> None:
"""Schedule a hide after a brief grace period."""
self._begin_hide()
def keep_open(self) -> None:
self._cancel_hide()
def _begin_hide(self) -> None:
if self._after_id is None:
self._after_id = self._master.after(
self._HIDE_MS, self._finish_hide)
def _finish_hide(self) -> None:
self._after_id = None
if self._win and self._win.winfo_exists():
self._win.withdraw()
def _cancel_hide(self) -> None:
"""Cancel any pending hide (used when mouse re-enters popup or icon)."""
if self._after_id is not None:
self._master.after_cancel(self._after_id)
self._after_id = None
def _on_enter_popup(self, _e=None) -> None:
self._cancel_hide()
def _on_leave_popup(self, _e=None) -> None:
self._begin_hide()
# ── internal ──────────────────────────────────────────────────────────────
def _ensure_window(self) -> ctk.CTkToplevel | None:
if self._win and self._win.winfo_exists():
return self._win
try:
win = ctk.CTkToplevel(self._master)
except Exception:
return None
win.overrideredirect(True)
win.attributes("-topmost", True)
win.withdraw()
win.configure(fg_color=T.CARD_BG)
outer = ctk.CTkFrame(win, fg_color=T.CARD_BG,
border_color=T.BORDER_STRONG, border_width=1,
corner_radius=8)
outer.pack(fill="both", expand=True, padx=1, pady=1)
outer.columnconfigure(0, weight=1)
def _add_tile(key: str, label: str, font=T.FONT_BODY,
color=T.TEXT_PRIMARY, row: int = 0) -> ctk.CTkLabel:
ctk.CTkLabel(outer, text=label, font=T.FONT_LABEL,
text_color=T.TEXT_MUTED).grid(
row=row * 2, column=0, sticky="w", padx=14, pady=(8, 0))
lbl = ctk.CTkLabel(outer, text="—", font=font,
text_color=color, wraplength=240, justify="left")
lbl.grid(row=row * 2 + 1, column=0, sticky="w",
padx=14, pady=(0, 4))
self._tiles[key] = lbl
return lbl
# SKU large
self._sku_lbl = ctk.CTkLabel(outer, text="", font=T.FONT_SKU,
text_color=T.TEXT_PRIMARY, anchor="w")
self._sku_lbl.grid(row=0, column=0, sticky="w", padx=14, pady=(12, 2))
# Description
self._desc_lbl = ctk.CTkLabel(outer, text="", font=T.FONT_BODY,
text_color=T.TEXT_MUTED,
wraplength=260, justify="left", anchor="w")
self._desc_lbl.grid(row=1, column=0, sticky="w", padx=14, pady=(0, 8))
ctk.CTkFrame(outer, fg_color=T.DIVIDER, height=1).grid(
row=2, column=0, sticky="ew", padx=10, pady=0)
_add_tile("net", "NET PRICE", font=T.FONT_PRICE_NET, color=T.NET_COLOR, row=2)
_add_tile("grade", "Grade", font=T.FONT_BODY_BOLD, row=3)
_add_tile("facts", "Yard", font=T.FONT_BODY, row=4)
ctk.CTkFrame(outer, fg_color="transparent", height=6).grid(
row=11, column=0)
for w in self._all_descendants(win):
w.bind("<Enter>", self._on_enter_popup, add="+")
w.bind("<Leave>", self._on_leave_popup, add="+")
self._win = win
return win
def _all_descendants(self, widget: tk.Widget) -> list[tk.Widget]:
result = [widget]
for child in widget.winfo_children():
result.extend(self._all_descendants(child))
return result
# ── _ResultRow ─────────────────────────────────────────────────────────────────
class _ResultRow(ctk.CTkFrame):
"""A single row in the search results list."""
def __init__(self, parent, on_click: Callable) -> None:
super().__init__(parent, fg_color=T.CARD_BG,
border_color=T.BORDER, border_width=1,
corner_radius=6, cursor="hand2")
self.columnconfigure(0, weight=1)
self._on_click = on_click
self._part: dict | None = None
self._selected = False
self._sku_lbl = ctk.CTkLabel(self, text="", font=T.FONT_RESULT_TITLE,
text_color=T.TEXT_PRIMARY, anchor="w")
self._sku_lbl.grid(row=0, column=0, sticky="w", padx=14, pady=(9, 1))
self._desc_lbl = ctk.CTkLabel(self, text="", font=T.FONT_RESULT,
text_color=T.TEXT_MUTED, anchor="w",
wraplength=280, justify="left")
self._desc_lbl.grid(row=1, column=0, sticky="w", padx=14, pady=(0, 9))
for w in (self, self._sku_lbl, self._desc_lbl):
w.bind("<Button-1>", self._handle_click)
w.bind("<Enter>", self._on_enter)
w.bind("<Leave>", self._on_leave)
def set_part(self, part: dict) -> None:
self._part = part
sku = part.get("stock_number", "") or part.get("STOCK NUMBER", "")
desc = part.get("description", "") or part.get("DESCRIPTION", "")
self._sku_lbl.configure(text=sku)
self._desc_lbl.configure(text=_truncate(desc, 72))
def set_selected(self, selected: bool) -> None:
self._selected = selected
if selected:
self.configure(fg_color=T.ACCENT, border_color=T.ACCENT)
self._sku_lbl.configure(text_color=T.ACCENT_TEXT)
self._desc_lbl.configure(text_color=T.ACCENT_TEXT)
else:
self.configure(fg_color=T.CARD_BG, border_color=T.BORDER)
self._sku_lbl.configure(text_color=T.TEXT_PRIMARY)
self._desc_lbl.configure(text_color=T.TEXT_MUTED)
def _handle_click(self, _e=None) -> None:
if self._part is not None:
self._on_click(self._part)
def _on_enter(self, _e=None) -> None:
if not self._selected:
self.configure(fg_color=T.CARD_BG_HOVER)
def _on_leave(self, _e=None) -> None:
if not self._selected:
self.configure(fg_color=T.CARD_BG)
# ── _FilterMenu ────────────────────────────────────────────────────────────────
class _FilterMenu:
"""Popup filter panel: Vehicle Make, Model, Side.
Attached to a filter button; positioned below it on open.
"""
_SIDE_OPTS = ("left", "right", "both")
def __init__(self, parent: ctk.CTkFrame,
on_change: Callable) -> None:
self._parent = parent
self._on_change = on_change
self._win: ctk.CTkToplevel | None = None
self._after_id: str | None = None
# Available options (populated when data loads)
self._avail_makes: list[str] = []
self._avail_models: list[str] = []
# Selected sets
self._sel_makes: set[str] = set()
self._sel_models: set[str] = set()
self._sel_sides: set[str] = set()
# CTkButton refs so we can update their colors when selection changes
self._make_btns: dict[str, ctk.CTkButton] = {}
self._model_btns: dict[str, ctk.CTkButton] = {}
self._side_btns: dict[str, ctk.CTkButton] = {}
# ── public ────────────────────────────────────────────────────────────────
def update_makes(self, makes: list[str]) -> None:
self._avail_makes = list(makes)
if self._is_open():
self.close()
def update_models(self, models: list[str]) -> None:
self._avail_models = list(models)
if self._is_open():
self.close()
def selected_makes(self) -> set[str]:
return set(self._sel_makes)
def selected_models(self) -> set[str]:
return set(self._sel_models)
def selected_sides(self) -> set[str]:
return set(self._sel_sides)
def total_active(self) -> int:
return len(self._sel_makes) + len(self._sel_models) + len(self._sel_sides)
def clear_all(self) -> None:
self._sel_makes.clear()
self._sel_models.clear()
self._sel_sides.clear()
self._refresh_btn_colors()
self._on_change()
def toggle(self) -> None:
if self._is_open():
self.close()
else:
self._open()
def close(self) -> None:
if self._after_id:
try:
self._parent.after_cancel(self._after_id)
except Exception:
pass
self._after_id = None
if self._win:
try:
if self._win.winfo_exists():
self._win.destroy()
except Exception:
pass
self._win = None
self._make_btns.clear()
self._model_btns.clear()
self._side_btns.clear()
# ── internal ──────────────────────────────────────────────────────────────
def _is_open(self) -> bool:
return (self._win is not None
and self._win.winfo_exists()
and self._win.winfo_viewable())
def _open(self) -> None:
self.close()
win = ctk.CTkToplevel(self._parent)
win.overrideredirect(True)
win.attributes("-topmost", True)
win.configure(fg_color=T.PANEL_BG)
win.withdraw()
outer = ctk.CTkFrame(win, fg_color=T.PANEL_BG,
border_color=T.BORDER_STRONG, border_width=1,
corner_radius=8)
outer.pack(fill="both", expand=True, padx=1, pady=1)
outer.columnconfigure(0, weight=1)
# Header
hdr = ctk.CTkFrame(outer, fg_color="transparent")
hdr.grid(row=0, column=0, sticky="ew", padx=12, pady=(10, 4))
hdr.columnconfigure(0, weight=1)
ctk.CTkLabel(hdr, text="Filters", font=T.FONT_BODY_BOLD,
text_color=T.TEXT_PRIMARY).grid(row=0, column=0, sticky="w")
clear_btn = ctk.CTkButton(
hdr, text="Clear", width=54, height=24, 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,
command=lambda: [self.clear_all(),
self._refresh_btn_colors()],
)
clear_btn.grid(row=0, column=1, sticky="e")
row_idx = 1
# ── Vehicle Make section ───────────────────────────────────────────
ctk.CTkFrame(outer, fg_color=T.DIVIDER, height=1).grid(
row=row_idx, column=0, sticky="ew", padx=10, pady=(4, 0))
row_idx += 1
ctk.CTkLabel(outer, text="Vehicle Make", font=T.FONT_LABEL,
text_color=T.TEXT_MUTED).grid(
row=row_idx, column=0, sticky="w", padx=12, pady=(6, 4))
row_idx += 1
if self._avail_makes:
makes_wrap = ctk.CTkFrame(outer, fg_color="transparent")
makes_wrap.grid(row=row_idx, column=0, sticky="ew", padx=10, pady=(0, 4))
col_count = 3
for i, make in enumerate(self._avail_makes):
btn = ctk.CTkButton(
makes_wrap, text=make, width=90, height=26,
font=T.FONT_LABEL,
fg_color=T.ACCENT if make in self._sel_makes else T.NEUTRAL_BTN_BG,
hover_color=T.ACCENT_HOVER if make in self._sel_makes else T.NEUTRAL_BTN_HOVER,
text_color=T.ACCENT_TEXT if make in self._sel_makes else T.TEXT_PRIMARY,
border_width=1, border_color=T.NEUTRAL_BTN_BORDER,
corner_radius=5,
command=lambda m=make: self._toggle_make(m),
)
btn.grid(row=i // col_count, column=i % col_count,
padx=3, pady=2)
self._make_btns[make] = btn
else:
ctk.CTkLabel(outer, text="(no makes loaded yet)", font=T.FONT_BODY,
text_color=T.TEXT_SUBTLE).grid(
row=row_idx, column=0, sticky="w", padx=14, pady=(0, 4))
row_idx += 1
# ── Model section ──────────────────────────────────────────────────
ctk.CTkFrame(outer, fg_color=T.DIVIDER, height=1).grid(
row=row_idx, column=0, sticky="ew", padx=10, pady=(2, 0))
row_idx += 1
ctk.CTkLabel(outer, text="Model", font=T.FONT_LABEL,
text_color=T.TEXT_MUTED).grid(
row=row_idx, column=0, sticky="w", padx=12, pady=(6, 4))
row_idx += 1
if self._avail_models:
models_wrap = ctk.CTkFrame(outer, fg_color="transparent")
models_wrap.grid(row=row_idx, column=0, sticky="ew", padx=10, pady=(0, 4))
col_count = 3
for i, model in enumerate(self._avail_models[:24]):
btn = ctk.CTkButton(
models_wrap, text=model, width=90, height=26,
font=T.FONT_LABEL,
fg_color=T.ACCENT if model in self._sel_models else T.NEUTRAL_BTN_BG,
hover_color=T.ACCENT_HOVER if model in self._sel_models else T.NEUTRAL_BTN_HOVER,
text_color=T.ACCENT_TEXT if model in self._sel_models else T.TEXT_PRIMARY,
border_width=1, border_color=T.NEUTRAL_BTN_BORDER,
corner_radius=5,
command=lambda m=model: self._toggle_model(m),
)
btn.grid(row=i // col_count, column=i % col_count,
padx=3, pady=2)
self._model_btns[model] = btn
else:
ctk.CTkLabel(outer, text="(no models loaded yet)", font=T.FONT_BODY,
text_color=T.TEXT_SUBTLE).grid(
row=row_idx, column=0, sticky="w", padx=14, pady=(0, 4))
row_idx += 1
# ── Side section ───────────────────────────────────────────────────
ctk.CTkFrame(outer, fg_color=T.DIVIDER, height=1).grid(
row=row_idx, column=0, sticky="ew", padx=10, pady=(2, 0))
row_idx += 1
ctk.CTkLabel(outer, text="Side", font=T.FONT_LABEL,
text_color=T.TEXT_MUTED).grid(
row=row_idx, column=0, sticky="w", padx=12, pady=(6, 4))
row_idx += 1
side_wrap = ctk.CTkFrame(outer, fg_color="transparent")
side_wrap.grid(row=row_idx, column=0, sticky="w", padx=10, pady=(0, 10))
for i, side in enumerate(self._SIDE_OPTS):
label = side.capitalize()
btn = ctk.CTkButton(
side_wrap, text=label, width=76, height=26,
font=T.FONT_LABEL,
fg_color=T.ACCENT if side in self._sel_sides else T.NEUTRAL_BTN_BG,
hover_color=T.ACCENT_HOVER if side in self._sel_sides else T.NEUTRAL_BTN_HOVER,
text_color=T.ACCENT_TEXT if side in self._sel_sides else T.TEXT_PRIMARY,
border_width=1, border_color=T.NEUTRAL_BTN_BORDER,
corner_radius=5,
command=lambda s=side: self._toggle_side(s),
)
btn.grid(row=0, column=i, padx=3)
self._side_btns[side] = btn
row_idx += 1
self._win = win
self._position_popup()
win.deiconify()
win.bind("<FocusOut>", self._on_focus_out)
def _position_popup(self) -> None:
if not self._win or not self._win.winfo_exists():
return
self._win.update_idletasks()
# Find the filter button by searching for it in the parent
btn_widget = None
try:
for child in self._parent.winfo_children():
if hasattr(child, "_filter_menu_anchor") and child._filter_menu_anchor:
btn_widget = child
break
except Exception:
pass
if btn_widget:
x = btn_widget.winfo_rootx()
y = btn_widget.winfo_rooty() + btn_widget.winfo_height() + 2
else:
x = self._parent.winfo_rootx() + 10
y = self._parent.winfo_rooty() + 60
pw = self._win.winfo_reqwidth()
ph = self._win.winfo_reqheight()
sw = self._parent.winfo_screenwidth()
sh = self._parent.winfo_screenheight()
if x + pw > sw:
x = sw - pw - 8
if y + ph > sh:
y = y - ph - 40
self._win.geometry(f"+{x}+{y}")
def _on_focus_out(self, _e=None) -> None:
def _check() -> None:
if not self._win or not self._win.winfo_exists():
return
try:
focused = self._win.focus_get()
if focused and str(focused).startswith(str(self._win)):
return
except Exception:
pass
self.close()
self._after_id = self._parent.after(150, _check)
def _toggle_make(self, make: str) -> None:
if make in self._sel_makes:
self._sel_makes.discard(make)
else:
self._sel_makes.add(make)
self._refresh_btn_colors()
self._on_change()
def _toggle_model(self, model: str) -> None:
if model in self._sel_models:
self._sel_models.discard(model)
else:
self._sel_models.add(model)
self._refresh_btn_colors()
self._on_change()
def _toggle_side(self, side: str) -> None:
if side in self._sel_sides:
self._sel_sides.discard(side)
else:
self._sel_sides.add(side)
self._refresh_btn_colors()
self._on_change()
def _refresh_btn_colors(self) -> None:
for make, btn in self._make_btns.items():
active = make in self._sel_makes
try:
btn.configure(
fg_color=T.ACCENT if active else T.NEUTRAL_BTN_BG,
hover_color=T.ACCENT_HOVER if active else T.NEUTRAL_BTN_HOVER,
text_color=T.ACCENT_TEXT if active else T.TEXT_PRIMARY,
)
except Exception:
pass
for model, btn in self._model_btns.items():
active = model in self._sel_models
try:
btn.configure(
fg_color=T.ACCENT if active else T.NEUTRAL_BTN_BG,
hover_color=T.ACCENT_HOVER if active else T.NEUTRAL_BTN_HOVER,
text_color=T.ACCENT_TEXT if active else T.TEXT_PRIMARY,
)
except Exception:
pass
for side, btn in self._side_btns.items():
active = side in self._sel_sides
try:
btn.configure(
fg_color=T.ACCENT if active else T.NEUTRAL_BTN_BG,
hover_color=T.ACCENT_HOVER if active else T.NEUTRAL_BTN_HOVER,
text_color=T.ACCENT_TEXT if active else T.TEXT_PRIMARY,
)
except Exception:
pass
# ── PartsTab ───────────────────────────────────────────────────────────────────
class PartsTab(ctk.CTkFrame):
"""Main Parts Lookup tab: search on the left, detail panel on the right."""
def __init__(
self,
parent,
*,
repo: PartsRepo | None = None,
images: ImageCache | None = None,
max_results: int = 15,
cut_files: CutFileManager | None = None,
lightburn_files: CutFileManager | None = None,
labels=None,
) -> None:
super().__init__(parent, fg_color="transparent")
self._repo = repo
self._image_cache = images
self._max_results = max_results
self._cut_manager = cut_files
self._lb_manager = lightburn_files
self._labels = labels
self._results: list[dict] = []
self._sel_part: dict | None = None
self._row_pool: list[_ResultRow] = []
self._filter_menu = _FilterMenu(self, self._on_filter_change)
self._placeholder_ctk: ctk.CTkImage | None = None
self.columnconfigure(0, weight=0)
self.columnconfigure(1, weight=0)
self.columnconfigure(2, weight=1)
self.rowconfigure(0, weight=1)
self._build()
# ── Build ──────────────────────────────────────────────────────────────────
def _build(self) -> None:
self.rowconfigure(0, weight=0) # search bar row (fixed height)
self.rowconfigure(1, weight=1) # content row (expands)
# ── Row 0: Full-width search bar ───────────────────────────────────
search_row = ctk.CTkFrame(self, fg_color=T.PANEL_BG, corner_radius=0,
border_width=0)
search_row.grid(row=0, column=0, columnspan=3, sticky="ew")
search_row.columnconfigure(1, weight=1)
ctk.CTkLabel(
search_row, text="Search", font=T.FONT_BODY_BOLD,
text_color=T.TEXT_PRIMARY, anchor="w", width=64,
).grid(row=0, column=0, sticky="w", padx=(14, 6), pady=10)
self._search_var = ctk.StringVar()
self._search_var.trace_add("write", lambda *_: self._on_search_change())
self._search_entry = ctk.CTkEntry(
search_row,
textvariable=self._search_var,
placeholder_text="Type a SKU or description (e.g. 0423L, Volvo rear door)",
height=36, font=T.FONT_SEARCH,
fg_color=T.CARD_BG, border_color=T.BORDER_STRONG,
border_width=1, text_color=T.TEXT_PRIMARY,
placeholder_text_color=T.TEXT_SUBTLE,
corner_radius=6,
)
self._search_entry.grid(row=0, column=1, sticky="ew", padx=(0, 6), pady=10)
_bind_context_menu(self._search_entry)
ctk.CTkButton(
search_row, text="Clear", width=64, height=36,
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=6,
command=self._clear_search,
).grid(row=0, column=2, padx=(0, 14), pady=10)
# ── Row 1 LEFT: results list ───────────────────────────────────────
left = ctk.CTkFrame(self, fg_color=T.PANEL_BG, width=340, corner_radius=0)
left.grid(row=1, column=0, sticky="nsew")
left.columnconfigure(0, weight=1)
left.rowconfigure(1, weight=1)
left.grid_propagate(False)
# Status + Filters button bar at top of left panel
top_bar = ctk.CTkFrame(left, fg_color="transparent")
top_bar.grid(row=0, column=0, sticky="ew", padx=8, pady=(8, 4))
top_bar.columnconfigure(0, weight=1)
self._status_lbl = ctk.CTkLabel(
top_bar, text="Start typing to search...",
font=T.FONT_LABEL, text_color=T.TEXT_MUTED, anchor="w",
)
self._status_lbl.grid(row=0, column=0, sticky="w", padx=4)
self._filter_btn = ctk.CTkButton(
top_bar, text="Filters", width=80, height=26,
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,
command=self._toggle_filter_menu,
)
self._filter_btn._filter_menu_anchor = True
self._filter_btn.grid(row=0, column=1, sticky="e", padx=(4, 0))
# Results scrollable list
self._list_frame = ctk.CTkScrollableFrame(
left, fg_color=T.PANEL_BG, corner_radius=0,
scrollbar_button_color=T.BORDER,
scrollbar_button_hover_color=T.BORDER_STRONG,
)
self._list_frame.grid(row=1, column=0, sticky="nsew")
self._list_frame.columnconfigure(0, weight=1)
# Keyboard navigation
self._search_entry.bind("<Down>", lambda _: self._nav_result(+1))
self._search_entry.bind("<Up>", lambda _: self._nav_result(-1))
self._search_entry.bind("<Return>", lambda _: self._nav_result(+1))
self._search_entry.bind("<Escape>", lambda _: self._clear_search())
# Debug: pre-fill search from environment variable (for automated screenshots)
import os as _os
_qs = _os.environ.get("_CSG_DEBUG_SEARCH", "")
if _qs:
self.after(500, lambda: self._search_var.set(_qs))
# ── Row 1 DIVIDER ──────────────────────────────────────────────────
ctk.CTkFrame(self, fg_color=T.BORDER, width=1,
corner_radius=0).grid(row=1, column=1, sticky="ns")
# ── Row 1 RIGHT: detail panel ──────────────────────────────────────
self._detail = ctk.CTkScrollableFrame(
self, fg_color=T.WINDOW_BG, corner_radius=0,
scrollbar_button_color=T.BORDER,
scrollbar_button_hover_color=T.BORDER_STRONG,
)
self._detail.grid(row=1, column=2, sticky="nsew")
self._detail.columnconfigure(0, weight=1)
self._build_detail_panel()
self._show_empty_detail()
def _build_detail_panel(self) -> None:
d = self._detail
d.columnconfigure(0, weight=1)
# ── SKU header card ────────────────────────────────────────────────
sku_card = ctk.CTkFrame(d, fg_color=T.CARD_BG, corner_radius=8,
border_width=1, border_color=T.BORDER)
sku_card.grid(row=0, column=0, sticky="ew", padx=(12, 10), pady=(10, 4))
sku_card.columnconfigure(0, weight=1)
self._sku_var = tk.StringVar(value="—")
ctk.CTkLabel(
sku_card, textvariable=self._sku_var, anchor="w", justify="left",
font=T.FONT_SKU, text_color=T.TEXT_PRIMARY, wraplength=560,
).grid(row=0, column=0, sticky="ew", padx=(16, 8), pady=(10, 4))
self._sku_copy_btn = ctk.CTkButton(
sku_card, text="Copy", width=70, height=28,
font=T.FONT_BODY_BOLD,
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
text_color=T.ACCENT_TEXT, border_color=T.ACCENT_BORDER,
border_width=1, corner_radius=6,
command=self._copy_sku,
)
self._sku_copy_btn.grid(row=0, column=1, sticky="e", padx=(0, 6), pady=4)
self._copy_combo_btn = ctk.CTkButton(
sku_card, text="Copy SKU + Description", width=170, height=28,
font=T.FONT_BODY_BOLD,
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
text_color=T.ACCENT_TEXT, border_color=T.ACCENT_BORDER,
border_width=1, corner_radius=6,
command=self._copy_sku_and_desc,
)
self._copy_combo_btn.grid(row=0, column=2, sticky="e", padx=(0, 6), pady=4)
self._info_btn = ctk.CTkLabel(
sku_card, text="ⓘ", font=(T.FONT_FAMILY, 18, "bold"),
text_color=T.TEXT_MUTED, width=28, height=28, cursor="hand2",
)
self._info_btn.grid(row=0, column=3, sticky="e", padx=(0, 14), pady=4)
self._info_btn.bind("<Enter>", self._on_info_enter)
self._info_btn.bind("<Leave>", self._on_info_leave)
self._details_popup = _PartDetailsPopup(self.winfo_toplevel())
# ── Row 1: image (left) + price/description (right) ───────────────
mid = ctk.CTkFrame(d, fg_color="transparent")
mid.grid(row=1, column=0, sticky="ew", padx=(10, 10), pady=(4, 0))
mid.columnconfigure(1, weight=1)
self._image_label = ctk.CTkLabel(
mid, text="", width=IMAGE_W, height=IMAGE_H,
fg_color=T.CARD_BG, corner_radius=8,
text_color=T.TEXT_MUTED, font=T.FONT_BODY,
)
self._image_label.grid(row=0, column=0, sticky="n", padx=(0, 12))
info = ctk.CTkFrame(mid, fg_color="transparent")
info.grid(row=0, column=1, sticky="nsew")
info.columnconfigure(0, weight=1)
info.columnconfigure(1, weight=0)
net_box = ctk.CTkFrame(info, fg_color="transparent")
net_box.grid(row=0, column=0, sticky="w", pady=(0, 4))
ctk.CTkLabel(net_box, text="Customer Price", font=T.FONT_LABEL,
text_color=T.TEXT_MUTED, anchor="w").pack(anchor="w")
self._net_var = tk.StringVar(value="—")
ctk.CTkLabel(net_box, textvariable=self._net_var, font=T.FONT_PRICE_NET,
text_color=T.NET_COLOR, anchor="w").pack(anchor="w")
list_box = ctk.CTkFrame(info, fg_color="transparent")
list_box.grid(row=0, column=1, sticky="ne", padx=(16, 0))
ctk.CTkLabel(list_box, text="LIST", font=T.FONT_LABEL,
text_color=T.TEXT_MUTED, anchor="e").pack(anchor="e")
self._list_var = tk.StringVar(value="")
ctk.CTkLabel(list_box, textvariable=self._list_var, font=T.FONT_PRICE_LIST,
text_color=T.TEXT_MUTED, anchor="e").pack(anchor="e")
self._desc_row = _CopyRow(info, "Description", wraplength=340)
self._desc_row.grid(row=1, column=0, columnspan=2, sticky="ew",
pady=(8, 0))
# ── Action buttons ─────────────────────────────────────────────────
combo_row = ctk.CTkFrame(d, fg_color="transparent")
combo_row.grid(row=2, column=0, sticky="ew", padx=(0, 8), pady=(6, 8))
self._cut_file_btn = ctk.CTkButton(
combo_row, text="Open Cut File", width=120, height=30,
font=T.FONT_LABEL,
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
text_color=T.ACCENT_TEXT, border_color=T.ACCENT_BORDER,
border_width=1, corner_radius=6,
command=self._open_cut_file,
)
self._cut_file_btn.grid(row=0, column=0, sticky="w", padx=(12, 0))
self._cut_file_settings_btn = ctk.CTkButton(
combo_row, text="⚙", width=30, height=30,
font=T.FONT_BODY_BOLD,
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
text_color=T.TEXT_PRIMARY, border_color=T.NEUTRAL_BTN_BORDER,
border_width=1, corner_radius=6,
command=self._show_cut_file_menu,
)
self._cut_file_settings_btn.grid(row=0, column=1, sticky="w", padx=(6, 0))
self._print_label_btn = ctk.CTkButton(
combo_row, text="Print Label", width=100, height=30,
font=T.FONT_LABEL,
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
text_color=T.ACCENT_TEXT, border_color=T.ACCENT_BORDER,
border_width=1, corner_radius=6,
command=self._print_label,
)
self._print_label_btn.grid(row=0, column=2, sticky="w", padx=(12, 0))
self._print_label_settings_btn = ctk.CTkButton(
combo_row, text="⚙", width=30, height=30,
font=T.FONT_BODY_BOLD,
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
text_color=T.TEXT_PRIMARY, border_color=T.NEUTRAL_BTN_BORDER,
border_width=1, corner_radius=6,
command=self._show_label_settings,
)
self._print_label_settings_btn.grid(row=0, column=3, sticky="w", padx=(6, 0))
self._print_address_btn = ctk.CTkButton(
combo_row, text="Print Address", width=110, height=30,
font=T.FONT_LABEL,
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
text_color=T.ACCENT_TEXT, border_color=T.ACCENT_BORDER,
border_width=1, corner_radius=6,
command=self._print_address,
)
self._print_address_btn.grid(row=0, column=4, sticky="w", padx=(12, 0))
self._lb_file_btn = ctk.CTkButton(
combo_row, text="Open LightBurn File", width=150, height=30,
font=T.FONT_LABEL,
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
text_color=T.ACCENT_TEXT, border_color=T.ACCENT_BORDER,
border_width=1, corner_radius=6,
command=self._open_lb_file,
)
self._lb_file_btn.grid(row=1, column=0, sticky="w", padx=(12, 0), pady=(6, 0))
self._lb_file_settings_btn = ctk.CTkButton(
combo_row, text="⚙", width=30, height=30,
font=T.FONT_BODY_BOLD,
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
text_color=T.TEXT_PRIMARY, border_color=T.NEUTRAL_BTN_BORDER,
border_width=1, corner_radius=6,
command=self._show_lb_file_menu,
)
self._lb_file_settings_btn.grid(row=1, column=1, sticky="w",
padx=(6, 0), pady=(6, 0))
# ── Part display ───────────────────────────────────────────────────────────
def _get_placeholder_ctk(self) -> ctk.CTkImage | None:
if self._placeholder_ctk is not None:
return self._placeholder_ctk
if self._image_cache is None:
return None
try:
pil = self._image_cache.placeholder().convert("RGBA")
pil = pil.resize((IMAGE_W, IMAGE_H), Image.LANCZOS)
self._placeholder_ctk = ctk.CTkImage(
light_image=pil, dark_image=pil, size=(IMAGE_W, IMAGE_H))
except Exception:
pass
return self._placeholder_ctk
def _show_empty_detail(self) -> None:
self._sel_part = None
self._sku_var.set("—")
self._net_var.set("—")
self._list_var.set("")
self._desc_row.set_value("")
ph = self._get_placeholder_ctk()
if ph:
self._apply_image_raw(ph)
else:
self._image_label.configure(image=None, text="")
def _show_part(self, part: dict) -> None:
self._sel_part = part
sku = part.get("stock_number", "") or ""
desc = part.get("description", "") or ""
net = part.get("net", "") or part.get("customer_price", "") or ""
lst = part.get("list_price", "") or ""
url = part.get("image_url", "") or ""
self._sku_var.set(sku or "—")
self._net_var.set(_format_price(net))
self._list_var.set(_format_price(lst) if lst else "")
self._desc_row.set_value(desc)
# Image — show placeholder then replace with real when loaded
ph = self._get_placeholder_ctk()
if ph:
self._apply_image_raw(ph)
else:
self._image_label.configure(image=None, text="")
if url and self._image_cache:
cur_sku = sku
def _on_image_ready(received_sku: str, img: Image.Image | None) -> None:
if received_sku == cur_sku:
self.after(0, lambda: self._apply_image(received_sku, img))
self._image_cache.load_async(sku, url, _on_image_ready)
# Update the details popup with warehouse info
self._details_popup.set_values(part)
def _apply_image_raw(self, pil_img) -> None:
"""Render an image into the image label without staleness checks.
Used by the empty-state path which has no SKU/token to validate against.
"""
if not pil_img:
self._image_label.configure(text="", image=None)
return
try:
if hasattr(pil_img, "size") and not hasattr(pil_img, "_light_image"):
pil_img = pil_img.resize((IMAGE_W, IMAGE_H), Image.LANCZOS)
ctk_img = ctk.CTkImage(light_image=pil_img,
dark_image=pil_img,
size=(IMAGE_W, IMAGE_H))
else:
ctk_img = pil_img
self._image_label.configure(image=ctk_img, text="")
self._image_label._ctk_image = ctk_img
except Exception as exc:
log.debug("Image apply failed: %s", exc)
self._image_label.configure(text="", image=None)
def _apply_image(self, sku: str, img: Image.Image | None) -> None:
if self._sel_part and self._sel_part.get("stock_number", "") == sku:
try:
self._apply_image_raw(img)
except Exception as exc:
log.debug("Image apply error: %s", exc)
# ── Copy helpers ───────────────────────────────────────────────────────────
def _on_info_enter(self, _event=None) -> None:
if self._sel_part:
self._details_popup.show_near(self._info_btn, self._sel_part)
def _on_info_leave(self, _event=None) -> None:
self._details_popup.hide_soon()
def _copy_sku(self) -> None:
sku = self._current_sku()
if not sku:
return
if _copy_to_clipboard(sku):
_flash_button(self._sku_copy_btn, T.COPY_OK,
restore_text="Copy", flash_text="Copied!")
def _copy_sku_and_desc(self) -> None:
if not self._sel_part:
return
sku = self._current_sku()
desc = self._sel_part.get("description", "") or ""
text = f"{desc} - {sku}".strip(" -") if desc else sku
if _copy_to_clipboard(text):
_flash_button(self._copy_combo_btn, T.COPY_OK,
restore_text="Copy SKU + Description",
flash_text="Copied!")
def _current_sku(self) -> str:
if self._sel_part is None:
return ""
return (self._sel_part.get("stock_number", "")
or self._sel_part.get("STOCK NUMBER", ""))
# ── Search ─────────────────────────────────────────────────────────────────
def _on_search_change(self) -> None:
if hasattr(self, "_debounce_id"):
try:
self.after_cancel(self._debounce_id)
except Exception:
pass
self._debounce_id = self.after(SEARCH_DEBOUNCE_MS, self._run_search)
def _run_search(self) -> None:
query = self._search_var.get().strip()
if not query:
self._render_results([])
self._set_status("Start typing to search...")
return
if self._repo is None:
self._set_status("Loading…")
return
self._set_status("Searching...")
makes = self._filter_menu.selected_makes()
models = self._filter_menu.selected_models()
sides = self._filter_menu.selected_sides()
limit = self._max_results
repo = self._repo
def _worker() -> None:
try:
results = repo.search(
query, makes=makes, models=models, sides=sides, limit=limit)
self.after(0, lambda: self._on_search_results(results, query))
except Exception:
log.exception("Search error")
self.after(0, lambda: self._set_status("Search error"))
import threading
threading.Thread(target=_worker, daemon=True).start()
def _toggle_filter_menu(self) -> None:
self._filter_menu.toggle()
n = self._filter_menu.total_active()
label = f"Filters ({n})" if n else "Filters"
self._filter_btn.configure(
text=label,
fg_color=T.ACCENT if n else T.NEUTRAL_BTN_BG,
text_color=T.ACCENT_TEXT if n else T.TEXT_PRIMARY,
)
def _on_filter_change(self) -> None:
n = self._filter_menu.total_active()
label = f"Filters ({n})" if n else "Filters"
self._filter_btn.configure(
text=label,
fg_color=T.ACCENT if n else T.NEUTRAL_BTN_BG,
text_color=T.ACCENT_TEXT if n else T.TEXT_PRIMARY,
)
self._run_search()
def _clear_search(self) -> None:
self._search_var.set("")
self._render_results([])
self._show_empty_detail()
self._set_status("Start typing to search...")
def _on_search_results(self, results: list[dict], query: str) -> None:
n = len(results)
plural = "s" if n != 1 else ""
if n == 0:
self._set_status(f'No results for "{query}"')
else:
self._set_status(f'{n} match{plural}')
self._results = results
self._render_results(results)
if results:
self._on_result_click(results[0])
# ── Results rendering ──────────────────────────────────────────────────────
def _render_results(self, results: list[dict]) -> None:
# Reuse or extend the row pool
while len(self._row_pool) < len(results):
row = _ResultRow(
self._list_frame,
on_click=self._on_result_click,
)
self._row_pool.append(row)
# Show needed rows, hide extras, add dividers
for child in self._list_frame.winfo_children():
child.grid_remove()
for i, part in enumerate(results):
row = self._row_pool[i]
row.set_part(part)
row.set_selected(False)
row.grid(row=i, column=0, sticky="ew", padx=8, pady=(0, 4))
self._sel_idx: int | None = None
def _on_result_click(self, part: dict) -> None:
idx = next((i for i, p in enumerate(self._results)
if p is part), None)
if idx is not None:
self._highlight_row(idx)
self._show_part(part)
def _highlight_row(self, index: int) -> None:
for i in range(len(self._results)):
if i < len(self._row_pool):
self._row_pool[i].set_selected(i == index)
self._sel_idx = index
def _nav_result(self, delta: int) -> None:
if not self._results:
return
cur = getattr(self, "_sel_idx", None)
new = max(0, min(len(self._results) - 1,
(cur if cur is not None else -1) + delta))
self._highlight_row(new)
self._show_part(self._results[new])
def _set_status(self, text: str) -> None:
self._status_lbl.configure(text=text)
# ── External repo injection ────────────────────────────────────────────────
def set_repo(self, repo: PartsRepo,
image_cache: ImageCache | None = None) -> None:
"""Called by App after async CSV load completes."""
self._repo = repo
self._image_cache = image_cache
self._placeholder_ctk = None # re-load with new cache
n = repo.count()
self._set_status(f"{n:,} parts loaded")
try:
makes = repo.available_makes()
models = repo.available_models()
self._filter_menu.update_makes(makes)
self._filter_menu.update_models(models)
except Exception:
pass
# Refresh detail panel so the placeholder image appears immediately
if self._sel_part is None:
self._show_empty_detail()
else:
self._show_part(self._sel_part)
# Re-run search in case text was typed before the repo finished loading
if self._search_var.get():
self.after(0, self._run_search)
# ── Cut file methods ───────────────────────────────────────────────────────
def _open_cut_file(self) -> None:
sku = self._current_sku()
if not sku:
messagebox.showinfo(
"Open Cut File",
"Pick a part first, then click Open Cut File.",
parent=self.winfo_toplevel(),
)
return
self._cut_file_btn.configure(state="disabled")
self._cut_manager.search_async(sku, self._on_cut_results)
def _on_cut_results(self, results: list[str]) -> None:
self.after(0, lambda: self._handle_cut_file_results(results))
def _handle_cut_file_results(self, results: list[str]) -> None:
self._cut_file_btn.configure(state="normal")
sku = self._current_sku()
if not results:
messagebox.showinfo(
"Open Cut File",
f"No cut file found for {sku}",
parent=self.winfo_toplevel(),
)
return
if len(results) == 1:
open_file(results[0])
else:
self._pick_cut_file(sku, results)
def _pick_cut_file(self, sku: str, candidates: list[str]) -> None:
"""Small modal picker when multiple files match a single SKU."""
win = ctk.CTkToplevel(self.winfo_toplevel())
win.title("Select Cut File")
win.transient(self.winfo_toplevel())
win.grab_set()
win.resizable(False, False)
win.configure(fg_color=T.WINDOW_BG)
ctk.CTkLabel(
win, text=f"Multiple files match {sku}:",
font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY,
).pack(padx=16, pady=(14, 6))
scroll = ctk.CTkScrollableFrame(win, fg_color=T.PANEL_BG,
height=min(220, len(candidates) * 36 + 20))
scroll.pack(fill="both", padx=12, pady=(0, 6))
scroll.columnconfigure(0, weight=1)
def _pick(path: str, _win=win) -> None:
open_file(path)
_win.destroy()
for i, path in enumerate(candidates):
import os
lbl = ctk.CTkButton(
scroll, text=os.path.basename(path),
font=T.FONT_BODY, text_color=T.TEXT_PRIMARY,
fg_color="transparent", hover_color=T.CARD_BG_HOVER,
anchor="w", command=lambda p=path: _pick(p),
)
lbl.grid(row=i, column=0, sticky="ew", padx=4, pady=2)
ctk.CTkButton(
win, text="Cancel", width=80, height=28,
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,
command=win.destroy,
).pack(pady=(4, 14))
def _show_cut_file_menu(self) -> None:
"""Popup menu next to the gear button for path management."""
menu = tk.Menu(self, tearoff=0)
menu.configure(bg="#ffffff", activebackground=T.ACCENT[0],
activeforeground="#ffffff", bd=1, relief="solid")
active_name = self._cut_manager.get_active_name()
active_path = self._cut_manager.get_active_path()
paths = self._cut_manager.get_paths()
if active_name:
menu.add_command(
label=f"-- Active path --",
state="disabled",
)
menu.add_command(
label=active_path or "(none)",
state="disabled",
)
menu.add_separator()
menu.add_command(label="Add folder…",
command=self._add_cut_path)
if active_name:
menu.add_command(label="Edit active folder…",
command=self._edit_active_cut_path)
menu.add_command(label="Remove active folder",
command=self._remove_active_cut_path)
if paths:
menu.add_separator()
for entry in paths:
n, p = entry["name"], entry["path"]
label = f"✓ {n}" if n == active_name else f" {n}"
menu.add_command(
label=label,
command=lambda _n=n: self._set_active_cut_path(_n),
)
menu.add_separator()
menu.add_command(label="Refresh index",
command=self._refresh_cut_file_index)
try:
btn = self._cut_file_settings_btn
menu.tk_popup(btn.winfo_rootx(),
btn.winfo_rooty() + btn.winfo_height())
finally:
menu.grab_release()
def _index_status_label(self) -> str:
if not self._cut_manager.get_active_path():
return "No folder configured · use ⚙ to add one"
if not self._cut_manager.index_is_fresh_for_active():
return "Index not built · will build on first search"
ct, total, dirs = self._cut_manager.index_stats()
built = self._cut_manager.index_built_at()
if built:
import time as _t
when = _t.strftime("%H:%M", _t.localtime(built)) if built > 1e9 else ""
return f"{ct:,} cut files indexed ({when})"
return f"{ct:,} cut files indexed"
def _refresh_cut_file_index(self) -> None:
self._cut_status_lbl.configure(text="Indexing…")
self._cut_file_settings_btn.configure(state="disabled")
def _on_done(_count: int = 0) -> None:
def restore() -> None:
self._cut_file_settings_btn.configure(state="normal")
self._cut_status_lbl.configure(
text=self._index_status_label())
self.after(0, restore)
self._cut_manager.refresh_index_async(_on_done)
def _set_active_cut_path(self, name: str) -> None:
self._cut_manager.set_active(name)
self._cut_status_lbl.configure(text=self._index_status_label())
def _add_cut_path(self) -> None:
folder = filedialog.askdirectory(
title="Select Cut File Folder",
parent=self.winfo_toplevel(),
)
if not folder:
return
import os
name = simpledialog.askstring(
"Add Folder",
"Name this folder (shown in the path list):",
initialvalue=os.path.basename(folder),
parent=self.winfo_toplevel(),
)
if not name:
return
self._cut_manager.add_or_update(name.strip(), folder)
self._cut_manager.set_active(name.strip())
self._cut_status_lbl.configure(text=self._index_status_label())
def _edit_active_cut_path(self) -> None:
name = self._cut_manager.get_active_name()
old_path = self._cut_manager.get_active_path()
folder = filedialog.askdirectory(
title="Select Cut File Folder",
initialdir=old_path or "/",
parent=self.winfo_toplevel(),
)
if not folder:
return
self._cut_manager.add_or_update(name, folder)
self._cut_status_lbl.configure(text=self._index_status_label())
def _remove_active_cut_path(self) -> None:
name = self._cut_manager.get_active_name()
if not name:
return
yes = messagebox.askyesno(
"Remove Folder",
f"Remove '{name}' from the cut file folder list?",
parent=self.winfo_toplevel(),
)
if yes:
self._cut_manager.remove(name)
self._cut_status_lbl.configure(text=self._index_status_label())
# ── Print Label ────────────────────────────────────────────────────────────
def _print_label(self) -> None:
sku = self._current_sku()
if not sku:
messagebox.showinfo("Print Label", "Select a part first.",
parent=self.winfo_toplevel())
return
if not self._labels:
messagebox.showwarning("Print Label",
"Label printer not configured.",
parent=self.winfo_toplevel())
return
desc = ""
if self._sel_part:
desc = (self._sel_part.get("description", "")
or self._sel_part.get("DESCRIPTION", ""))
try:
self._labels.print_label(sku, desc)
_flash_button(self._print_label_btn, T.COPY_OK,
restore_text="Print Label", flash_text="Sent!")
except Exception as exc:
messagebox.showerror("Print Label", str(exc),
parent=self.winfo_toplevel())
def _show_label_settings(self) -> None:
if not self._labels:
messagebox.showwarning("Label Settings",
"Label printer not configured.",
parent=self.winfo_toplevel())
return
win = ctk.CTkToplevel(self.winfo_toplevel())
win.title("Label Printer Settings")
win.transient(self.winfo_toplevel())
win.grab_set()
win.resizable(False, False)
win.configure(fg_color=T.WINDOW_BG)
ctk.CTkLabel(win, text="Printer", font=T.FONT_LABEL,
text_color=T.TEXT_MUTED).pack(
padx=20, pady=(16, 4), anchor="w")
printers = self._labels.available_printers()
current = self._labels.get_printer() or ""
var = ctk.StringVar(value=current)
ctk.CTkComboBox(win, values=printers, variable=var,
width=300, font=T.FONT_BODY).pack(padx=20, pady=(0, 12))
def _save() -> None:
self._labels.set_printer(var.get())
win.destroy()
btns = ctk.CTkFrame(win, fg_color="transparent")
btns.pack(padx=20, pady=(0, 16))
ctk.CTkButton(btns, text="Save", width=90, height=30,
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
text_color=T.ACCENT_TEXT, corner_radius=6,
command=_save).pack(side="left")
ctk.CTkButton(btns, text="Printer Preferences…", height=30,
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=6,
command=self._labels.open_preferences).pack(
side="left", padx=(8, 0))
# ── Print Address ──────────────────────────────────────────────────────────
def _print_address(self) -> None:
if not self._labels:
messagebox.showwarning("Print Address",
"Label printer not configured.",
parent=self.winfo_toplevel())
return
win = ctk.CTkToplevel(self.winfo_toplevel())
win.title("Print Address Label")
win.transient(self.winfo_toplevel())
win.grab_set()
win.resizable(False, False)
win.configure(fg_color=T.WINDOW_BG)
ctk.CTkLabel(win, text="Address", font=T.FONT_LABEL,
text_color=T.TEXT_MUTED).pack(
padx=20, pady=(16, 4), anchor="w")
addr_box = ctk.CTkTextbox(win, width=340, height=100,
font=T.FONT_BODY, fg_color=T.CARD_BG,
border_color=T.BORDER_STRONG, border_width=1,
corner_radius=6)
addr_box.pack(padx=20, pady=(0, 12))
def _do_print() -> None:
address = addr_box.get("1.0", "end").strip()
if not address:
messagebox.showwarning("Print Address",
"Please enter an address.", parent=win)
return
try:
self._labels.print_address_label(address)
win.destroy()
except Exception as exc:
messagebox.showerror("Print Address", str(exc), parent=win)
btns = ctk.CTkFrame(win, fg_color="transparent")
btns.pack(padx=20, pady=(0, 16))
ctk.CTkButton(btns, text="Print", width=80, height=30,
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
text_color=T.ACCENT_TEXT, corner_radius=6,
command=_do_print).pack(side="left")
ctk.CTkButton(btns, text="Cancel", width=80, height=30,
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,
command=win.destroy).pack(side="left", padx=(8, 0))
# ── LightBurn file methods ─────────────────────────────────────────────────
def _open_lb_file(self) -> None:
sku = self._current_sku()
if not sku:
messagebox.showinfo(
"Open LightBurn File",
"Pick a part first, then click Open LightBurn File.",
parent=self.winfo_toplevel(),
)
return
if not self._lb_manager:
messagebox.showwarning(
"Open LightBurn File",
"LightBurn file folder not configured.",
parent=self.winfo_toplevel(),
)
return
self._lb_file_btn.configure(state="disabled")
self._lb_manager.search_async(sku, self._on_lb_results)
def _on_lb_results(self, results: list[str]) -> None:
self.after(0, lambda: self._handle_lb_file_results(results))
def _handle_lb_file_results(self, results: list[str]) -> None:
self._lb_file_btn.configure(state="normal")
sku = self._current_sku()
if not results:
messagebox.showinfo("Open LightBurn File",
f"No LightBurn file found for {sku}",
parent=self.winfo_toplevel())
return
if len(results) == 1:
open_file(results[0])
else:
self._pick_lb_file(sku, results)
def _pick_lb_file(self, sku: str, candidates: list[str]) -> None:
win = ctk.CTkToplevel(self.winfo_toplevel())
win.title("Select LightBurn File")
win.transient(self.winfo_toplevel())
win.grab_set()
win.resizable(False, False)
win.configure(fg_color=T.WINDOW_BG)
ctk.CTkLabel(win, text=f"Multiple files match {sku}:",
font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).pack(
padx=16, pady=(14, 6))
scroll = ctk.CTkScrollableFrame(
win, fg_color=T.PANEL_BG,
height=min(220, len(candidates) * 36 + 20))
scroll.pack(fill="both", padx=12, pady=(0, 6))
scroll.columnconfigure(0, weight=1)
import os
def _pick(path: str, _win=win) -> None:
open_file(path)
_win.destroy()
for i, path in enumerate(candidates):
ctk.CTkButton(
scroll, text=os.path.basename(path),
font=T.FONT_BODY, text_color=T.TEXT_PRIMARY,
fg_color="transparent", hover_color=T.CARD_BG_HOVER,
anchor="w", command=lambda p=path: _pick(p),
).grid(row=i, column=0, sticky="ew", padx=4, pady=2)
ctk.CTkButton(win, text="Cancel", width=80, height=28,
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,
command=win.destroy).pack(pady=(4, 14))
def _show_lb_file_menu(self) -> None:
if not self._lb_manager:
return
menu = tk.Menu(self, tearoff=0)
menu.configure(bg="#ffffff", activebackground=T.ACCENT[0],
activeforeground="#ffffff", bd=1, relief="solid")
active_name = self._lb_manager.get_active_name()
active_path = self._lb_manager.get_active_path()
paths = self._lb_manager.get_paths()
if active_name:
menu.add_command(label="-- Active path --", state="disabled")
menu.add_command(label=active_path or "(none)", state="disabled")
menu.add_separator()
menu.add_command(label="Add folder…", command=self._add_lb_path)
if active_name:
menu.add_command(label="Edit active folder…",
command=self._edit_active_lb_path)
menu.add_command(label="Remove active folder",
command=self._remove_active_lb_path)
if paths:
menu.add_separator()
for entry in paths:
n = entry["name"]
label = f"✓ {n}" if n == active_name else f" {n}"
menu.add_command(label=label,
command=lambda _n=n: self._set_active_lb_path(_n))
menu.add_separator()
menu.add_command(label="Refresh index", command=self._refresh_lb_file_index)
try:
btn = self._lb_file_settings_btn
menu.tk_popup(btn.winfo_rootx(),
btn.winfo_rooty() + btn.winfo_height())
finally:
menu.grab_release()
def _set_active_lb_path(self, name: str) -> None:
self._lb_manager.set_active(name)
self._cut_status_lbl.configure(text=self._index_status_label())
def _add_lb_path(self) -> None:
folder = filedialog.askdirectory(
title="Select LightBurn File Folder",
parent=self.winfo_toplevel(),
)
if not folder:
return
import os
name = simpledialog.askstring(
"Add Folder",
"Name this folder (shown in the path list):",
initialvalue=os.path.basename(folder),
parent=self.winfo_toplevel(),
)
if not name:
return
self._lb_manager.add_or_update(name.strip(), folder)
self._lb_manager.set_active(name.strip())
self._cut_status_lbl.configure(text=self._index_status_label())
def _edit_active_lb_path(self) -> None:
name = self._lb_manager.get_active_name()
old_path = self._lb_manager.get_active_path()
folder = filedialog.askdirectory(
title="Select LightBurn File Folder",
initialdir=old_path or "/",
parent=self.winfo_toplevel(),
)
if not folder:
return
self._lb_manager.add_or_update(name, folder)
self._cut_status_lbl.configure(text=self._index_status_label())
def _remove_active_lb_path(self) -> None:
name = self._lb_manager.get_active_name()
if not name:
return
yes = messagebox.askyesno(
"Remove Folder",
f"Remove '{name}' from the LightBurn folder list?",
parent=self.winfo_toplevel(),
)
if yes:
self._lb_manager.remove(name)
self._cut_status_lbl.configure(text=self._index_status_label())
def _refresh_lb_file_index(self) -> None:
if not self._lb_manager:
return
self._cut_status_lbl.configure(text="Indexing LightBurn…")
self._lb_file_settings_btn.configure(state="disabled")
def _on_done(_count: int = 0) -> None:
def restore() -> None:
self._lb_file_settings_btn.configure(state="normal")
self._cut_status_lbl.configure(text=self._index_status_label())
self.after(0, restore)
self._lb_manager.refresh_index_async(_on_done)