1850 lines
75 KiB
Python
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)
|