Initial commit
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user