사용자 명시 요청 (#4 잔여 핵심): "기존 구조에 지도 아래에 있는 로그는 백엔드로 빼고, 프로세스를 클릭할 때마다 새로운 창이 뜨는 것이 아니라 한 화면에서 바로 구동되게끔 적용". 1. 인라인 로그 패널 완전 제거: - self.textbox CTkTextbox 위젯 제거 (이전 라운드 80px 축소 → 본 라운드 완전 삭제). - main_frame layout: row 0 weight 3→1 (지도 전체 차지), row 1 (로그) 제거, status_bar row 2→1. - self.log() 동작 변경: textbox.insert 대신 백엔드 logger.info (logs/scanvas.log RotatingFileHandler 5MB×5). status_text 가 짧은 미리보기 (≤80자) 즉시 표시. - 효과: 메인 캔버스 영역 ~25% 확대 + GUI 메인 thread 부담 감소. 2. harness/inline_panel.py 신규 (231 LOC): - InlinePanel: ctk.CTkToplevel 호환 인라인 오버레이 (CTkFrame 상속). - API 호환: title/geometry/transient/grab_set/protocol/wait_window/destroy + iconbitmap/wm_* no-op. - 핵심 트릭: tk.Misc.wait_window(self) 가 Frame 에서도 동작 (widget destruction 대기) — wait_window 호출 5곳 (T1/T6/T7/T8/T10) 그대로 유지 가능. - 다중 패널 z-order (_z_counter + lift), main_frame 95% cap, MC Red 타이틀 바. 3. 12 ctk.CTkToplevel → InlinePanel 일괄 치환: - T1 (DXF 레이어), T2 (구조물 빌드), T3 (빌드 진행), T6 (상세도면), T7 (치수), T8 (계획선 고도), T9 (TIN core), T10 (렌더 옵션), T11 (Blender 결과), T12 (AI 렌더 결과) — 10 main popups. - T4 (렌더 sub-옵션), T5 (VLM 결과) — T3 자식 popups. - 2 replace_all 패턴: ctk.CTkToplevel(self) → InlinePanel(self), ctk.CTkToplevel(win) → InlinePanel(win). - 결과: 12 popups 모두 main 창 안 floating frame 으로 렌더, 별도 OS 창 안 뜸. 사용자가 ALT-TAB 으로 창 사이 오갈 필요 없음. 검증: - py_compile + AST OK (scanvas_maker, perf, crash_logger, inline_panel 4개). - ruff check All checks passed (0 errors). - import smoke test: scanvas_maker import 성공, InlinePanel 가 진짜 harness 클래스로 로드. - self.textbox 잔존 refs: 0. CTkToplevel refs: 3 (모두 import fallback/주석). InlinePanel refs: 15 (12 호출지 + import). 잔여 (#4 next round, multi-session): - InlinePanel 실 GUI 워크플로 검증 (사용자 도면 로드 후 T1~T12 한 번씩). - VTK 임베딩 (pv.Plotter().show() 6곳 → pyvistaqt.QtInteractor). - messagebox 63회 → 인라인 토스트. - Inspector 패널 영구 컬럼 (3-column 레이아웃). - 메인 thread 블로킹 작업 worker thread 분리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
8.5 KiB
Python
220 lines
8.5 KiB
Python
"""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 에서도 동작.
|