"""Tkinter GUI for picking slabs and rendering the optimal layout. Layout (left-to-right): 1. Tier-grouped slab catalog with [+] [−] count controls 2. Current basket summary 3. Slot count slider + Solve button + result preview The preview is rendered to a temp PNG via renderer and shown via PhotoImage. """ from __future__ import annotations import os import sys import tempfile import threading import tkinter as tk from tkinter import filedialog, messagebox, ttk from typing import Dict, List from PIL import Image, ImageTk from .renderer import render_solution from .slabs import SLABS, SLABS_BY_VALUE, TIER_LABEL, TIER_ORDER from .solver import solve class App(tk.Tk): def __init__(self) -> None: super().__init__() self.title("Sephiria Inventory Optimizer") self.geometry("1280x800") self.minsize(1000, 600) # counts: value -> int self.counts: Dict[str, int] = {s.value: 0 for s in SLABS} self.count_labels: Dict[str, tk.Label] = {} self.summary_var = tk.StringVar(value="선택된 석판: 0개") self.score_var = tk.StringVar(value="score: -") self.slot_var = tk.IntVar(value=34) self.solving = False self.preview_image: ImageTk.PhotoImage | None = None self.last_solution = None self._build() # -------------------------------------------------------------- layout def _build(self) -> None: root = ttk.Frame(self, padding=8) root.pack(fill="both", expand=True) root.columnconfigure(0, weight=2) root.columnconfigure(1, weight=3) root.rowconfigure(0, weight=1) # Left: catalog left = ttk.LabelFrame(root, text="석판 목록 (보유 개수)", padding=6) left.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) self._build_catalog(left) # Right: controls + preview right = ttk.Frame(root) right.grid(row=0, column=1, sticky="nsew") right.columnconfigure(0, weight=1) right.rowconfigure(2, weight=1) ctl = ttk.LabelFrame(right, text="옵션", padding=8) ctl.grid(row=0, column=0, sticky="ew") ttk.Label(ctl, text="슬롯 수").grid(row=0, column=0, sticky="w") slot_scale = ttk.Scale( ctl, from_=18, to=60, orient="horizontal", variable=self.slot_var, command=self._on_slot_change, ) slot_scale.grid(row=0, column=1, sticky="ew", padx=6) ctl.columnconfigure(1, weight=1) self.slot_label = ttk.Label(ctl, text="34") self.slot_label.grid(row=0, column=2, sticky="w") ttk.Button(ctl, text="모두 비우기", command=self._clear).grid( row=1, column=0, pady=(8, 0), sticky="w" ) self.solve_btn = ttk.Button(ctl, text="최적 배치 계산", command=self._solve) self.solve_btn.grid(row=1, column=1, pady=(8, 0), sticky="ew", padx=6) ttk.Button(ctl, text="이미지 저장…", command=self._save).grid( row=1, column=2, pady=(8, 0), sticky="e" ) summary = ttk.Frame(right) summary.grid(row=1, column=0, sticky="ew", pady=(6, 0)) ttk.Label(summary, textvariable=self.summary_var).pack(side="left") ttk.Label(summary, textvariable=self.score_var).pack(side="right") preview_frame = ttk.LabelFrame(right, text="결과 미리보기", padding=4) preview_frame.grid(row=2, column=0, sticky="nsew", pady=(6, 0)) self.preview = ttk.Label( preview_frame, anchor="center", text="좌측에서 석판을 추가하고 '최적 배치 계산'을 눌러주세요.", ) self.preview.pack(fill="both", expand=True) def _build_catalog(self, parent: tk.Widget) -> None: canvas = tk.Canvas(parent, highlightthickness=0) scroll = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview) inner = ttk.Frame(canvas) inner.bind( "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")), ) canvas.create_window((0, 0), window=inner, anchor="nw") canvas.configure(yscrollcommand=scroll.set) canvas.pack(side="left", fill="both", expand=True) scroll.pack(side="right", fill="y") # mousewheel def _wheel(e): canvas.yview_scroll(int(-e.delta / 120), "units") canvas.bind_all("", _wheel) canvas.bind_all("", lambda e: canvas.yview_scroll(-1, "units")) canvas.bind_all("", lambda e: canvas.yview_scroll(1, "units")) # group by tier by_tier: Dict[str, List] = {t: [] for t in TIER_ORDER} for s in SLABS: by_tier.setdefault(s.tier, []).append(s) row = 0 for tier in sorted(by_tier, key=lambda t: TIER_ORDER.get(t, 99)): slabs = by_tier[tier] if not slabs: continue ttk.Label( inner, text=TIER_LABEL.get(tier, tier), font=("TkDefaultFont", 10, "bold"), ).grid(row=row, column=0, columnspan=4, sticky="w", pady=(8, 2)) row += 1 for slab in slabs: ttk.Label(inner, text=slab.ko_label, width=8).grid( row=row, column=0, sticky="w" ) ttk.Button( inner, text="−", width=2, command=lambda v=slab.value: self._change_count(v, -1), ).grid(row=row, column=1) lbl = ttk.Label(inner, text="0", width=3, anchor="center") lbl.grid(row=row, column=2) self.count_labels[slab.value] = lbl ttk.Button( inner, text="+", width=2, command=lambda v=slab.value: self._change_count(v, 1), ).grid(row=row, column=3) row += 1 # -------------------------------------------------------------- handlers def _change_count(self, value: str, delta: int) -> None: n = max(0, self.counts.get(value, 0) + delta) self.counts[value] = n self.count_labels[value]["text"] = str(n) self._update_summary() def _clear(self) -> None: for v in self.counts: self.counts[v] = 0 self.count_labels[v]["text"] = "0" self._update_summary() def _update_summary(self) -> None: total = sum(self.counts.values()) self.summary_var.set(f"선택된 석판: {total}개") def _on_slot_change(self, _evt) -> None: v = int(round(self.slot_var.get())) self.slot_var.set(v) self.slot_label["text"] = str(v) def _expand_basket(self) -> List[str]: basket: List[str] = [] for v, n in self.counts.items(): basket.extend([v] * n) return basket def _solve(self) -> None: if self.solving: return basket = self._expand_basket() if not basket: messagebox.showinfo("안내", "먼저 보유한 석판을 추가하세요.") return slot_num = int(round(self.slot_var.get())) if len(basket) > slot_num: if not messagebox.askyesno( "확인", f"슬롯({slot_num})보다 석판({len(basket)})이 많습니다.\n" "초과분은 무시하고 계산할까요?", ): return self.solving = True self.solve_btn["state"] = "disabled" self.score_var.set("score: 계산 중…") threading.Thread( target=self._solve_worker, args=(basket, slot_num), daemon=True ).start() def _solve_worker(self, basket: List[str], slot_num: int) -> None: try: sol = solve(basket, slot_num=slot_num, time_limit=4.0) img = render_solution(sol, download=True) self.after(0, self._show_result, sol, img) except Exception as e: self.after(0, lambda: messagebox.showerror("오류", str(e))) finally: self.after(0, self._solve_done) def _solve_done(self) -> None: self.solving = False self.solve_btn["state"] = "normal" def _show_result(self, sol, img: Image.Image) -> None: self.last_solution = sol # fit preview width w = max(self.preview.winfo_width(), 600) scale = min(1.0, w / img.width) if scale < 1.0: img = img.resize((int(img.width * scale), int(img.height * scale))) self.preview_image = ImageTk.PhotoImage(img) self.preview.configure(image=self.preview_image, text="") self.score_var.set(f"score: {sol.score}") def _save(self) -> None: if self.last_solution is None: messagebox.showinfo("안내", "먼저 계산을 실행하세요.") return path = filedialog.asksaveasfilename( defaultextension=".png", filetypes=[("PNG", "*.png")], initialfile="sephiria_layout.png", ) if not path: return img = render_solution(self.last_solution, download=True) img.save(path) messagebox.showinfo("완료", f"저장됨: {path}") def main() -> int: try: app = App() except tk.TclError as e: print(f"GUI를 띄울 수 없습니다 ({e}). CLI 모드를 시도하세요:", file=sys.stderr) print(" python -m sephiria_inv --help", file=sys.stderr) return 1 app.mainloop() return 0 if __name__ == "__main__": raise SystemExit(main())