"""S-CANVAS InlinePanel — `ctk.CTkToplevel` 호환 인라인 오버레이 (#4). 피드백 #4: "프로세스를 클릭할 때마다 새로운 창이 뜨는 것이 아니라 한 화면에서 바로 구동되게끔 적용". 본 모듈은 `ctk.CTkToplevel(parent)` 자리에 그대로 들어가는 drop-in 대체. main 앱 창의 `main_frame` 안에 floating overlay 로 렌더되어 별도 OS 창을 만들지 않는다. 호환 API (Toplevel subset): - title(str) - geometry("WxH" | "WxH+X+Y") — X,Y 는 무시 (centering) - transient(parent) — no-op - grab_set() / grab_release() — lift + focus 시뮬레이트 - protocol("WM_DELETE_WINDOW", fn) - wait_window() — tk.Misc.wait_window 그대로 동작 (Frame 도 OK) - destroy() - iconbitmap / iconphoto / wm_* — no-op (Toplevel 전용) 사용: from harness.inline_panel import InlinePanel win = InlinePanel(self) # ctk.CTkToplevel(self) 와 동일하게 win.title("DXF 레이어 분류") win.geometry("900x650") win.grab_set() # ... 자식 위젯 .pack() / .grid() btn_close = ctk.CTkButton(win, text="닫기", command=win.destroy) btn_close.pack() win.wait_window() # 패널 destroy 까지 블록 기존 Toplevel 와 거의 동일한 코드 변경 = 1줄 (`CTkToplevel` → `InlinePanel`). """ from __future__ import annotations import contextlib from collections.abc import Callable import customtkinter as ctk # Mastercard 팔레트 (scanvas_maker.py 와 일관) — 타이틀 바 색상. _MC_RED = "#EB001B" _MC_RED_DARK = "#A30013" _MC_WHITE = "#FFFFFF" class InlinePanel(ctk.CTkFrame): """`ctk.CTkToplevel` 인터페이스를 구현한 inline overlay frame. 상위 SCanvasApp 인스턴스의 `main_frame` 안에 `place(relx=0.5, rely=0.5)` 로 중앙 배치. 같은 시점에 여러 패널이 열리면 가장 마지막 것이 위 (lift). 구현 노트: - `tk.Misc.wait_window(self)` 는 widget destruction 을 기다리는 메커니즘 이라 Toplevel 외 Frame 에서도 동작. CTkFrame → tk.Frame → tk.Widget. - `grab_set()` 은 OS-level focus 잡는 게 본래 의미인데, 인라인 오버레이는 하나의 창 안이라 무의미 → lift + focus 로 시뮬레이트. - geometry 의 X+Y 좌표는 무시 (panel 은 항상 main_frame 중앙). """ _z_counter = 0 # 다중 패널 z-order 카운터 def __init__(self, parent: ctk.CTkBaseClass, **kwargs): # 상위 SCanvasApp 찾기 — winfo_toplevel() 이 root window 반환. # parent 가 SCanvasApp 또는 다른 InlinePanel 둘 다 OK. # 안전 폴백: main_frame 없는 root 면 parent 자체에 그림. app = parent.winfo_toplevel() host = app.main_frame if hasattr(app, "main_frame") else parent super().__init__( host, fg_color=("#FFFFFF", "#1A1A1A"), border_width=2, border_color=("#E0E0E0", "#333333"), corner_radius=10, **kwargs, ) self._app = app self._host = host self._title_text: str = "" self._title_bar: ctk.CTkFrame | None = None self._title_label: ctk.CTkLabel | None = None self._content_frame: ctk.CTkFrame | None = None self._on_close: Callable[[], None] | None = None self._closed = False self._geom_w = 640 self._geom_h = 480 InlinePanel._z_counter += 1 self._z = InlinePanel._z_counter self._build_chrome() self._reposition() # --- chrome (title bar + content area) ----------------------------------- def _build_chrome(self) -> None: """타이틀 바 (MC red) + 컨텐츠 프레임. 자식 위젯은 컨텐츠 프레임에 들어감.""" # title_bar: MC red 배경, 흰 텍스트, ✕ 버튼 self._title_bar = ctk.CTkFrame( self, height=34, fg_color=(_MC_RED, _MC_RED_DARK), corner_radius=8, ) self._title_bar.pack(side="top", fill="x", padx=4, pady=(4, 0)) self._title_bar.pack_propagate(False) self._title_label = ctk.CTkLabel( self._title_bar, text="", text_color=_MC_WHITE, font=ctk.CTkFont(size=13, weight="bold"), ) self._title_label.pack(side="left", padx=12, pady=4) close_btn = ctk.CTkButton( self._title_bar, text="✕", width=28, height=24, fg_color="transparent", hover_color=_MC_RED_DARK, text_color=_MC_WHITE, corner_radius=4, font=ctk.CTkFont(size=14, weight="bold"), command=self._user_close, ) close_btn.pack(side="right", padx=4, pady=4) # content frame: 자식 위젯 컨테이너. 사용자가 .pack/.grid(self)로 추가하면 # 자동으로 여기 자식이 됨 (CTkFrame 의 _children 위임). 직접 호출은 따로. # 다만 기존 코드는 `widget(win, ...)` 처럼 win 자체를 parent 로 쓰니 그게 # InlinePanel 의 직접 자식이 됨 — 그래서 content_frame 은 reserved 안 만들고 # title_bar 만 packed 후 자식들이 그 아래로 채워짐 (pack 자동 layout). # title bar 와 content 사이 분리선 sep = ctk.CTkFrame(self, height=1, fg_color=("#E0E0E0", "#333333")) sep.pack(side="top", fill="x", padx=8, pady=(0, 4)) def _reposition(self) -> None: """geometry str 파싱 + 중앙 배치 (main_frame 의 중앙).""" try: mw = self._host.winfo_width() or 1200 mh = self._host.winfo_height() or 800 # main_frame 의 95% 안으로 cap w = min(self._geom_w, int(mw * 0.95)) h = min(self._geom_h, int(mh * 0.95)) self.place(relx=0.5, rely=0.5, anchor="center", width=w, height=h) self.lift() except Exception: with contextlib.suppress(Exception): self.place(relx=0.5, rely=0.5, anchor="center") self.lift() # --- ctk.CTkToplevel 호환 메서드 ----------------------------------------- def title(self, t: str) -> None: self._title_text = t if self._title_label is not None: self._title_label.configure(text=t) def geometry(self, geom_str: str) -> None: """\"WxH\" 또는 \"WxH+X+Y\" 형식 파싱. X,Y 는 무시 (always center).""" try: size_part = geom_str.split("+", 1)[0] w_str, h_str = size_part.split("x") self._geom_w = int(w_str) self._geom_h = int(h_str) self._reposition() except (ValueError, AttributeError): pass def transient(self, master) -> None: """no-op (이미 main_frame 안).""" def grab_set(self) -> None: """모달 시뮬레이트 — lift + focus.""" with contextlib.suppress(Exception): self.lift() self.focus_set() def grab_release(self) -> None: """no-op.""" def protocol(self, name: str, handler: Callable[[], None]) -> None: if name == "WM_DELETE_WINDOW": self._on_close = handler def iconbitmap(self, *args, **kwargs) -> None: """Toplevel 전용 — no-op (인라인 패널은 OS 창이 아님).""" def iconphoto(self, *args, **kwargs) -> None: """Toplevel 전용 — no-op.""" def wm_iconbitmap(self, *args, **kwargs) -> None: """Toplevel 전용 — no-op.""" def wm_iconphoto(self, *args, **kwargs) -> None: """Toplevel 전용 — no-op.""" def attributes(self, *args, **kwargs): """\"-topmost\", \"-alpha\" 등 — no-op (인라인 패널은 z-order 관리만).""" return None def overrideredirect(self, *args, **kwargs) -> None: """no-op.""" # --- 종료 처리 ----------------------------------------------------------- def _user_close(self) -> None: """타이틀 바 ✕ 또는 protocol(WM_DELETE_WINDOW) 호출 시 진입점.""" if self._on_close is not None: try: self._on_close() except Exception: self.destroy() else: self.destroy() def destroy(self) -> None: if self._closed: return self._closed = True with contextlib.suppress(Exception): self.place_forget() super().destroy() # wait_window() 는 별도 정의 안 함 — tk.Misc.wait_window(self) 가 widget # destruction 을 기다리는데, CTkFrame 이 tk.Widget 상속이라 Frame 에서도 동작.