Files
live-app-translator/src/livesub/ui/main_window.py
EJClaw e4a048c8d5 fix: 필수 팩 기본 활성화 + 저부하 모드 토글 반영 + 용어 겹침 수정
리뷰 지적 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>
2026-09-21 18:05:22 +09:00

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