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