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

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()