"""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("<>")) menu.add_command(label="Copy", command=lambda: widget.event_generate("<>")) menu.add_command(label="Paste", command=lambda: widget.event_generate("<>")) menu.add_separator() menu.add_command(label="Select All", command=lambda: widget.event_generate("<>")) def _show(event): try: menu.tk_popup(event.x_root, event.y_root) finally: menu.grab_release() widget.bind("", _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("", self.toggle) self._arrow.bind("", 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("", self._on_enter_popup, add="+") w.bind("", 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("", self._handle_click) w.bind("", self._on_enter) w.bind("", 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("", 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("", lambda _: self._nav_result(+1)) self._search_entry.bind("", lambda _: self._nav_result(-1)) self._search_entry.bind("", lambda _: self._nav_result(+1)) self._search_entry.bind("", 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("", self._on_info_enter) self._info_btn.bind("", 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)