리뷰 지적 2건과, 그것을 고치다 드러난 용어집 버그 1건을 함께 처리했다. 1) 저부하 모드 토글이 모델에 반영되지 않던 버그 ModelManager 는 생성 시점의 low_power 로 정밀도를 정하는데 설정에서 토글해도 갱신되지 않았고, stop() 은 모델을 내리지 않아 이전 정밀도 모델이 그대로 남았다. TranslationEngine.sync_performance() 를 추가해 값이 바뀌면 언로드 후 갱신하고, _bootstrap 과 재시작 경로에서 부른다. 2) 필수 게임 팩이 기본으로 꺼져 있던 문제 새 설치 기본값을 fps-common + 오버워치 + 배그 + R6 + 워독스로 켰다. 3) (1)(2)를 고치며 드러난 것: 팩 여러 개를 켜면 결과가 체크 순서에 휘둘렸다. knocked 가 FPS공통 "기절" / 배그 "눕혔다" 로 갈리는 식으로 19개 용어가 충돌하는데, 사용자는 그 순서를 볼 수도 바꿀 수도 없었다. 팩에 scope(generic/game) 를 두고 범용 -> 게임 전용 -> 사용자 항목 순으로 적용해 결정적으로 만들었다. 게임 팩끼리 갈리는 것은 사람이 판단할 문제라 find_conflicts() 로 뽑아 용어집 화면에 표시한다. 4) 용어 겹침 버그: "the blue"(조각)가 "blue zone" 보다 앞 위치라는 이유로 먼저 잡혀 "자기장 자기장" 이 됐다. 최장일치 정렬은 같은 시작 위치에서만 통한다. 조각 항목을 제거하고, 모든 팩을 켠 상태로 각 용어가 정확히 한 번만 치환되는지 검사하는 테스트를 추가했다. 검증: pytest 110개 통과 (저부하 6 + 팩 계층/충돌/겹침 12 신규), ruff clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""메인 컨트롤 창 — 사이드바 + 페이지 스택.
|
|
|
|
디자인 의도: 자막은 게임 위 오버레이가 전담하고, 이 창은 '설정과 상태'만
|
|
담당한다. 그래서 두 창이 서로 방해하지 않는다.
|
|
"""
|
|
|
|
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
|