from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QSplitter, QToolBar, QStatusBar, QLabel, ) from PySide6.QtCore import Qt, QRect from PySide6.QtGui import QAction from app.ui.analysis_panel import AnalysisPanel from app.core.sync_controller import sync_manager from app.ui.components.toggle_button import ToggleButton from app.ui.dialogs.api_key_dialog import APIKeyDialog from app.core.settings import get_settings_manager from app.ui.widgets.ai_floating_chat import AIFloatingButton, AIChatDialog, animate_open, animate_close from PySide6.QtCore import Qt, QRect, QTimer class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("SL200 AI Smart Analyzer") self.resize(1800, 1000) # 1. 기본 UI 골격만 즉시 생성 (매우 빠름) self.setup_ui_skeleton() self.setup_menubar() self.setup_toolbar() # 2. 무거운 컴포넌트(패널, AI)는 윈도우가 뜬 직후 지연 로딩 # 0ms: 이벤트 루프가 돌기 시작한 직후 실행 (윈도우 표시 후) QTimer.singleShot(10, self._init_panels) # 500ms: 패널 로딩 후 실행 QTimer.singleShot(500, self._init_ai_client) def setup_ui_skeleton(self): """기본 UI 골격 생성 (빈 패널)""" central = QWidget() self.setCentralWidget(central) self.main_layout = QVBoxLayout(central) # 좌우 분할 (빈 상태로 생성) self.splitter = QSplitter(Qt.Orientation.Horizontal) self.main_layout.addWidget(self.splitter) self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) self.status_bar.showMessage("초기화 중...") # 플로팅 AI 버튼 (미리 생성하되 기능은 나중에 연결) self._ai_button = AIFloatingButton(central) self._ai_button.show() self._ai_chat: AIChatDialog | None = None self._ai_button.move_to_bottom_right() def _init_panels(self): """패널 지연 생성""" try: # 왼쪽/오른쪽 패널 생성 (ID 부여) # 여기가 가장 시간이 많이 걸리는 부분 (약 0.5~1초) self.left_panel = AnalysisPanel("left") self.right_panel = AnalysisPanel("right") self.splitter.addWidget(self.left_panel) self.splitter.addWidget(self.right_panel) self.splitter.setSizes([900, 900]) # 1:1 비율 # 패널 데이터 로드 시 날짜 비교를 위한 시그널 연결 self.left_panel.data_loaded.connect(self.check_sync_availability) self.right_panel.data_loaded.connect(self.check_sync_availability) # AI 버튼 클릭 연결 self._ai_button.clicked.connect(self._open_ai_chat) self.status_bar.showMessage("준비 완료", 3000) print("[MainWindow] Panels initialized lazily.") except Exception as e: print(f"[MainWindow] Panel initialization failed: {e}") self.status_bar.showMessage(f"초기화 오류: {e}") # setup_ui 메서드는 제거 (setup_ui_skeleton으로 대체됨) def resizeEvent(self, event): super().resizeEvent(event) try: if hasattr(self, "_ai_button") and self._ai_button: self._ai_button.move_to_bottom_right() except Exception: pass def setup_toolbar(self): toolbar = QToolBar("Main Toolbar") self.addToolBar(toolbar) # 커스텀 토글 버튼 self.sync_toggle = ToggleButton(width=60, active_color="#00D084") # 녹색 self.sync_toggle.toggled.connect(self.on_sync_toggled) self.sync_toggle.setEnabled(False) # 초기에는 비활성화 toolbar.addWidget(QLabel(" SYNC MODE: ")) toolbar.addWidget(self.sync_toggle) # 상태 라벨 self.sync_status_label = QLabel(" (데이터 없음)") self.sync_status_label.setStyleSheet("color: #888;") toolbar.addWidget(self.sync_status_label) def setup_menubar(self): """메뉴바 설정""" menubar = self.menuBar() # 파일 메뉴 menubar.addMenu("파일(&F)") # 설정 메뉴 settings_menu = menubar.addMenu("설정(&S)") # AI API 키 설정 ai_settings_action = QAction("🔑 AI API 키 설정...", self) ai_settings_action.setShortcut("Ctrl+Shift+A") ai_settings_action.triggered.connect(self.show_api_key_dialog) settings_menu.addAction(ai_settings_action) # 도움말 메뉴 menubar.addMenu("도움말(&H)") def show_api_key_dialog(self): """API 키 설정 다이얼로그 표시""" dialog = APIKeyDialog(self) dialog.settings_changed.connect(self._on_ai_settings_changed) dialog.exec() def _on_ai_settings_changed(self): """AI 설정이 변경되었을 때""" self.status_bar.showMessage("AI 설정이 저장되었습니다.", 3000) self._init_ai_client() def check_sync_availability(self): """두 패널의 데이터 날짜가 같은지 확인하여 Sync 버튼 활성화/비활성화""" left_date = self.left_panel.get_data_date() right_date = self.right_panel.get_data_date() # 둘 다 데이터가 있는 경우에만 비교 if left_date and right_date: if left_date == right_date: # 날짜가 같으면 Sync 활성화 self.sync_toggle.setEnabled(True) self.sync_status_label.setText(f" ({left_date})") self.sync_status_label.setStyleSheet("color: #00D084;") else: # 날짜가 다르면 Sync 비활성화 self.sync_toggle.setEnabled(False) self.sync_toggle.setChecked(False) # 자동으로 끔 self.sync_status_label.setText(f" (날짜 불일치: {left_date} ≠ {right_date})") self.sync_status_label.setStyleSheet("color: #FF5252;") elif left_date or right_date: # 한쪽만 데이터가 있음 self.sync_toggle.setEnabled(False) date_info = left_date or right_date self.sync_status_label.setText(f" ({date_info} - 상대 패널 없음)") self.sync_status_label.setStyleSheet("color: #888;") else: # 둘 다 데이터 없음 self.sync_toggle.setEnabled(False) self.sync_status_label.setText(" (데이터 없음)") self.sync_status_label.setStyleSheet("color: #888;") def on_sync_toggled(self, checked): """동기화 모드 변경 시 동작""" sync_manager.is_sync_enabled = checked if checked: # [SYNC ON] # 1. 오른쪽 패널의 파일 로드 버튼 숨김 self.right_panel.set_slave_mode(True) # 2. 왼쪽 패널의 데이터가 있다면 오른쪽으로 복사 if self.left_panel.raw_data: self.right_panel.receive_shared_data(self.left_panel.raw_data) self.status_bar.showMessage("Sync ON: 오른쪽 패널이 왼쪽 데이터와 동기화됩니다.") else: # [SYNC OFF] self.right_panel.set_slave_mode(False) self.status_bar.showMessage("Sync OFF: 독립 분석 모드") def _init_ai_client(self): """저장된 설정으로 AI 클라이언트 초기화(가능할 때만)""" try: from app.ai import get_ai_client, AIProviderType settings = get_settings_manager().ai_settings current_provider = settings.get_current_provider() if not current_provider: return api_key = settings.get_api_key(current_provider) model = settings.get_model(current_provider) if not api_key: return client = get_ai_client() provider_type = AIProviderType(current_provider) if client.set_provider(provider_type, api_key, model): st = client.get_status() self.status_bar.showMessage(f"AI 준비: {st.get('provider')} / {st.get('model')}", 3000) except Exception as e: print(f"AI 클라이언트 초기화 실패: {e}") def _get_panel_ctx(self, panel_id: str, user_query: str | None = None): panel = self.right_panel if panel_id == "right" else self.left_panel ctx = panel.get_ai_context() # 질의에 따라 추가 분석 결과를 포함(프롬프트 품질 개선) if user_query: q = user_query.lower() if ("pg3" in q or "pg3-2" in q) and ("역" in user_query): try: ctx.setdefault("analysis", {}) ctx["analysis"]["pg3_2_per_station"] = panel.compute_pg_at_station("PG3-2") except Exception: pass return ctx def _is_qobject_alive(self, obj) -> bool: """PySide6 객체가 이미 delete된 상태인지 안전하게 판별""" if obj is None: return False try: import shiboken6 # type: ignore return bool(shiboken6.isValid(obj)) except Exception: # shiboken6이 없거나, 일부 환경에서 import 실패 시 런타임 호출로 체크 try: obj.objectName() return True except Exception: return False def _on_ai_chat_destroyed(self, *_args): # 삭제된 객체 참조 제거(다음 클릭에서 RuntimeError 방지) self._ai_chat = None self._ai_button.set_open_state(False) def _open_ai_chat(self): # 열려 있으면: 웹처럼 버튼(X)로 닫기 동작 if self._ai_chat and self._is_qobject_alive(self._ai_chat) and self._ai_chat.isVisible(): btn = self._ai_button start_rect = self._ai_chat.geometry() # 닫힘 애니메이션은 버튼 위치로 축소 btn_pos = btn.mapTo(self, btn.rect().topLeft()) end_rect = QRect(btn_pos.x(), btn_pos.y(), btn.width(), btn.height()) animate_close(self._ai_chat, start_rect, end_rect, duration_ms=180) return # 레퍼런스는 남아있지만 이미 삭제된 경우 정리 if self._ai_chat and not self._is_qobject_alive(self._ai_chat): self._ai_chat = None self._ai_button.set_open_state(False) self._ai_chat = AIChatDialog(self, self._get_panel_ctx) self._ai_chat.setAttribute(Qt.WA_DeleteOnClose, True) self._ai_chat.destroyed.connect(self._on_ai_chat_destroyed) self._ai_button.set_open_state(True) # 버튼 위치 기준으로 확장 애니메이션 btn = self._ai_button # 메인윈도우 좌표로 변환 btn_top_left = btn.mapTo(self, btn.rect().topLeft()) start_rect = QRect(btn_top_left.x(), btn_top_left.y(), btn.width(), btn.height()) # 최종 크기: 버튼을 가리지 않도록 '버튼 위로' 배치(가능하면) w, h = 560, 680 gap = 10 margin = 14 # 1) 우선: 버튼 위로(오른쪽 정렬) end_x = btn_top_left.x() + btn.width() - w end_y = btn_top_left.y() - h - gap # 위로 배치 불가능하면: 버튼 왼쪽으로 if end_y < margin: end_y = max(margin, btn_top_left.y() + btn.height() - h) # 세로는 버튼과 겹치지 않게 최대한 맞춤 end_x = btn_top_left.x() - w - gap # 그래도 화면 밖이면: 화면 안으로 클램프(버튼 가림 최소화) end_x = max(margin, min(end_x, self.width() - w - margin)) end_y = max(margin, min(end_y, self.height() - h - margin)) end_rect = QRect(end_x, end_y, w, h) animate_open(self._ai_chat, start_rect, end_rect, duration_ms=280)