"""메인 컨트롤 창 — 사이드바 + 페이지 스택. 디자인 의도: 자막은 게임 위 오버레이가 전담하고, 이 창은 '설정과 상태'만 담당한다. 그래서 두 창이 서로 방해하지 않는다. """ from __future__ import annotations import logging from datetime import datetime from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtGui import QCloseEvent from PySide6.QtWidgets import ( QButtonGroup, QFrame, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPushButton, QScrollArea, QStackedWidget, QVBoxLayout, QWidget, ) from ..config import AppConfig from ..constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir from ..core import EngineState, EngineStatus, TranslationEngine, TranslationLine from .overlay import SubtitleOverlay from .pages import GlossaryPage, HomePage, ModelsPage, SettingsPage, SubtitlePage from .theme import SPACING, palette, stylesheet log = logging.getLogger(__name__) NAV = [ ("홈", "실시간 번역"), ("모델", "품질 티어"), ("자막", "표시 설정"), ("용어집", "고유명사 고정"), ("설정", "세부 조정"), ] class MainWindow(QMainWindow): #: 엔진 콜백은 워커 스레드에서 오므로 시그널로 GUI 스레드에 넘긴다. line_received = Signal(object) status_received = Signal(object) def __init__(self, config: AppConfig) -> None: super().__init__() self.config = config self.setWindowTitle(f"{APP_NAME} · {APP_SLOGAN}") self.resize(1060, 820) self.engine = TranslationEngine( config, on_line=self.line_received.emit, on_status=self.status_received.emit, ) self.overlay = SubtitleOverlay(config.subtitle, config.overlay) self._transcript_file = None self._build_ui() self._connect() self._save_timer = QTimer(self) self._save_timer.setSingleShot(True) self._save_timer.setInterval(800) # 입력 중 계속 쓰지 않도록 묶어서 저장 self._save_timer.timeout.connect(lambda: self.config.save()) # --- UI ------------------------------------------------------------- def _build_ui(self) -> None: self.setStyleSheet(stylesheet(self.config.theme)) central = QWidget() layout = QHBoxLayout(central) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self._build_sidebar()) self.stack = QStackedWidget() self.home_page = HomePage(self.config) self.models_page = ModelsPage(self.config) self.subtitle_page = SubtitlePage(self.config) self.glossary_page = GlossaryPage(self.config) self.settings_page = SettingsPage(self.config) for page in ( self.home_page, self.models_page, self.subtitle_page, self.glossary_page, self.settings_page, ): self.stack.addWidget(_scrollable(page)) layout.addWidget(self.stack, 1) self.setCentralWidget(central) def _build_sidebar(self) -> QFrame: p = palette(self.config.theme) bar = QFrame() bar.setObjectName("Sidebar") bar.setFixedWidth(212) layout = QVBoxLayout(bar) layout.setContentsMargins(14, 22, 14, 18) layout.setSpacing(6) brand = QLabel(APP_NAME) brand.setStyleSheet( f"font-size: 24px; font-weight: 800; color: {p.accent}; padding-left: 6px;" ) layout.addWidget(brand) tag = QLabel(APP_SLOGAN) tag.setObjectName("Hint") tag.setStyleSheet(f"color: {p.text_dim}; padding-left: 7px;") layout.addWidget(tag) layout.addSpacing(SPACING + 8) self._nav_group = QButtonGroup(self) self._nav_group.setExclusive(True) for index, (title, hint) in enumerate(NAV): button = QPushButton(title) button.setObjectName("NavButton") button.setCheckable(True) button.setToolTip(hint) button.setCursor(Qt.CursorShape.PointingHandCursor) self._nav_group.addButton(button, index) layout.addWidget(button) self._nav_group.button(0).setChecked(True) self._nav_group.idClicked.connect(self._goto_page) layout.addStretch(1) version = QLabel(f"v{APP_VERSION}") version.setObjectName("Hint") version.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(version) return bar def _connect(self) -> None: self.line_received.connect(self._on_line) self.status_received.connect(self._on_status) self.home_page.start_requested.connect(self.start_engine) self.home_page.stop_requested.connect(self.stop_engine) self.home_page.config_changed.connect(self._schedule_save) self.models_page.tier_changed.connect(self._on_tier_changed) self.models_page.config_changed.connect(self._schedule_save) self.subtitle_page.style_changed.connect(self._on_style_changed) self.subtitle_page.overlay_toggled.connect(self._on_overlay_toggled) self.glossary_page.glossary_changed.connect(self._on_glossary_changed) self.settings_page.config_changed.connect(self._schedule_save) self.settings_page.restart_needed.connect(self._on_restart_needed) self.overlay.geometry_changed.connect(lambda *_: self._schedule_save()) self.overlay.closed.connect( lambda: self.subtitle_page.overlay_check.setChecked(False) ) # --- 엔진 ----------------------------------------------------------- def start_engine(self) -> None: if self.config.overlay.visible: self.overlay.show() self.config.save() self.engine.start() def stop_engine(self) -> None: self.engine.stop() self.overlay.clear() self._close_transcript() def _on_tier_changed(self, tier_key: str) -> None: was_running = self.engine.running if was_running: self.engine.stop() self.engine.models.switch_tier(tier_key) if was_running: self.engine.start() def _on_restart_needed(self) -> None: if self.engine.running: self.engine.stop() self.engine.start() # --- 이벤트 --------------------------------------------------------- def _on_line(self, line: TranslationLine) -> None: self.overlay.set_line(line.translated_text, line.source_text, line.is_final) self.home_page.on_line(line) if line.is_final and self.config.log_transcripts: self._write_transcript(line) def _on_status(self, status: EngineStatus) -> None: self.home_page.on_status(status) if status.state is EngineState.ERROR: QMessageBox.warning(self, "오류", status.message) def _on_style_changed(self) -> None: self.overlay.update() self._schedule_save() def _on_overlay_toggled(self, visible: bool) -> None: self.config.overlay.visible = visible self.overlay.setVisible(visible) self._schedule_save() def _on_glossary_changed(self) -> None: self.engine.reload_glossary() self._schedule_save() def _goto_page(self, index: int) -> None: self.stack.setCurrentIndex(index) def _schedule_save(self) -> None: self._save_timer.start() # --- 기록 ----------------------------------------------------------- def _write_transcript(self, line: TranslationLine) -> None: if self._transcript_file is None: folder = user_data_dir() / "transcripts" folder.mkdir(parents=True, exist_ok=True) name = datetime.now().strftime("%Y%m%d-%H%M%S.txt") # 세션 내내 열어두는 핸들이다. _close_transcript() 에서 닫는다. self._transcript_file = open(folder / name, "a", encoding="utf-8") # noqa: SIM115 stamp = datetime.now().strftime("%H:%M:%S") self._transcript_file.write( f"[{stamp}] {line.source_text}\n {line.translated_text}\n" ) self._transcript_file.flush() def _close_transcript(self) -> None: if self._transcript_file is not None: self._transcript_file.close() self._transcript_file = None # --- 종료 ----------------------------------------------------------- def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 self.config.save() self.engine.shutdown() self._close_transcript() self.overlay.close() super().closeEvent(event) def _scrollable(widget: QWidget) -> QScrollArea: area = QScrollArea() area.setWidgetResizable(True) area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) holder = QWidget() layout = QVBoxLayout(holder) layout.setContentsMargins(26, 24, 26, 24) layout.addWidget(widget) area.setWidget(holder) return area