416 lines
16 KiB
Python
416 lines
16 KiB
Python
"""Main application window: top toolbar, tabs, status bar."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
from tkinter import filedialog
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import tkinter as tk
|
|
import customtkinter as ctk
|
|
|
|
from .config import Config
|
|
from .cut_files import CutFileManager, CUT_FILE_EXTENSIONS, LIGHTBURN_EXTENSIONS
|
|
from .data import PartsRepo
|
|
from .images import ImageCache, _bundle_path
|
|
from .labels import LabelPrinter
|
|
from . import paths as app_paths
|
|
from . import theme as T
|
|
from .ui_parts_tab import PartsTab
|
|
from .ui_shipping_tab import ShippingTab
|
|
from .ui_compact_mode import CompactModeWindow
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_TAB_PARTS = "Parts"
|
|
_TAB_SHIPPING = "Shipping"
|
|
_STATE_FILE = "ui_state.json"
|
|
|
|
|
|
def _state_path() -> Path:
|
|
return Path(app_paths.appdata_dir()) / _STATE_FILE
|
|
|
|
|
|
def _load_state() -> dict:
|
|
try:
|
|
with open(_state_path(), "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _save_state(data: dict) -> None:
|
|
p = _state_path()
|
|
try:
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(p, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2)
|
|
log.debug("Saved ui_state.json to %s", p)
|
|
except Exception as exc:
|
|
log.warning("Failed to save ui_state.json to %s: %s", p, exc)
|
|
|
|
|
|
def _apply_segmented_text_colors(widget) -> None:
|
|
"""Color the selected segment's text white, others dark."""
|
|
try:
|
|
seg = getattr(widget, "_segmented_button", widget)
|
|
for name, btn in seg._buttons_dict.items():
|
|
if name == seg.get():
|
|
btn.configure(text_color=T.ACCENT_TEXT)
|
|
else:
|
|
btn.configure(text_color=T.TEXT_PRIMARY)
|
|
except Exception as exc:
|
|
log.debug("Could not apply segmented text colors: %s", exc)
|
|
|
|
|
|
class App(ctk.CTk):
|
|
def __init__(self, cfg: Config) -> None:
|
|
ctk.set_appearance_mode(cfg.appearance_mode or "light")
|
|
ctk.set_widget_scaling(cfg.ui_scale or 1.2)
|
|
|
|
super().__init__()
|
|
self._cfg = cfg
|
|
|
|
self.title("Parts & Shipping Lookup - CollisionStoneGuard")
|
|
self.geometry("1280x820")
|
|
self.minsize(900, 560)
|
|
|
|
# Load persisted state
|
|
self._state = _load_state()
|
|
if self._state.get("parts_folder"):
|
|
cfg.parts_folder_override = self._state["parts_folder"]
|
|
cfg.resolve_csv()
|
|
|
|
# Build cut file managers from state (parameterized: Roland and LightBurn)
|
|
self.cut_files = CutFileManager(
|
|
state=self._state,
|
|
save_fn=_save_state,
|
|
extensions=CUT_FILE_EXTENSIONS,
|
|
state_key_paths="cut_file_paths",
|
|
state_key_active="active_cut_file_path",
|
|
)
|
|
self.lightburn_files = CutFileManager(
|
|
state=self._state,
|
|
save_fn=_save_state,
|
|
extensions=LIGHTBURN_EXTENSIONS,
|
|
state_key_paths="lb_file_paths",
|
|
state_key_active="active_lb_file_path",
|
|
)
|
|
self.labels = LabelPrinter(state=self._state, save_state=_save_state)
|
|
self.repo: PartsRepo | None = None
|
|
self.images: ImageCache | None = None
|
|
|
|
self._load_icon()
|
|
self._build_layout()
|
|
|
|
# Kick off async CSV load after event loop starts
|
|
self.after(100, self._initial_load)
|
|
|
|
# ── Icon ──────────────────────────────────────────────────────────────────
|
|
|
|
def _load_icon(self) -> None:
|
|
for relpath in ("assets/icon.ico", "assets/icon_small.png"):
|
|
p = _bundle_path(relpath)
|
|
if not p.exists():
|
|
p = Path(__file__).resolve().parents[1] / relpath
|
|
if p.exists():
|
|
try:
|
|
if relpath.endswith(".ico"):
|
|
self.iconbitmap(str(p))
|
|
else:
|
|
from PIL import Image, ImageTk
|
|
img = ImageTk.PhotoImage(Image.open(str(p)))
|
|
self.iconphoto(True, img)
|
|
self._icon_ref = img
|
|
return
|
|
except Exception as exc:
|
|
log.debug("Could not load window icon: %s", exc)
|
|
|
|
# ── Layout ────────────────────────────────────────────────────────────────
|
|
|
|
def _build_layout(self) -> None:
|
|
cfg = self._cfg
|
|
|
|
# ── Toolbar (pack top, height=64 fixed) ────────────────────────────
|
|
toolbar = ctk.CTkFrame(self, fg_color=T.TOOLBAR_BG, height=64,
|
|
corner_radius=0, border_width=0)
|
|
toolbar.pack(side="top", fill="x")
|
|
toolbar.pack_propagate(False)
|
|
tk.Frame.configure(toolbar, height=64) # CTkFrame doesn't set tk.Frame reqheight
|
|
toolbar.columnconfigure(1, weight=1)
|
|
|
|
ctk.CTkLabel(
|
|
toolbar, text="Parts & Shipping Lookup",
|
|
font=T.FONT_HEADER, text_color=T.TEXT_PRIMARY, anchor="w",
|
|
).grid(row=0, column=0, sticky="w", padx=(20, 0), pady=14)
|
|
|
|
right = ctk.CTkFrame(toolbar, fg_color="transparent")
|
|
right.grid(row=0, column=2, sticky="e", padx=(0, 16), pady=14)
|
|
|
|
self._mode_toggle = ctk.CTkSegmentedButton(
|
|
right, values=["Light", "Dark"], command=self._on_mode_change,
|
|
selected_color=T.ACCENT, selected_hover_color=T.ACCENT_HOVER,
|
|
unselected_color=T.NEUTRAL_BTN_BG,
|
|
unselected_hover_color=T.NEUTRAL_BTN_HOVER,
|
|
text_color=T.TEXT_PRIMARY, font=T.FONT_BODY_BOLD,
|
|
height=36, width=170, corner_radius=8,
|
|
)
|
|
self._mode_toggle.set(
|
|
"Dark" if ctk.get_appearance_mode().lower() == "dark" else "Light"
|
|
)
|
|
self._mode_toggle.pack(side="right")
|
|
_apply_segmented_text_colors(self._mode_toggle)
|
|
|
|
ctk.CTkButton(
|
|
right, text="Compact Mode", width=140, height=36,
|
|
font=T.FONT_BODY_BOLD,
|
|
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=8,
|
|
command=self._open_compact_mode,
|
|
).pack(side="right", padx=(0, 12))
|
|
|
|
# ── Status bar (pack bottom first so tabs expand into remaining space) ──
|
|
_mode = ctk.get_appearance_mode().lower()
|
|
_sb_bg = T.TOOLBAR_BG[1] if _mode == "dark" else T.TOOLBAR_BG[0]
|
|
self._status_frame = tk.Frame(self, height=34, bg=_sb_bg)
|
|
self._status_frame.pack(side="bottom", fill="x")
|
|
self._status_frame.pack_propagate(False)
|
|
status = self._status_frame
|
|
status.columnconfigure(0, weight=1)
|
|
|
|
# ── Separators (after toolbar/status, before tabs) ────────────────────
|
|
ctk.CTkFrame(self, fg_color=T.BORDER, height=1,
|
|
corner_radius=0).pack(side="bottom", fill="x")
|
|
ctk.CTkFrame(self, fg_color=T.BORDER, height=1,
|
|
corner_radius=0).pack(side="top", fill="x")
|
|
|
|
# ── Tab view (fills remaining space between toolbar and status bar) ───
|
|
self._tabs = ctk.CTkTabview(
|
|
self, anchor="nw",
|
|
segmented_button_selected_color=T.ACCENT,
|
|
segmented_button_selected_hover_color=T.ACCENT_HOVER,
|
|
segmented_button_unselected_color=T.NEUTRAL_BTN_BG,
|
|
segmented_button_unselected_hover_color=T.NEUTRAL_BTN_HOVER,
|
|
text_color=T.TEXT_PRIMARY,
|
|
fg_color=T.WINDOW_BG, border_width=0, corner_radius=10,
|
|
)
|
|
self._tabs.pack(side="top", fill="both", expand=True, padx=10, pady=(8, 6))
|
|
self._tabs.add(_TAB_PARTS)
|
|
self._tabs.add(_TAB_SHIPPING)
|
|
|
|
self._parts_tab = PartsTab(
|
|
self._tabs.tab(_TAB_PARTS),
|
|
repo=self.repo, images=self.images,
|
|
max_results=getattr(cfg, "max_results", 15),
|
|
cut_files=self.cut_files, lightburn_files=self.lightburn_files,
|
|
labels=self.labels,
|
|
)
|
|
self._parts_tab.pack(fill="both", expand=True)
|
|
|
|
self._shipping_tab = ShippingTab(self._tabs.tab(_TAB_SHIPPING), cfg=cfg)
|
|
self._shipping_tab.pack(fill="both", expand=True)
|
|
|
|
self._tabs.set(
|
|
_TAB_SHIPPING if getattr(cfg, "default_tab", "parts") == "shipping"
|
|
else _TAB_PARTS
|
|
)
|
|
|
|
try:
|
|
tab_seg = self._tabs._segmented_button
|
|
orig_cmd = tab_seg.cget("command")
|
|
|
|
def _on_tab_change(value, *, _orig=orig_cmd, _seg=tab_seg):
|
|
_apply_segmented_text_colors(_seg)
|
|
if callable(_orig):
|
|
_orig(value)
|
|
|
|
tab_seg.configure(command=_on_tab_change)
|
|
_apply_segmented_text_colors(tab_seg)
|
|
except Exception as exc:
|
|
log.warning("Could not wire tab text color refresh: %s", exc)
|
|
|
|
self._status_var = tk.StringVar(value="Starting up...")
|
|
ctk.CTkLabel(
|
|
status, textvariable=self._status_var,
|
|
anchor="w", font=T.FONT_STATUS, text_color=T.TEXT_MUTED,
|
|
).grid(row=0, column=0, sticky="ew", padx=14, pady=6)
|
|
|
|
_btn_kw = dict(
|
|
height=24, font=T.FONT_STATUS, border_width=1, corner_radius=6,
|
|
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
|
|
text_color=T.TEXT_PRIMARY, border_color=T.NEUTRAL_BTN_BORDER,
|
|
)
|
|
|
|
self._open_csv_btn = ctk.CTkButton(
|
|
status, text="Open CSV", width=90,
|
|
command=self._open_csv, **_btn_kw)
|
|
self._open_csv_btn.grid(row=0, column=1, sticky="e", padx=(10, 4), pady=4)
|
|
|
|
self._change_folder_btn = ctk.CTkButton(
|
|
status, text="Change folder", width=120,
|
|
command=self._change_parts_folder, **_btn_kw)
|
|
self._change_folder_btn.grid(row=0, column=2, sticky="e", padx=(0, 4), pady=4)
|
|
|
|
self._open_appdata_btn = ctk.CTkButton(
|
|
status, text="App data folder", width=120,
|
|
command=self._open_appdata_folder, **_btn_kw)
|
|
self._open_appdata_btn.grid(row=0, column=3, sticky="e", padx=(0, 4), pady=4)
|
|
|
|
self._reload_btn = ctk.CTkButton(
|
|
status, text="Reload data", width=100,
|
|
command=self._manual_reload,
|
|
height=24, font=T.FONT_STATUS, border_width=1, corner_radius=6,
|
|
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
|
text_color=T.ACCENT_TEXT, border_color=T.ACCENT_BORDER)
|
|
self._reload_btn.grid(row=0, column=4, sticky="e", padx=(0, 10), pady=4)
|
|
|
|
# ── Toolbar actions ───────────────────────────────────────────────────────
|
|
|
|
def _open_appdata_folder(self) -> None:
|
|
folder = app_paths.appdata_dir()
|
|
try:
|
|
import sys, subprocess
|
|
if os.name == "nt":
|
|
os.startfile(folder)
|
|
elif sys.platform == "darwin":
|
|
subprocess.Popen(["open", folder])
|
|
else:
|
|
subprocess.Popen(["xdg-open", folder])
|
|
except Exception as exc:
|
|
log.warning("Could not open app data folder %s: %s", folder, exc)
|
|
|
|
def _open_csv(self) -> None:
|
|
path = getattr(self._cfg, "resolved_csv_path", "") or ""
|
|
if not path or not os.path.isfile(path):
|
|
self._set_status("No CSV loaded — use 'Change folder' to locate one.")
|
|
return
|
|
try:
|
|
os.startfile(path)
|
|
except Exception as exc:
|
|
log.warning("Could not open CSV %s: %s", path, exc)
|
|
|
|
def _change_parts_folder(self) -> None:
|
|
"""Show a folder picker, persist the chosen folder to ui_state.json, reload."""
|
|
folder = filedialog.askdirectory(
|
|
parent=self,
|
|
title="Select Parts Folder",
|
|
)
|
|
if not folder:
|
|
return
|
|
self._cfg.parts_folder_override = folder
|
|
self._cfg.resolve_csv()
|
|
self._state["parts_folder"] = folder
|
|
try:
|
|
_save_state(self._state)
|
|
log.info("Parts folder set to %s", folder)
|
|
except Exception as exc:
|
|
log.warning("Folder set, but failed to save state: %s", exc)
|
|
self._set_status(f"Parts folder set to {os.path.basename(folder)}. Reloading...")
|
|
self.after(80, self._do_reload)
|
|
|
|
def _on_mode_change(self, mode: str) -> None:
|
|
ctk.set_appearance_mode(mode.lower())
|
|
self._cfg.appearance_mode = mode.lower()
|
|
try:
|
|
self._cfg.save()
|
|
except Exception:
|
|
pass
|
|
self._state["appearance_mode"] = mode.lower()
|
|
_save_state(self._state)
|
|
_sb_bg = T.TOOLBAR_BG[1] if mode.lower() == "dark" else T.TOOLBAR_BG[0]
|
|
self._status_frame.configure(bg=_sb_bg)
|
|
|
|
# ── Data loading ──────────────────────────────────────────────────────────
|
|
|
|
def _initial_load(self) -> None:
|
|
csv_path = getattr(self._cfg, "resolved_csv_path", "")
|
|
if not csv_path:
|
|
self._prompt_missing_csv()
|
|
return
|
|
self._set_status("Loading parts CSV...")
|
|
self._do_reload()
|
|
|
|
def _prompt_missing_csv(self) -> None:
|
|
"""Show an explanation popup and open a folder picker to choose the parts CSV directory."""
|
|
from tkinter import messagebox
|
|
messagebox.showinfo(
|
|
"Parts Database Not Found",
|
|
"No parts CSV was found at the configured path.\n\n"
|
|
"Please choose the folder that contains your dated parts files\n"
|
|
"(e.g. '06-16-26 OnPart.csv').",
|
|
parent=self,
|
|
)
|
|
self._change_parts_folder()
|
|
|
|
def _manual_reload(self) -> None:
|
|
self._cfg.resolve_csv()
|
|
self._do_reload()
|
|
|
|
def _do_reload(self) -> None:
|
|
self._set_status("Reloading...")
|
|
cfg = self._cfg
|
|
|
|
def worker() -> None:
|
|
try:
|
|
repo = PartsRepo(
|
|
csv_path=getattr(cfg, "resolved_csv_path", ""),
|
|
sqlite_path=getattr(cfg, "sqlite_path", ""),
|
|
)
|
|
image_cache = ImageCache(
|
|
cache_dir=getattr(cfg, "image_cache_dir", ""),
|
|
smb_dir=getattr(cfg, "smb_image_dir", ""),
|
|
)
|
|
n = repo.count()
|
|
csv_name = os.path.basename(
|
|
getattr(cfg, "resolved_csv_path", "")) or ""
|
|
ts = datetime.now().strftime("%H:%M:%S")
|
|
|
|
def _done() -> None:
|
|
self.repo = repo
|
|
self.images = image_cache
|
|
self._parts_tab.set_repo(repo, image_cache)
|
|
verb = "Loaded"
|
|
msg = (f"{verb} {n:,} parts at {ts}"
|
|
if not csv_name
|
|
else f"{verb} {n:,} parts from {csv_name}")
|
|
self._set_status(msg)
|
|
log.info("Loaded %d parts", n)
|
|
|
|
self.after(0, _done)
|
|
except Exception as exc:
|
|
log.exception("Initial load failed")
|
|
self.after(0, lambda: self._set_status(f"Load failed: {exc}"))
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
|
|
def _set_status(self, text: str) -> None:
|
|
self.after(0, lambda: self._status_var.set(text))
|
|
|
|
# ── Compact Mode ──────────────────────────────────────────────────────────
|
|
|
|
def _open_compact_mode(self) -> None:
|
|
CompactModeWindow(
|
|
self,
|
|
repo=self.repo,
|
|
labels=self.labels,
|
|
state=self._state,
|
|
save_state=_save_state,
|
|
)
|
|
|
|
# ── Cleanup ───────────────────────────────────────────────────────────────
|
|
|
|
def destroy(self) -> None:
|
|
try:
|
|
self._state.update(self.cut_files.to_state())
|
|
self._state.update(self.lightburn_files.to_state())
|
|
self._state["appearance_mode"] = ctk.get_appearance_mode().lower()
|
|
_save_state(self._state)
|
|
except Exception:
|
|
pass
|
|
super().destroy()
|