"""메인 컨트롤 창 — 사이드바 + 페이지 스택. 디자인 의도: 자막은 게임 위 오버레이가 전담하고, 이 창은 '설정과 상태'만 담당한다. 그래서 두 창이 서로 방해하지 않는다. """ 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 .hotkeys import GlobalHotkeys 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.hotkeys = GlobalHotkeys(self) self._transcript_file = None self._build_ui() self._connect() self._install_hotkeys() 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.subtitle_page.placement_changed.connect(self._on_placement_changed) 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.settings_page.hotkeys_changed.connect(self._install_hotkeys) self.overlay.geometry_changed.connect(self._on_overlay_moved) self.overlay.visibility_changed.connect(self.subtitle_page.overlay_check.setChecked) self.overlay.closed.connect( lambda: self.subtitle_page.overlay_check.setChecked(False) ) # --- 전역 단축키 ------------------------------------------------------ def _install_hotkeys(self) -> None: """설정에 적힌 단축키를 OS에 등록한다. 변경될 때마다 다시 등록한다.""" self.hotkeys.unregister_all() keys = self.config.hotkeys if not keys.enabled: self.settings_page.set_hotkey_status("전역 단축키를 끈 상태입니다.") return if not self.hotkeys.supported: self.settings_page.set_hotkey_status( "전역 단축키는 Windows에서만 동작합니다. " "이 창이 떠 있을 때는 자막 화면에서 직접 끄고 켤 수 있습니다.", ok=False, ) return registered, failed = [], [] for label, sequence, callback in ( ("자막", keys.toggle_subtitle, self.toggle_subtitle), ("번역", keys.toggle_engine, self.toggle_engine), ): if not sequence: continue if self.hotkeys.register(sequence, callback): registered.append(f"{label} {sequence}") else: failed.append(f"{label} {sequence}") if failed: self.settings_page.set_hotkey_status( f"등록 실패: {', '.join(failed)} — {self.hotkeys.last_error}", ok=False ) else: self.settings_page.set_hotkey_status( f"등록됨: {' · '.join(registered)}" if registered else "등록된 단축키가 없습니다." ) def toggle_subtitle(self) -> None: """자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다.""" visible = self.overlay.toggle_subtitle() self._schedule_save() log.info("자막 %s", "켬" if visible else "끔") def toggle_engine(self) -> None: if self.engine.running: self.stop_engine() else: self.start_engine() # --- 엔진 ----------------------------------------------------------- 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: # 정지 상태에서도 반영해 둬야 저부하 모드를 켠 즉시 VRAM이 반납된다. self.engine.sync_performance() 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: if visible == self.config.overlay.visible: return # 단축키로 바뀐 것을 체크박스가 되돌려 보내는 루프 차단 self.overlay.set_subtitle_visible(visible) self._schedule_save() def _on_placement_changed(self) -> None: """모니터·9분할 위치·여백·클릭통과가 바뀌면 자막 창에 바로 반영.""" self.overlay.apply_config() self.overlay.snap_to_anchor() self.subtitle_page.sync_from_config() self._schedule_save() def _on_overlay_moved(self, *_) -> None: """드래그로 옮기면 '자유 배치'가 되므로 설정 화면 표시를 맞춘다.""" self.subtitle_page.sync_from_config() 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.hotkeys.unregister_all() # OS에 잡아둔 키를 반드시 풀어준다 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