feat: LiveSub 2차 — 자막 on/off, 모니터·위치 선택, GPU 저부하, 게임 용어집

이름을 Hearo → LiveSub 로 변경. 말장난보다 하는 일이 바로 보이는 쪽이 낫다.

자막 on/off
- 전역 단축키 Ctrl+Alt+S (자막) / Ctrl+Alt+D (번역) — Windows RegisterHotKey +
  네이티브 이벤트 필터라 게임 창이 떠 있어도 동작. 다른 OS 에서는 no-op
- 단축키·체크박스·우클릭 메뉴가 모두 같은 경로를 타도록 통합

모니터 선택 + 디스코드식 배치
- placement.py: Qt 비의존 배치 계산 (모니터 목록 → 9분할 좌표)
- AnchorGrid 위젯으로 3x3 위치 선택, 드래그하면 자유 배치로 전환
- 모니터 구성이 바뀌어도 자막이 화면 밖으로 사라지지 않도록 클램프

GPU 저부하 모드 (기본 켜짐)
- 연산 정밀도 int8 강등, VRAM 상한 35%, 추론 후 60ms 양보,
  300초 무음 시 모델 언로드, 중간 결과 비활성
- 기본 티어를 균형(6GB) → 신속(4GB) 으로 하향
- 8GB GPU 기준 자막 2.8GB / 게임 5.2GB

게임 용어집 번들 (304개)
- 롤 74 · 발로란트 59 · 오버워치2 49 · FPS공통 46 · 마크 38 · 방송 38
- 체크박스로 켜고 끄며, 사용자가 직접 등록한 항목이 항상 우선

검증: pytest 91개 통과 (배치 14 + 팩 12 + UI 6 신규), ruff clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-09-21 17:49:26 +09:00
parent 5596f905d1
commit 8b36b244ff
60 changed files with 1939 additions and 300 deletions

View File

@@ -0,0 +1,6 @@
"""Qt UI 레이어."""
from .main_window import MainWindow
from .overlay import SubtitleOverlay, SubtitlePreview
__all__ = ["MainWindow", "SubtitleOverlay", "SubtitlePreview"]

173
src/livesub/ui/hotkeys.py Normal file
View File

@@ -0,0 +1,173 @@
"""전역 단축키.
Qt 의 QShortcut 은 우리 창이 포커스를 가졌을 때만 동작한다. 게임을 하는 중에
자막을 껐다 켜야 하므로 OS 레벨 단축키가 필요하다. Windows 에서는
`RegisterHotKey` + 네이티브 이벤트 필터로 구현하고, 다른 OS 에서는 조용히
아무 것도 하지 않는다(창이 떠 있을 때만 쓰는 QShortcut 은 상위에서 따로 건다).
"""
from __future__ import annotations
import logging
import sys
from collections.abc import Callable
from PySide6.QtCore import QAbstractNativeEventFilter, QObject
log = logging.getLogger(__name__)
WM_HOTKEY = 0x0312
MOD_ALT = 0x0001
MOD_CONTROL = 0x0002
MOD_SHIFT = 0x0004
MOD_WIN = 0x0008
MOD_NOREPEAT = 0x4000
_MODIFIERS = {
"ctrl": MOD_CONTROL,
"control": MOD_CONTROL,
"alt": MOD_ALT,
"shift": MOD_SHIFT,
"win": MOD_WIN,
"meta": MOD_WIN,
}
#: 문자/숫자가 아닌 키의 가상 키 코드
_NAMED_KEYS = {
"f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73, "f5": 0x74, "f6": 0x75,
"f7": 0x76, "f8": 0x77, "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
"space": 0x20, "enter": 0x0D, "return": 0x0D, "tab": 0x09, "esc": 0x1B,
"escape": 0x1B, "insert": 0x2D, "delete": 0x2E, "home": 0x24, "end": 0x23,
"pageup": 0x21, "pagedown": 0x22, "up": 0x26, "down": 0x28,
"left": 0x25, "right": 0x27, "`": 0xC0, "-": 0xBD, "=": 0xBB,
}
class HotkeyError(RuntimeError):
"""단축키를 등록할 수 없을 때 (대개 다른 프로그램이 선점)."""
def parse(sequence: str) -> tuple[int, int]:
""""Ctrl+Alt+S" → (수정자 비트마스크, 가상 키 코드)."""
parts = [p.strip().lower() for p in sequence.split("+") if p.strip()]
if not parts:
raise HotkeyError(f"빈 단축키입니다: {sequence!r}")
mods = 0
key_part = ""
for part in parts:
if part in _MODIFIERS:
mods |= _MODIFIERS[part]
else:
key_part = part
if not key_part:
raise HotkeyError(f"조합키만 있고 실제 키가 없습니다: {sequence!r}")
if not mods:
# 수정자 없는 전역 단축키는 게임 입력을 통째로 가로채므로 막는다.
raise HotkeyError(f"Ctrl/Alt/Shift 중 하나는 있어야 합니다: {sequence!r}")
if key_part in _NAMED_KEYS:
vk = _NAMED_KEYS[key_part]
elif len(key_part) == 1 and key_part.isalnum():
vk = ord(key_part.upper())
else:
raise HotkeyError(f"알 수 없는 키입니다: {key_part!r}")
return mods | MOD_NOREPEAT, vk
class GlobalHotkeys(QObject, QAbstractNativeEventFilter):
"""전역 단축키 묶음. Windows 전용이며 그 외에서는 no-op."""
def __init__(self, parent: QObject | None = None) -> None:
QObject.__init__(self, parent)
QAbstractNativeEventFilter.__init__(self)
self._callbacks: dict[int, Callable[[], None]] = {}
self._next_id = 1
self._installed = False
self._last_error = ""
@property
def supported(self) -> bool:
return sys.platform == "win32"
@property
def last_error(self) -> str:
return self._last_error
def register(self, sequence: str, callback: Callable[[], None]) -> bool:
"""단축키 하나를 등록한다. 성공하면 True."""
if not self.supported:
self._last_error = "전역 단축키는 Windows에서만 지원합니다."
return False
try:
mods, vk = parse(sequence)
except HotkeyError as exc:
self._last_error = str(exc)
log.warning("단축키 해석 실패: %s", exc)
return False
import ctypes
hotkey_id = self._next_id
if not ctypes.windll.user32.RegisterHotKey(None, hotkey_id, mods, vk):
self._last_error = (
f"'{sequence}' 를 등록하지 못했습니다. 다른 프로그램이 쓰고 있을 수 있습니다."
)
log.warning(self._last_error)
return False
self._callbacks[hotkey_id] = callback
self._next_id += 1
self._ensure_filter()
log.info("전역 단축키 등록: %s", sequence)
return True
def unregister_all(self) -> None:
if not self.supported or not self._callbacks:
return
import ctypes
from PySide6.QtWidgets import QApplication
for hotkey_id in self._callbacks:
ctypes.windll.user32.UnregisterHotKey(None, hotkey_id)
self._callbacks.clear()
if self._installed:
app = QApplication.instance()
if app is not None:
app.removeNativeEventFilter(self)
self._installed = False
def _ensure_filter(self) -> None:
if self._installed:
return
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is not None:
app.installNativeEventFilter(self)
self._installed = True
def nativeEventFilter(self, event_type, message): # noqa: N802 - Qt 규약
if not self._callbacks or event_type not in (b"windows_generic_MSG",
b"windows_dispatcher_MSG"):
return False, 0
import ctypes
import ctypes.wintypes
msg = ctypes.cast(int(message), ctypes.POINTER(ctypes.wintypes.MSG)).contents
if msg.message != WM_HOTKEY:
return False, 0
callback = self._callbacks.get(int(msg.wParam))
if callback is None:
return False, 0
try:
callback()
except Exception: # noqa: BLE001 - 콜백 오류로 이벤트 루프가 죽으면 안 된다
log.exception("단축키 처리 실패")
return True, 0

View File

@@ -0,0 +1,327 @@
"""메인 컨트롤 창 — 사이드바 + 페이지 스택.
디자인 의도: 자막은 게임 위 오버레이가 전담하고, 이 창은 '설정과 상태'만
담당한다. 그래서 두 창이 서로 방해하지 않는다.
"""
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:
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

385
src/livesub/ui/overlay.py Normal file
View File

@@ -0,0 +1,385 @@
"""자막 오버레이 창.
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
조작:
* 드래그 — 위치 이동
* 우하단 모서리 — 크기 조절
* 휠 — 글자 크기 조절
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
"""
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, Qt, QTimer, Signal
from PySide6.QtGui import (
QAction,
QColor,
QFont,
QFontMetrics,
QPainter,
QPainterPath,
QPen,
)
from PySide6.QtWidgets import QMenu, QWidget
from ..config import OverlayConfig, SubtitleStyle
from .placement import ANCHOR_CUSTOM, list_screens, pick_screen, resolve_rect, screen_for_point
GRIP = 18 # 우하단 리사이즈 핸들 크기
def paint_subtitle(
painter: QPainter,
rect: QRect,
st: SubtitleStyle,
translated: str,
source: str = "",
partial: str = "",
) -> None:
"""자막 한 화면을 그린다. 오버레이와 설정 미리보기가 이 함수를 공유한다."""
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
if st.background_opacity > 0:
bg = QColor(st.background_color)
bg.setAlpha(int(255 * st.background_opacity / 100))
painter.setBrush(bg)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(rect, 12, 12)
rows: list[tuple[str, QColor, int]] = []
if st.show_source and source and translated:
rows.append((source, QColor(st.source_text_color), int(st.font_size * 0.62)))
main = translated or partial
if main:
color = QColor(st.text_color)
if not translated: # 아직 확정 전 — 흐리게
color.setAlpha(170)
rows.append((main, color, st.font_size))
if not rows:
return
margin = 18
available = rect.width() - margin * 2
blocks: list[tuple[list[str], QColor, QFont]] = []
total_h = 0
for text, color, size in rows:
font = QFont(st.font_family, size)
font.setBold(st.bold)
lines = _wrap(text, QFontMetrics(font), available, st.max_lines)
blocks.append((lines, color, font))
total_h += len(lines) * (QFontMetrics(font).height() + st.line_spacing)
y = rect.y() + max(margin, (rect.height() - total_h) // 2)
for lines, color, font in blocks:
painter.setFont(font)
fm = QFontMetrics(font)
for line in lines:
width = fm.horizontalAdvance(line)
if st.align == "left":
x = rect.x() + margin
elif st.align == "right":
x = rect.right() - margin - width
else:
x = rect.x() + (rect.width() - width) // 2
_draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st)
y += fm.height() + st.line_spacing
def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None:
"""외곽선 있는 글자.
스트로크는 글자 윤곽선 '가운데'에 그려지므로 절반이 글자 안쪽을 파먹는다.
한글처럼 획이 얇은 글꼴은 그대로 두면 글자가 통째로 외곽선 색이 된다.
그래서 스트로크를 먼저 깔고 그 위에 글자를 채우는 2패스로 그린다.
"""
if st.outline_width <= 0:
painter.setPen(color)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawText(x, y, text)
return
path = QPainterPath()
path.addText(x, y, font, text)
pen = QPen(QColor(st.outline_color))
pen.setWidth(st.outline_width * 2) # 절반이 안쪽으로 먹히므로 2배
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawPath(path)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawPath(path)
class SubtitlePreview(QWidget):
"""설정 화면에서 쓰는 미리보기. 실제 자막과 같은 코드로 그린다."""
def __init__(self, style: SubtitleStyle, parent: QWidget | None = None):
super().__init__(parent)
self.style_cfg = style
self.translated = ""
self.source = ""
self.setMinimumHeight(140)
def set_sample(self, translated: str, source: str) -> None:
self.translated = translated
self.source = source
self.update()
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
paint_subtitle(
painter, self.rect(), self.style_cfg, self.translated, self.source
)
class SubtitleOverlay(QWidget):
"""항상 위에 떠 있는 자막 창."""
geometry_changed = Signal(int, int, int, int)
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
visibility_changed = Signal(bool)
closed = Signal()
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
super().__init__(None)
self.style_cfg = style
self.overlay_cfg = overlay
self._final_text = ""
self._partial_text = ""
self._source_text = ""
self._drag_from: QPoint | None = None
self._resize_from: QRect | None = None
self._fade = QTimer(self)
self._fade.setSingleShot(True)
self._fade.timeout.connect(self._clear_text)
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint
| Qt.WindowType.Tool
| Qt.WindowType.WindowStaysOnTopHint
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setMinimumSize(320, 90)
self.apply_config()
# --- 외부 API -------------------------------------------------------
def apply_config(self) -> None:
cfg = self.overlay_cfg
flags = self.windowFlags()
flags = (
flags | Qt.WindowType.WindowStaysOnTopHint
if cfg.always_on_top
else flags & ~Qt.WindowType.WindowStaysOnTopHint
)
self.setWindowFlags(flags)
self.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through
)
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
if cfg.anchor != ANCHOR_CUSTOM or cfg.x < 0 or cfg.y < 0:
self.snap_to_anchor(save_only=True)
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
self.setVisible(cfg.visible)
self.update()
def snap_to_anchor(self, anchor: str | None = None, save_only: bool = False) -> None:
"""선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식)."""
cfg = self.overlay_cfg
if anchor is not None:
cfg.anchor = anchor
if cfg.anchor == ANCHOR_CUSTOM:
return
screens = list_screens()
screen = pick_screen(screens, cfg.screen_index)
if screen is None:
return
cfg.screen_index = screen.index
cfg.x, cfg.y, cfg.width, cfg.height = resolve_rect(
screen, cfg.anchor, cfg.width, cfg.height, cfg.margin
)
if not save_only:
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
self.geometry_changed.emit(cfg.x, cfg.y, cfg.width, cfg.height)
def set_subtitle_visible(self, visible: bool) -> None:
"""자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다."""
self.overlay_cfg.visible = visible
self.setVisible(visible)
if not visible:
self._clear_text()
self.visibility_changed.emit(visible)
def toggle_subtitle(self) -> bool:
self.set_subtitle_visible(not self.overlay_cfg.visible)
return self.overlay_cfg.visible
def set_line(self, translated: str, source: str = "", is_final: bool = True) -> None:
if is_final:
self._final_text = translated or source
self._partial_text = ""
self._source_text = source
else:
self._partial_text = source
self._restart_fade()
self.update()
def clear(self) -> None:
self._clear_text()
# --- 그리기 ---------------------------------------------------------
def paintEvent(self, event) -> None: # noqa: N802 - Qt 규약
painter = QPainter(self)
paint_subtitle(
painter,
self.rect(),
self.style_cfg,
self._final_text,
self._source_text,
self._partial_text,
)
if not (self._final_text or self._partial_text) and not self.overlay_cfg.locked:
self._draw_placeholder(painter)
self._draw_grip(painter)
def _draw_placeholder(self, painter) -> None:
painter.setPen(QColor(255, 255, 255, 90))
font = QFont(self.style_cfg.font_family, 13)
painter.setFont(font)
painter.drawText(
self.rect(),
Qt.AlignmentFlag.AlignCenter,
"자막이 여기에 표시됩니다\n드래그로 이동 · 휠로 크기 · 우클릭으로 메뉴",
)
def _draw_grip(self, painter) -> None:
if self.overlay_cfg.locked or self.overlay_cfg.click_through:
return
painter.setPen(QPen(QColor(255, 255, 255, 110), 2))
w, h = self.width(), self.height()
for offset in (5, 10, 15):
painter.drawLine(w - offset, h - 4, w - 4, h - offset)
# --- 마우스 ---------------------------------------------------------
def mousePressEvent(self, event) -> None: # noqa: N802
if self.overlay_cfg.locked or event.button() != Qt.MouseButton.LeftButton:
return
pos = event.position().toPoint()
if pos.x() > self.width() - GRIP and pos.y() > self.height() - GRIP:
self._resize_from = QRect(event.globalPosition().toPoint(), self.size())
else:
self._drag_from = event.globalPosition().toPoint() - self.pos()
def mouseMoveEvent(self, event) -> None: # noqa: N802
if self._resize_from is not None:
delta = event.globalPosition().toPoint() - self._resize_from.topLeft()
self.resize(
max(self.minimumWidth(), self._resize_from.width() + delta.x()),
max(self.minimumHeight(), self._resize_from.height() + delta.y()),
)
elif self._drag_from is not None:
self.move(event.globalPosition().toPoint() - self._drag_from)
def mouseReleaseEvent(self, event) -> None: # noqa: N802
if self._drag_from is None and self._resize_from is None:
return
self._drag_from = None
self._resize_from = None
cfg = self.overlay_cfg
g = self.geometry()
cfg.x, cfg.y = g.x(), g.y()
cfg.width, cfg.height = g.width(), g.height()
# 손으로 옮긴 순간부터는 앵커가 아니라 그 자리를 그대로 지킨다.
cfg.anchor = ANCHOR_CUSTOM
# 다른 모니터로 끌고 갔을 수 있으므로 소속을 다시 찾는다.
owner = screen_for_point(list_screens(), g.center().x(), g.center().y())
if owner is not None:
cfg.screen_index = owner.index
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
def wheelEvent(self, event) -> None: # noqa: N802
if self.overlay_cfg.locked:
return
step = 2 if event.angleDelta().y() > 0 else -2
self.style_cfg.font_size = max(12, min(120, self.style_cfg.font_size + step))
self.update()
def contextMenuEvent(self, event) -> None: # noqa: N802
menu = QMenu(self)
cfg = self.overlay_cfg
lock = QAction("위치 잠금", self, checkable=True, checked=cfg.locked)
lock.toggled.connect(self._set_locked)
menu.addAction(lock)
through = QAction("클릭 통과", self, checkable=True, checked=cfg.click_through)
through.toggled.connect(self._set_click_through)
menu.addAction(through)
top = QAction("항상 위에", self, checkable=True, checked=cfg.always_on_top)
top.toggled.connect(self._set_always_on_top)
menu.addAction(top)
menu.addSeparator()
menu.addAction("자막 지우기", self.clear)
menu.addAction("자막 창 숨기기", self.hide_overlay)
menu.exec(event.globalPos())
# --- 메뉴 동작 ------------------------------------------------------
def _set_locked(self, value: bool) -> None:
self.overlay_cfg.locked = value
self.update()
def _set_click_through(self, value: bool) -> None:
self.overlay_cfg.click_through = value
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, value)
self.update()
def _set_always_on_top(self, value: bool) -> None:
self.overlay_cfg.always_on_top = value
self.apply_config()
def hide_overlay(self) -> None:
self.set_subtitle_visible(False)
self.closed.emit()
def _restart_fade(self) -> None:
self._fade.stop()
if self.style_cfg.fade_out_ms > 0:
self._fade.start(self.style_cfg.fade_out_ms)
def _clear_text(self) -> None:
self._final_text = ""
self._partial_text = ""
self._source_text = ""
self.update()
def _wrap(text: str, fm: QFontMetrics, width: int, max_lines: int) -> list[str]:
"""단어 단위 줄바꿈. 마지막 줄이 넘치면 앞부분을 버리고 최신 내용을 남긴다."""
if width <= 0 or not text:
return [text] if text else []
lines: list[str] = []
current = ""
for word in text.split():
candidate = f"{current} {word}".strip()
if fm.horizontalAdvance(candidate) <= width or not current:
current = candidate
else:
lines.append(current)
current = word
if current:
lines.append(current)
# 실시간 자막에서는 앞이 아니라 뒤(최신)를 보여주는 게 맞다.
return lines[-max_lines:] if max_lines > 0 else lines

View File

@@ -0,0 +1,7 @@
from .glossary_page import GlossaryPage
from .home import HomePage
from .models_page import ModelsPage
from .settings_page import SettingsPage
from .subtitle_page import SubtitlePage
__all__ = ["GlossaryPage", "HomePage", "ModelsPage", "SettingsPage", "SubtitlePage"]

View File

@@ -0,0 +1,297 @@
"""용어집 — 게임·방송 고유명사를 원하는 번역으로 고정한다."""
from __future__ import annotations
import csv
from pathlib import Path
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QAbstractItemView,
QCheckBox,
QFileDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ...constants import LANGUAGE_CODES, LANGUAGES, user_data_dir
from ...models import Glossary, GlossaryEntry
from ...models.packs import available_packs
from ..theme import SPACING
from ..widgets import Card, PageHeader
COLUMNS = ["원문 용어", *[f"{LANGUAGES[c]['label']} 역어" for c in LANGUAGE_CODES], "메모"]
class GlossaryPage(QWidget):
glossary_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
self.path = Path(config.glossary.path or user_data_dir() / "glossary.json")
self.glossary = Glossary.load(self.path, config.glossary.case_sensitive)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader(
"용어집",
"여기 등록한 단어는 번역 모델이 마음대로 바꾸지 못하고 지정한 역어로 고정됩니다. "
"모델을 추가로 학습시키지 않아도 바로 적용됩니다.",
)
)
root.addWidget(self._build_packs_card())
root.addWidget(self._build_options_card())
root.addWidget(self._build_table_card(), 1)
self._reload_table()
# --- 기본 제공 팩 ----------------------------------------------------
def _build_packs_card(self) -> Card:
card = Card(
"게임별 기본 용어집",
"체크만 하면 바로 적용됩니다. 역어가 마음에 안 들면 아래 표에 같은 "
"단어를 등록하세요 — 직접 등록한 쪽이 항상 이깁니다.",
)
self._pack_checks: dict[str, QCheckBox] = {}
enabled = set(self.config.glossary.enabled_packs)
packs = available_packs()
if not packs:
card.add(_hint("기본 제공 용어집을 찾지 못했습니다."))
return card
for pack in packs:
check = QCheckBox(f"{pack.name} ({len(pack)}개)")
check.setChecked(pack.key in enabled)
check.setToolTip(pack.description)
check.toggled.connect(self._on_packs_changed)
self._pack_checks[pack.key] = check
card.add(check)
self.pack_total = card.add(_hint(""))
self._update_pack_total()
return card
def _on_packs_changed(self, *_) -> None:
self.config.glossary.enabled_packs = [
key for key, check in self._pack_checks.items() if check.isChecked()
]
self._update_pack_total()
self.glossary_changed.emit()
def _update_pack_total(self) -> None:
enabled = set(self.config.glossary.enabled_packs)
total = sum(len(p) for p in available_packs() if p.key in enabled)
self.pack_total.setText(f"켜둔 기본 용어 {total}개")
# --- 구성 -----------------------------------------------------------
def _build_options_card(self) -> Card:
card = Card()
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self.enabled_check = QCheckBox("용어집 사용")
self.enabled_check.setChecked(self.config.glossary.enabled)
self.enabled_check.toggled.connect(self._on_enabled)
layout.addWidget(self.enabled_check)
self.case_check = QCheckBox("대소문자 구분")
self.case_check.setChecked(self.config.glossary.case_sensitive)
self.case_check.toggled.connect(self._on_case)
layout.addWidget(self.case_check)
layout.addStretch(1)
for label, slot in (
("CSV 가져오기", self._import_csv),
("CSV 내보내기", self._export_csv),
):
button = QPushButton(label)
button.clicked.connect(slot)
layout.addWidget(button)
card.add(row)
return card
def _build_table_card(self) -> Card:
card = Card(
"등록된 용어",
"역어를 비워두면 그 언어에서는 규칙을 적용하지 않습니다. "
"CSV 형식: 원문,한국어,English,日本語,中文,메모",
)
search_row = QWidget()
search_layout = QHBoxLayout(search_row)
search_layout.setContentsMargins(0, 0, 0, 0)
search_layout.setSpacing(10)
self.search = QLineEdit()
self.search.setPlaceholderText("용어 검색…")
self.search.textChanged.connect(self._filter)
search_layout.addWidget(self.search, 1)
for label, slot in (("행 추가", self._add_row), ("선택 삭제", self._delete_rows)):
button = QPushButton(label)
button.clicked.connect(slot)
search_layout.addWidget(button)
card.add(search_row)
self.table = QTableWidget(0, len(COLUMNS))
self.table.setHorizontalHeaderLabels(COLUMNS)
self.table.verticalHeader().setVisible(False)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.table.itemChanged.connect(self._on_item_changed)
card.add(self.table)
self.count_label = card.add(
_hint(f"{len(self.glossary)}개 등록됨")
)
return card
# --- 데이터 ---------------------------------------------------------
def _reload_table(self) -> None:
self.table.blockSignals(True)
self.table.setRowCount(0)
for entry in self.glossary.entries:
self._append_row(entry)
self.table.blockSignals(False)
self.count_label.setText(f"{len(self.glossary)}개 등록됨")
def _append_row(self, entry: GlossaryEntry) -> None:
row = self.table.rowCount()
self.table.insertRow(row)
values = [entry.source, *[entry.target_for(c) for c in LANGUAGE_CODES], entry.note]
for col, value in enumerate(values):
self.table.setItem(row, col, QTableWidgetItem(value))
def _collect(self) -> list[GlossaryEntry]:
entries: list[GlossaryEntry] = []
for row in range(self.table.rowCount()):
source_item = self.table.item(row, 0)
source = source_item.text().strip() if source_item else ""
if not source:
continue
targets: dict[str, str] = {}
for i, code in enumerate(LANGUAGE_CODES, start=1):
item = self.table.item(row, i)
text = item.text().strip() if item else ""
if text:
targets[code] = text
note_item = self.table.item(row, len(COLUMNS) - 1)
entries.append(
GlossaryEntry(
source=source,
targets=targets,
note=note_item.text().strip() if note_item else "",
)
)
return entries
def _save(self) -> None:
self.glossary = Glossary(self._collect(), self.config.glossary.case_sensitive)
self.glossary.save(self.path)
self.count_label.setText(f"{len(self.glossary)}개 등록됨")
self.glossary_changed.emit()
# --- 동작 -----------------------------------------------------------
def _on_item_changed(self, *_) -> None:
self._save()
def _on_enabled(self, value: bool) -> None:
self.config.glossary.enabled = value
self.glossary_changed.emit()
def _on_case(self, value: bool) -> None:
self.config.glossary.case_sensitive = value
self._save()
def _add_row(self) -> None:
self.table.blockSignals(True)
self._append_row(GlossaryEntry(source=""))
self.table.blockSignals(False)
self.table.editItem(self.table.item(self.table.rowCount() - 1, 0))
def _delete_rows(self) -> None:
rows = sorted({i.row() for i in self.table.selectedIndexes()}, reverse=True)
if not rows:
return
self.table.blockSignals(True)
for row in rows:
self.table.removeRow(row)
self.table.blockSignals(False)
self._save()
def _filter(self, text: str) -> None:
needle = text.strip().casefold()
for row in range(self.table.rowCount()):
haystack = " ".join(
(self.table.item(row, c).text() if self.table.item(row, c) else "")
for c in range(self.table.columnCount())
).casefold()
self.table.setRowHidden(row, bool(needle) and needle not in haystack)
def _import_csv(self) -> None:
path, _ = QFileDialog.getOpenFileName(self, "CSV 가져오기", "", "CSV (*.csv)")
if not path:
return
try:
with open(path, encoding="utf-8-sig", newline="") as fh:
rows = [r for r in csv.reader(fh) if r and r[0].strip()]
except OSError as exc:
QMessageBox.warning(self, "가져오기 실패", str(exc))
return
# 첫 줄이 헤더처럼 보이면 건너뛴다.
if rows and rows[0][0].strip() in ("원문", "원문 용어", "source"):
rows = rows[1:]
added = 0
for row in rows:
cells = [c.strip() for c in row] + [""] * (len(COLUMNS) - len(row))
targets = {
code: cells[i]
for i, code in enumerate(LANGUAGE_CODES, start=1)
if cells[i]
}
self.glossary.add(
GlossaryEntry(source=cells[0], targets=targets, note=cells[len(COLUMNS) - 1])
)
added += 1
self.glossary.save(self.path)
self._reload_table()
self.glossary_changed.emit()
QMessageBox.information(self, "가져오기 완료", f"{added}개 용어를 반영했습니다.")
def _export_csv(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self, "CSV 내보내기", "glossary.csv", "CSV (*.csv)"
)
if not path:
return
with open(path, "w", encoding="utf-8-sig", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(COLUMNS)
for entry in self.glossary.entries:
writer.writerow(
[entry.source, *[entry.target_for(c) for c in LANGUAGE_CODES], entry.note]
)
def _hint(text: str) -> QLabel:
label = QLabel(text)
label.setObjectName("Hint")
label.setAlignment(Qt.AlignmentFlag.AlignRight)
return label

View File

@@ -0,0 +1,226 @@
"""홈 — 대상 선택, 언어, 시작/정지, 실시간 로그."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QVBoxLayout,
QWidget,
)
from ...audio import capture_capabilities, list_sources
from ...config import AppConfig
from ...constants import LANGUAGES
from ...core.events import EngineState, EngineStatus, TranslationLine
from ..theme import SPACING, palette
from ..widgets import Card, LevelMeter, PageHeader, StatusPill
MAX_LOG_ROWS = 300
class HomePage(QWidget):
start_requested = Signal()
stop_requested = Signal()
config_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
self._sources: list = []
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader("실시간 번역", "소리를 받아올 프로그램과 번역할 언어를 고르세요.")
)
root.addWidget(self._build_source_card())
root.addWidget(self._build_control_card())
root.addWidget(self._build_log_card(), 1)
self.refresh_sources()
# --- 구성 -----------------------------------------------------------
def _build_source_card(self) -> Card:
card = Card("소리 받아올 곳", "")
self.capability_label = QLabel()
self.capability_label.setObjectName("Hint")
self.capability_label.setWordWrap(True)
card.add(self.capability_label)
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self.source_combo = QComboBox()
self.source_combo.setMinimumWidth(360)
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
layout.addWidget(self.source_combo, 1)
refresh = QPushButton("새로고침")
refresh.clicked.connect(self.refresh_sources)
layout.addWidget(refresh)
card.add(row)
return card
def _build_control_card(self) -> Card:
card = Card()
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
self.source_lang = QComboBox()
self.source_lang.addItem("자동 감지", "auto")
for code, meta in LANGUAGES.items():
self.source_lang.addItem(meta["label"], code)
self.source_lang.setCurrentIndex(
max(0, self.source_lang.findData(self.config.models.source_lang))
)
self.source_lang.currentIndexChanged.connect(self._on_lang_changed)
self.target_lang = QComboBox()
for code, meta in LANGUAGES.items():
self.target_lang.addItem(meta["label"], code)
self.target_lang.setCurrentIndex(
max(0, self.target_lang.findData(self.config.models.target_lang))
)
self.target_lang.currentIndexChanged.connect(self._on_lang_changed)
layout.addWidget(QLabel("원본"))
layout.addWidget(self.source_lang)
arrow = QLabel("→")
arrow.setStyleSheet(f"color: {palette('dark').accent}; font-size: 18px;")
layout.addWidget(arrow)
layout.addWidget(QLabel("번역"))
layout.addWidget(self.target_lang)
layout.addStretch(1)
self.status_pill = StatusPill()
layout.addWidget(self.status_pill)
self.toggle_button = QPushButton("번역 시작")
self.toggle_button.setObjectName("Primary")
self.toggle_button.setMinimumWidth(140)
self.toggle_button.clicked.connect(self._on_toggle)
layout.addWidget(self.toggle_button)
card.add(row)
self.level_meter = LevelMeter()
card.add(self.level_meter)
return card
def _build_log_card(self) -> Card:
card = Card("최근 자막", "확정된 문장이 위로 쌓입니다. 지연 시간도 함께 보여줍니다.")
self.log_list = QListWidget()
self.log_list.setWordWrap(True)
self.log_list.setAlternatingRowColors(False)
card.add(self.log_list)
return card
# --- 동작 -----------------------------------------------------------
def refresh_sources(self) -> None:
caps = capture_capabilities()
self._sources = list_sources()
self.source_combo.blockSignals(True)
self.source_combo.clear()
for src in self._sources:
prefix = "🎮" if src.kind == "process" else "🔊"
self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src)
if not self._sources:
self.source_combo.addItem("사용 가능한 오디오 대상이 없습니다", None)
self._select_saved_source()
self.source_combo.blockSignals(False)
self._on_source_changed()
if caps["process"]:
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
elif caps["device"]:
msg = (
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
"프로그램 단위로 분리됩니다."
)
else:
msg = "이 환경에서는 오디오 캡처를 할 수 없습니다. Windows에서 실행하세요."
self.capability_label.setText(msg)
def _select_saved_source(self) -> None:
cfg = self.config.audio
for i, src in enumerate(self._sources):
if cfg.backend == "process" and src.kind == "process" and src.pid == cfg.target_pid:
self.source_combo.setCurrentIndex(i)
return
if cfg.backend == "device" and src.kind == "device" and src.device_index == cfg.device_index:
self.source_combo.setCurrentIndex(i)
return
def _on_source_changed(self, *_) -> None:
src = self.source_combo.currentData()
cfg = self.config.audio
if src is None:
return
if src.kind == "process":
cfg.backend = "process"
cfg.target_pid = src.pid
cfg.target_process_name = src.label
else:
cfg.backend = "device"
cfg.device_index = src.device_index
cfg.target_pid = 0
self.config_changed.emit()
def _on_lang_changed(self, *_) -> None:
self.config.models.source_lang = self.source_lang.currentData()
self.config.models.target_lang = self.target_lang.currentData()
self.config_changed.emit()
def _on_toggle(self) -> None:
if self.toggle_button.property("running"):
self.stop_requested.emit()
else:
self.start_requested.emit()
# --- 엔진 연동 ------------------------------------------------------
def on_status(self, status: EngineStatus) -> None:
running = status.state in (EngineState.RUNNING, EngineState.LOADING)
self.toggle_button.setProperty("running", running)
self.toggle_button.setText("번역 정지" if running else "번역 시작")
self.toggle_button.setObjectName("Danger" if running else "Primary")
self.toggle_button.style().polish(self.toggle_button)
kind = {
EngineState.RUNNING: "ok",
EngineState.LOADING: "busy",
EngineState.STOPPING: "busy",
EngineState.ERROR: "error",
}.get(status.state, "idle")
self.status_pill.set_status(status.message or status.state.value, kind)
self.level_meter.set_level(status.level)
for widget in (self.source_combo, self.source_lang, self.target_lang):
widget.setEnabled(not running)
def on_line(self, line: TranslationLine) -> None:
if not line.is_final:
return
item = QListWidgetItem(
f"{line.translated_text}\n"
f" {line.source_text} · {line.latency_s:.1f}초 "
f"(인식 {line.asr_ms}ms · 번역 {line.mt_ms}ms)"
)
item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
item.setToolTip(line.source_text)
self.log_list.insertItem(0, item)
while self.log_list.count() > MAX_LOG_ROWS:
self.log_list.takeItem(self.log_list.count() - 1)
self.log_list.scrollToTop()

View File

@@ -0,0 +1,221 @@
"""모델 — 5단계 품질 티어 선택과 GPU 상태."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ...models import Tier, detect_gpu, ordered_tiers
from ..theme import SPACING, palette
from ..widgets import Card, PageHeader
class TierCard(QFrame):
"""티어 하나를 라디오 버튼 카드로."""
def __init__(self, tier: Tier, gpu_vram_gb: float, parent: QWidget | None = None):
super().__init__(parent)
self.tier = tier
self.setObjectName("Card")
p = palette("dark")
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 14, 16, 14)
layout.setSpacing(14)
self.radio = QRadioButton()
layout.addWidget(self.radio, 0, Qt.AlignmentFlag.AlignTop)
text = QVBoxLayout()
text.setSpacing(3)
head = QHBoxLayout()
head.setSpacing(8)
name = QLabel(f"{tier.order}. {tier.name}")
name.setStyleSheet("font-size: 16px; font-weight: 700;")
head.addWidget(name)
if tier.recommended:
head.addWidget(_badge("추천", p.accent))
if tier.best_after_finetune:
head.addWidget(_badge("추가학습 최적", p.success))
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
head.addWidget(_badge(f"VRAM {tier.min_vram_gb:g}GB 필요", p.warning))
head.addStretch(1)
text.addLayout(head)
tagline = QLabel(tier.tagline)
tagline.setObjectName("Subtitle")
text.addWidget(tagline)
spec = QLabel(
f"예상 지연 {tier.approx_latency_s:g}초 · 권장 VRAM {tier.min_vram_gb:g}GB "
f"· 내려받기 약 {tier.download_gb:g}GB"
)
spec.setObjectName("Hint")
text.addWidget(spec)
if tier.notes:
note = QLabel(tier.notes)
note.setObjectName("Hint")
note.setWordWrap(True)
text.addWidget(note)
layout.addLayout(text, 1)
def set_selected(self, selected: bool) -> None:
self.setObjectName("CardSelected" if selected else "Card")
self.style().unpolish(self)
self.style().polish(self)
class ModelsPage(QWidget):
tier_changed = Signal(str)
config_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
self._cards: list[TierCard] = []
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader(
"모델",
"속도 우선(1)부터 품질 우선(5)까지 다섯 단계입니다. "
"모델은 처음 사용할 때 자동으로 내려받습니다.",
)
)
root.addWidget(self._build_gpu_card())
self._group = QButtonGroup(self)
self._group.setExclusive(True)
for tier in ordered_tiers():
card = TierCard(tier, self._gpu_vram_gb)
self._group.addButton(card.radio, tier.order)
card.radio.toggled.connect(
lambda checked, t=tier: self._on_tier_selected(t) if checked else None
)
self._cards.append(card)
root.addWidget(card)
root.addWidget(self._build_lora_card())
root.addStretch(1)
self._apply_selection(config.models.tier)
# --- 구성 -----------------------------------------------------------
def _build_gpu_card(self) -> Card:
gpu = detect_gpu()
self._gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
p = palette("dark")
card = Card("실행 환경")
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
if gpu.available:
text = (
f"GPU: {gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB "
f"(여유 {gpu.free_vram_mb / 1024:.1f}GB)"
)
color = p.success
else:
text = f"GPU를 쓸 수 없습니다 — {gpu.reason} (CPU로도 동작하지만 많이 느립니다)"
color = p.warning
info = QLabel(text)
info.setStyleSheet(f"color: {color}; font-weight: 600;")
info.setWordWrap(True)
layout.addWidget(info, 1)
self.device_combo = QComboBox()
self.device_combo.addItem("GPU (CUDA)", "cuda")
self.device_combo.addItem("CPU", "cpu")
self.device_combo.setCurrentIndex(
max(0, self.device_combo.findData(self.config.models.device))
)
self.device_combo.setEnabled(gpu.available)
self.device_combo.currentIndexChanged.connect(self._on_device_changed)
layout.addWidget(self.device_combo)
card.add(row)
return card
def _build_lora_card(self) -> Card:
card = Card(
"추가학습 어댑터 (선택)",
"게임·방송 용어로 따로 학습시킨 LoRA 어댑터 폴더를 지정하면 번역 모델에 얹습니다. "
"scripts/finetune_mt.py 로 만들 수 있습니다.",
)
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self.lora_edit = QLineEdit(self.config.models.lora_adapter_path)
self.lora_edit.setPlaceholderText("비워두면 사용하지 않습니다")
self.lora_edit.editingFinished.connect(self._on_lora_changed)
layout.addWidget(self.lora_edit, 1)
browse = QPushButton("폴더 선택")
browse.clicked.connect(self._browse_lora)
layout.addWidget(browse)
card.add(row)
return card
# --- 동작 -----------------------------------------------------------
def _apply_selection(self, tier_key: str) -> None:
for card in self._cards:
selected = card.tier.key == tier_key
card.radio.setChecked(selected)
card.set_selected(selected)
def _on_tier_selected(self, tier: Tier) -> None:
if self.config.models.tier == tier.key:
return
self.config.models.tier = tier.key
for card in self._cards:
card.set_selected(card.tier.key == tier.key)
self.tier_changed.emit(tier.key)
self.config_changed.emit()
def _on_device_changed(self) -> None:
self.config.models.device = self.device_combo.currentData()
self.config_changed.emit()
def _on_lora_changed(self) -> None:
self.config.models.lora_adapter_path = self.lora_edit.text().strip()
self.config_changed.emit()
def _browse_lora(self) -> None:
from PySide6.QtWidgets import QFileDialog
path = QFileDialog.getExistingDirectory(self, "LoRA 어댑터 폴더 선택")
if path:
self.lora_edit.setText(path)
self._on_lora_changed()
def _badge(text: str, color: str) -> QLabel:
label = QLabel(text)
label.setStyleSheet(
f"color: {color}; border: 1px solid {color}; border-radius: 9px;"
f"padding: 1px 8px; font-size: 11px; font-weight: 700;"
)
return label

View File

@@ -0,0 +1,324 @@
"""설정 — 인식 민감도, 응답 속도, 기타 동작."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSlider,
QSpinBox,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ...models import detect_gpu
from ..theme import SPACING, palette
from ..widgets import Card, PageHeader
class SettingsPage(QWidget):
config_changed = Signal()
#: 오디오 파이프라인 설정이 바뀌어 엔진 재시작이 필요할 때
restart_needed = Signal()
#: 전역 단축키를 다시 등록해야 할 때
hotkeys_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader("설정", "게임과 같이 돌릴 때의 GPU 부담, 단축키, 인식 민감도를 조절합니다.")
)
root.addWidget(self._build_performance_card())
root.addWidget(self._build_hotkey_card())
root.addWidget(self._build_audio_card())
root.addWidget(self._build_behavior_card())
root.addWidget(self._build_about_card())
root.addStretch(1)
# --- GPU 부담 --------------------------------------------------------
def _build_performance_card(self) -> Card:
perf = self.config.performance
card = Card(
"게임과 같이 쓰기 (GPU 절약)",
"게임이 먼저입니다. 이 모드를 켜면 자막 품질을 조금 내주는 대신 "
"게임 프레임과 VRAM을 지킵니다.",
)
form = card.add_form()
self.game_mode_check = QCheckBox("저부하 모드 (권장)")
self.game_mode_check.setChecked(perf.game_mode)
self.game_mode_check.setToolTip(
"연산 정밀도를 int8로 낮추고, 추론 사이에 GPU를 잠깐 쉬게 하고, "
"조용할 때 모델을 내립니다."
)
self.game_mode_check.toggled.connect(self._on_performance_changed)
form.addRow("", self.game_mode_check)
self.vram_slider, vram_row = _slider_row(
int(perf.vram_limit_ratio * 100), 0, 90, "%", self._on_performance_changed
)
self.vram_slider.setToolTip(
"이 프로그램이 쓸 수 있는 VRAM 상한입니다. 0%면 제한하지 않습니다.\n"
"게임이 VRAM 부족으로 버벅이면 낮추세요."
)
form.addRow("VRAM 상한", vram_row)
self.yield_spin = _spin(perf.yield_ms, 0, 500, 10, " ms", self._on_performance_changed)
self.yield_spin.setToolTip(
"한 문장을 처리하고 나서 GPU를 놓아주는 시간입니다.\n"
"길수록 게임이 부드럽고 자막은 조금 늦게 뜹니다."
)
form.addRow("GPU 양보 시간", self.yield_spin)
self.idle_spin = _spin(
perf.idle_unload_s, 0, 3600, 30, " 초", self._on_performance_changed
)
self.idle_spin.setToolTip(
"이 시간 동안 말소리가 없으면 모델을 내려 VRAM을 통째로 반납합니다.\n"
"다음 대사가 나오면 자동으로 다시 올라갑니다. 0이면 끕니다."
)
form.addRow("유휴 시 모델 해제", self.idle_spin)
self.perf_summary = QLabel()
self.perf_summary.setObjectName("Hint")
self.perf_summary.setWordWrap(True)
card.add(self.perf_summary)
self._update_perf_summary()
return card
def _update_perf_summary(self) -> None:
perf = self.config.performance
gpu = detect_gpu()
if not gpu.available or not gpu.total_vram_mb:
self.perf_summary.setText("GPU 정보를 읽지 못해 상한을 계산할 수 없습니다.")
return
if perf.vram_limit_ratio <= 0:
self.perf_summary.setText(
f"{gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB — 상한 없음 "
"(게임이 VRAM 부족으로 버벅일 수 있습니다)"
)
return
budget = gpu.total_vram_mb * perf.vram_limit_ratio / 1024
left = gpu.total_vram_mb / 1024 - budget
self.perf_summary.setText(
f"{gpu.name} · 자막이 최대 {budget:.1f}GB 사용, 게임 몫 약 {left:.1f}GB"
)
# --- 단축키 ----------------------------------------------------------
def _build_hotkey_card(self) -> Card:
keys = self.config.hotkeys
card = Card(
"전역 단축키",
"게임 창이 떠 있어도 동작합니다. Ctrl / Alt / Shift 중 하나는 반드시 넣으세요.",
)
form = card.add_form()
self.hotkey_enabled = QCheckBox("전역 단축키 사용")
self.hotkey_enabled.setChecked(keys.enabled)
self.hotkey_enabled.toggled.connect(self._on_hotkey_changed)
form.addRow("", self.hotkey_enabled)
self.subtitle_hotkey = QLineEdit(keys.toggle_subtitle)
self.subtitle_hotkey.setPlaceholderText("예: Ctrl+Alt+S")
self.subtitle_hotkey.editingFinished.connect(self._on_hotkey_changed)
form.addRow("자막 켜기/끄기", self.subtitle_hotkey)
self.engine_hotkey = QLineEdit(keys.toggle_engine)
self.engine_hotkey.setPlaceholderText("예: Ctrl+Alt+D")
self.engine_hotkey.editingFinished.connect(self._on_hotkey_changed)
form.addRow("번역 시작/정지", self.engine_hotkey)
self.hotkey_status = QLabel()
self.hotkey_status.setObjectName("Hint")
self.hotkey_status.setWordWrap(True)
card.add(self.hotkey_status)
return card
def set_hotkey_status(self, message: str, ok: bool = True) -> None:
p = palette("dark")
self.hotkey_status.setText(message)
self.hotkey_status.setStyleSheet(f"color: {p.text_dim if ok else p.warning};")
def _on_performance_changed(self, *_) -> None:
perf = self.config.performance
perf.game_mode = self.game_mode_check.isChecked()
perf.vram_limit_ratio = self.vram_slider.value() / 100
perf.yield_ms = self.yield_spin.value()
perf.idle_unload_s = self.idle_spin.value()
for widget in (self.vram_slider, self.yield_spin, self.idle_spin):
widget.setEnabled(perf.game_mode)
self._update_perf_summary()
if hasattr(self, "partial_spin"): # 오디오 카드가 아직 안 만들어졌을 수 있다
self._sync_partial_enabled()
self.config_changed.emit()
self.restart_needed.emit()
def _on_hotkey_changed(self, *_) -> None:
keys = self.config.hotkeys
keys.enabled = self.hotkey_enabled.isChecked()
keys.toggle_subtitle = self.subtitle_hotkey.text().strip()
keys.toggle_engine = self.engine_hotkey.text().strip()
for widget in (self.subtitle_hotkey, self.engine_hotkey):
widget.setEnabled(keys.enabled)
self.config_changed.emit()
self.hotkeys_changed.emit()
# --- 구성 -----------------------------------------------------------
def _build_audio_card(self) -> Card:
cfg = self.config.audio
card = Card(
"음성 인식",
"소리가 작아 인식이 안 되면 입력 증폭을 올리고, 주변 소음까지 잡히면 "
"민감도를 낮추세요. 값을 바꾸면 다음 시작부터 적용됩니다.",
)
form = card.add_form()
self.gain_slider, gain_row = _slider_row(
int(cfg.input_gain * 100), 25, 400, "%", self._on_audio_changed
)
form.addRow("입력 증폭", gain_row)
self.vad_combo = QComboBox()
for label, value in (
("낮음 — 작은 소리도 잡음", 0),
("보통", 1),
("높음 (기본)", 2),
("매우 높음 — 또렷한 말만", 3),
):
self.vad_combo.addItem(label, value)
self.vad_combo.setCurrentIndex(max(0, self.vad_combo.findData(cfg.vad_aggressiveness)))
self.vad_combo.currentIndexChanged.connect(self._on_audio_changed)
form.addRow("잡음 억제", self.vad_combo)
self.silence_spin = _spin(cfg.silence_ms, 200, 2000, 50, " ms", self._on_audio_changed)
self.silence_spin.setToolTip(
"이 시간만큼 조용하면 한 문장이 끝난 것으로 봅니다. "
"짧게 하면 자막이 빨리 뜨지만 문장이 자주 끊깁니다."
)
form.addRow("문장 끊는 침묵", self.silence_spin)
self.max_spin = _spin(cfg.max_segment_ms, 3000, 30_000, 1000, " ms", self._on_audio_changed)
self.max_spin.setToolTip("말이 계속 이어져도 이 길이에서 강제로 끊습니다.")
form.addRow("문장 최대 길이", self.max_spin)
self.partial_spin = _spin(
cfg.partial_interval_ms, 0, 3000, 100, " ms", self._on_audio_changed
)
self.partial_spin.setToolTip(
"말하는 도중에도 인식 중인 내용을 흐리게 미리 보여줍니다.\n"
"같은 오디오를 여러 번 인식하므로 GPU 작업이 거의 두 배가 됩니다.\n"
"저부하 모드가 켜져 있으면 무시됩니다."
)
self.partial_row_label = QLabel("중간 결과 갱신")
form.addRow(self.partial_row_label, self.partial_spin)
self._sync_partial_enabled()
return card
def _sync_partial_enabled(self) -> None:
"""저부하 모드에서는 중간 결과를 쓰지 않으므로 입력을 잠근다."""
on = not self.config.performance.game_mode
self.partial_spin.setEnabled(on)
self.partial_row_label.setText(
"중간 결과 갱신" if on else "중간 결과 갱신 (저부하 모드에서 꺼짐)"
)
def _build_behavior_card(self) -> Card:
card = Card("동작")
form = card.add_form()
self.theme_combo = QComboBox()
self.theme_combo.addItem("어두운 테마", "dark")
self.theme_combo.addItem("밝은 테마", "light")
self.theme_combo.setCurrentIndex(max(0, self.theme_combo.findData(self.config.theme)))
self.theme_combo.currentIndexChanged.connect(self._on_behavior_changed)
form.addRow("테마", self.theme_combo)
self.preload_check = QCheckBox("시작할 때 모델을 미리 올려두기")
self.preload_check.setChecked(self.config.models.preload_on_start)
self.preload_check.setToolTip(
"끄면 프로그램이 빨리 뜨지만 첫 문장 번역이 오래 걸립니다."
)
self.preload_check.toggled.connect(self._on_behavior_changed)
form.addRow("", self.preload_check)
self.log_check = QCheckBox("번역 기록을 파일로 남기기")
self.log_check.setChecked(self.config.log_transcripts)
self.log_check.toggled.connect(self._on_behavior_changed)
form.addRow("", self.log_check)
open_folder = QPushButton("설정 폴더 열기")
open_folder.clicked.connect(self._open_folder)
form.addRow("", open_folder)
return card
def _build_about_card(self) -> Card:
card = Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN)
path = QLabel(f"설정 위치: {user_data_dir()}")
path.setObjectName("Hint")
path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
path.setWordWrap(True)
card.add(path)
return card
# --- 동작 -----------------------------------------------------------
def _on_audio_changed(self, *_) -> None:
cfg = self.config.audio
cfg.input_gain = self.gain_slider.value() / 100
cfg.vad_aggressiveness = self.vad_combo.currentData()
cfg.silence_ms = self.silence_spin.value()
cfg.max_segment_ms = self.max_spin.value()
cfg.partial_interval_ms = self.partial_spin.value()
self.config_changed.emit()
self.restart_needed.emit()
def _on_behavior_changed(self, *_) -> None:
self.config.theme = self.theme_combo.currentData()
self.config.models.preload_on_start = self.preload_check.isChecked()
self.config.log_transcripts = self.log_check.isChecked()
self.config_changed.emit()
def _open_folder(self) -> None:
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
QDesktopServices.openUrl(QUrl.fromLocalFile(str(user_data_dir())))
def _spin(value, low, high, step, suffix, slot) -> QSpinBox:
spin = QSpinBox()
spin.setRange(low, high)
spin.setSingleStep(step)
spin.setSuffix(suffix)
spin.setValue(value)
spin.valueChanged.connect(slot)
return spin
def _slider_row(value, low, high, suffix, slot) -> tuple[QSlider, QWidget]:
row = QWidget()
row.setObjectName("Row")
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(low, high)
slider.setValue(value)
label = QLabel(f"{value}{suffix}")
label.setMinimumWidth(48)
slider.valueChanged.connect(lambda v: label.setText(f"{v}{suffix}"))
slider.valueChanged.connect(slot)
layout.addWidget(slider, 1)
layout.addWidget(label)
return slider, row

View File

@@ -0,0 +1,360 @@
"""자막 — 글꼴·색·크기 등 표시 설정과 실시간 미리보기."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase
from PySide6.QtWidgets import (
QCheckBox,
QColorDialog,
QComboBox,
QFontComboBox,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QSpinBox,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ..overlay import SubtitlePreview
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, list_screens
from ..theme import SPACING
from ..widgets import AnchorGrid, Card, PageHeader
PREVIEW_TEXT = "적이 왼쪽에서 들어온다, 지금 바로 빠져!"
PREVIEW_SOURCE = "Enemy coming from the left, fall back now!"
class ColorButton(QPushButton):
color_picked = Signal(str)
def __init__(self, color: str, parent: QWidget | None = None):
super().__init__(parent)
self.setFixedSize(52, 30)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.set_color(color)
self.clicked.connect(self._pick)
@property
def color(self) -> str:
return self._color
def set_color(self, color: str) -> None:
self._color = color
self.setStyleSheet(
f"background: {color}; border: 1px solid #3A4152; border-radius: 6px;"
)
def _pick(self) -> None:
chosen = QColorDialog.getColor(QColor(self._color), self, "색 선택")
if chosen.isValid():
self.set_color(chosen.name())
self.color_picked.emit(chosen.name())
class SubtitlePage(QWidget):
style_changed = Signal()
#: 자막 표시 on/off
overlay_toggled = Signal(bool)
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜
placement_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader("자막", "자막을 켜고 끄고, 어느 모니터 어디에 띄울지 정합니다.")
)
root.addWidget(self._build_display_card())
root.addWidget(self._build_preview_card())
root.addWidget(self._build_text_card())
root.addWidget(self._build_layout_card())
root.addStretch(1)
self._sync_preview()
# --- 구성 -----------------------------------------------------------
def _build_display_card(self) -> Card:
"""자막 on/off + 모니터 선택 + 디스코드식 위치 선택."""
cfg = self.config.overlay
card = Card(
"표시 위치",
"모니터를 고르고 네모칸을 누르면 그 자리에 자막이 붙습니다. "
"직접 드래그해서 옮기면 그 위치를 그대로 기억합니다.",
)
# --- on/off + 단축키 안내 ---
toggle_row = QWidget()
toggle_layout = QHBoxLayout(toggle_row)
toggle_layout.setContentsMargins(0, 0, 0, 0)
toggle_layout.setSpacing(10)
self.overlay_check = QCheckBox("자막 켜기")
self.overlay_check.setChecked(cfg.visible)
self.overlay_check.setStyleSheet("font-weight: 700;")
self.overlay_check.toggled.connect(self.overlay_toggled.emit)
toggle_layout.addWidget(self.overlay_check)
hotkey_hint = QLabel(f"게임 중에도 {self.config.hotkeys.toggle_subtitle} 로 켜고 끌 수 있습니다")
hotkey_hint.setObjectName("Hint")
toggle_layout.addWidget(hotkey_hint)
toggle_layout.addStretch(1)
self.click_through_check = QCheckBox("클릭 통과")
self.click_through_check.setChecked(cfg.click_through)
self.click_through_check.setToolTip(
"켜면 자막 위를 클릭해도 게임으로 전달됩니다. 게임 중에는 켜두세요."
)
self.click_through_check.toggled.connect(self._on_placement_changed)
toggle_layout.addWidget(self.click_through_check)
card.add(toggle_row)
# --- 모니터 선택 ---
monitor_row = QWidget()
monitor_layout = QHBoxLayout(monitor_row)
monitor_layout.setContentsMargins(0, 0, 0, 0)
monitor_layout.setSpacing(10)
monitor_layout.addWidget(QLabel("모니터"))
self.screen_combo = QComboBox()
self.screen_combo.currentIndexChanged.connect(self._on_screen_changed)
monitor_layout.addWidget(self.screen_combo, 1)
refresh = QPushButton("새로고침")
refresh.setToolTip("모니터를 연결하거나 뺐다면 눌러주세요.")
refresh.clicked.connect(self.refresh_screens)
monitor_layout.addWidget(refresh)
card.add(monitor_row)
# --- 9분할 위치 ---
self.anchor_grid = AnchorGrid()
self.anchor_grid.anchor_selected.connect(self._on_anchor_selected)
card.add(self.anchor_grid)
self.anchor_label = QLabel()
self.anchor_label.setObjectName("Hint")
self.anchor_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
card.add(self.anchor_label)
margin_row = QWidget()
margin_layout = QHBoxLayout(margin_row)
margin_layout.setContentsMargins(0, 0, 0, 0)
margin_layout.setSpacing(10)
margin_layout.addWidget(QLabel("가장자리 여백"))
self.margin_spin = QSpinBox()
self.margin_spin.setRange(0, 400)
self.margin_spin.setSingleStep(10)
self.margin_spin.setSuffix(" px")
self.margin_spin.setValue(cfg.margin)
self.margin_spin.valueChanged.connect(self._on_placement_changed)
margin_layout.addWidget(self.margin_spin)
margin_layout.addStretch(1)
card.add(margin_row)
self.refresh_screens()
return card
def _build_preview_card(self) -> Card:
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
self.preview = SubtitlePreview(self.config.subtitle)
self.preview.set_sample(PREVIEW_TEXT, PREVIEW_SOURCE)
card.add(self.preview)
return card
def _build_text_card(self) -> Card:
st = self.config.subtitle
card = Card("글씨")
form = card.add_form()
self.font_combo = QFontComboBox()
available = set(QFontDatabase.families())
self.font_combo.setCurrentFont(
QFont(st.font_family if st.font_family in available else "Segoe UI")
)
self.font_combo.currentFontChanged.connect(self._on_font_changed)
form.addRow("글꼴", self.font_combo)
self.size_spin = QSpinBox()
self.size_spin.setRange(12, 120)
self.size_spin.setSuffix(" px")
self.size_spin.setValue(st.font_size)
self.size_spin.valueChanged.connect(self._on_change)
form.addRow("크기", self.size_spin)
self.bold_check = QCheckBox("굵게")
self.bold_check.setChecked(st.bold)
self.bold_check.toggled.connect(self._on_change)
form.addRow("", self.bold_check)
self.text_color = ColorButton(st.text_color)
self.text_color.color_picked.connect(self._on_change)
form.addRow("번역문 색", self.text_color)
self.source_color = ColorButton(st.source_text_color)
self.source_color.color_picked.connect(self._on_change)
form.addRow("원문 색", self.source_color)
self.outline_color = ColorButton(st.outline_color)
self.outline_color.color_picked.connect(self._on_change)
form.addRow("외곽선 색", self.outline_color)
self.outline_spin = QSpinBox()
self.outline_spin.setRange(0, 8)
self.outline_spin.setSuffix(" px")
self.outline_spin.setValue(st.outline_width)
self.outline_spin.setToolTip("배경이 밝든 어둡든 글씨가 읽히게 해줍니다. 0이면 끕니다.")
self.outline_spin.valueChanged.connect(self._on_change)
form.addRow("외곽선 두께", self.outline_spin)
return card
def _build_layout_card(self) -> Card:
st = self.config.subtitle
card = Card("배치와 배경")
form = card.add_form()
self.bg_color = ColorButton(st.background_color)
self.bg_color.color_picked.connect(self._on_change)
form.addRow("배경 색", self.bg_color)
opacity_row = QWidget()
opacity_row.setObjectName("Row")
layout = QHBoxLayout(opacity_row)
layout.setContentsMargins(0, 0, 0, 0)
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
self.opacity_slider.setRange(0, 100)
self.opacity_slider.setValue(st.background_opacity)
self.opacity_slider.valueChanged.connect(self._on_change)
self.opacity_value = QLabel(f"{st.background_opacity}%")
self.opacity_value.setMinimumWidth(42)
layout.addWidget(self.opacity_slider, 1)
layout.addWidget(self.opacity_value)
form.addRow("배경 투명도", opacity_row)
self.align_combo = QComboBox()
for label, value in (("가운데", "center"), ("왼쪽", "left"), ("오른쪽", "right")):
self.align_combo.addItem(label, value)
self.align_combo.setCurrentIndex(max(0, self.align_combo.findData(st.align)))
self.align_combo.currentIndexChanged.connect(self._on_change)
form.addRow("정렬", self.align_combo)
self.lines_spin = QSpinBox()
self.lines_spin.setRange(1, 5)
self.lines_spin.setSuffix(" 줄")
self.lines_spin.setValue(st.max_lines)
self.lines_spin.valueChanged.connect(self._on_change)
form.addRow("최대 줄 수", self.lines_spin)
self.fade_spin = QSpinBox()
self.fade_spin.setRange(0, 30_000)
self.fade_spin.setSingleStep(500)
self.fade_spin.setSuffix(" ms")
self.fade_spin.setValue(st.fade_out_ms)
self.fade_spin.setToolTip("이 시간이 지나면 자막을 지웁니다. 0이면 계속 남겨둡니다.")
self.fade_spin.valueChanged.connect(self._on_change)
form.addRow("자동 지우기", self.fade_spin)
self.show_source_check = QCheckBox("원문도 같이 표시")
self.show_source_check.setChecked(st.show_source)
self.show_source_check.toggled.connect(self._on_change)
form.addRow("", self.show_source_check)
return card
# --- 표시 위치 -------------------------------------------------------
def refresh_screens(self) -> None:
"""연결된 모니터를 다시 읽어 콤보를 채운다."""
cfg = self.config.overlay
screens = list_screens()
self.screen_combo.blockSignals(True)
self.screen_combo.clear()
for screen in screens:
self.screen_combo.addItem(screen.label, screen.index)
if not screens:
self.screen_combo.addItem("모니터를 찾지 못했습니다", -1)
found = self.screen_combo.findData(cfg.screen_index)
self.screen_combo.setCurrentIndex(found if found >= 0 else 0)
self.screen_combo.blockSignals(False)
self.anchor_grid.set_anchor(cfg.anchor)
self._update_anchor_label()
def sync_from_config(self) -> None:
"""드래그 등으로 설정이 바뀐 뒤 화면 표시를 맞춘다."""
cfg = self.config.overlay
self.overlay_check.blockSignals(True)
self.overlay_check.setChecked(cfg.visible)
self.overlay_check.blockSignals(False)
self.anchor_grid.set_anchor(cfg.anchor)
self._update_anchor_label()
def _update_anchor_label(self) -> None:
anchor = self.config.overlay.anchor
if anchor == ANCHOR_CUSTOM:
self.anchor_label.setText(
"자유 배치 — 직접 옮긴 자리를 쓰고 있습니다. 칸을 누르면 다시 맞춰집니다."
)
else:
self.anchor_label.setText(f"현재 위치: {ANCHOR_LABELS.get(anchor, anchor)}")
def _on_screen_changed(self) -> None:
index = self.screen_combo.currentData()
if index is None or index < 0:
return
self.config.overlay.screen_index = index
# 모니터를 바꾸면 그 모니터 기준으로 다시 배치해야 한다. 자유 배치
# 상태였다면 하단 중앙으로 되돌린다 — 이전 좌표는 다른 모니터 것이라
# 그대로 쓰면 화면 밖으로 나간다.
if self.config.overlay.anchor == ANCHOR_CUSTOM:
self.config.overlay.anchor = "bottom-center"
self.anchor_grid.set_anchor("bottom-center")
self._update_anchor_label()
self.placement_changed.emit()
def _on_anchor_selected(self, anchor: str) -> None:
self.config.overlay.anchor = anchor
self._update_anchor_label()
self.placement_changed.emit()
def _on_placement_changed(self, *_) -> None:
cfg = self.config.overlay
cfg.margin = self.margin_spin.value()
cfg.click_through = self.click_through_check.isChecked()
self.placement_changed.emit()
# --- 동작 -----------------------------------------------------------
def _on_font_changed(self, font: QFont) -> None:
self.config.subtitle.font_family = font.family()
self._on_change()
def _on_change(self, *_) -> None:
st = self.config.subtitle
st.font_size = self.size_spin.value()
st.bold = self.bold_check.isChecked()
st.text_color = self.text_color.color
st.source_text_color = self.source_color.color
st.outline_color = self.outline_color.color
st.outline_width = self.outline_spin.value()
st.background_color = self.bg_color.color
st.background_opacity = self.opacity_slider.value()
st.align = self.align_combo.currentData()
st.max_lines = self.lines_spin.value()
st.fade_out_ms = self.fade_spin.value()
st.show_source = self.show_source_check.isChecked()
self.opacity_value.setText(f"{st.background_opacity}%")
self._sync_preview()
self.style_changed.emit()
def _sync_preview(self) -> None:
# 미리보기는 설정 객체를 그대로 참조하므로 다시 그리기만 하면 된다.
self.preview.setMinimumHeight(max(140, self.config.subtitle.font_size * 4))
self.preview.update()

141
src/livesub/ui/placement.py Normal file
View File

@@ -0,0 +1,141 @@
"""자막 창 배치 계산.
디스코드 오버레이와 같은 방식이다. 모니터를 고르고 9개 구역 중 하나를 누르면
그 자리에 딱 붙는다. 세밀하게 옮기고 싶으면 그 다음에 드래그하면 된다.
Qt 에 의존하지 않는 순수 계산부(`resolve_rect`)와 Qt 화면 조회부를 나눠놨다.
덕분에 모니터가 없는 환경에서도 배치 로직을 그대로 테스트할 수 있다.
"""
from __future__ import annotations
from dataclasses import dataclass
#: 디스코드 오버레이와 같은 3x3 그리드. 값은 (가로 정렬, 세로 정렬).
ANCHORS: dict[str, tuple[str, str]] = {
"top-left": ("left", "top"),
"top-center": ("center", "top"),
"top-right": ("right", "top"),
"middle-left": ("left", "middle"),
"middle-center": ("center", "middle"),
"middle-right": ("right", "middle"),
"bottom-left": ("left", "bottom"),
"bottom-center": ("center", "bottom"),
"bottom-right": ("right", "bottom"),
}
#: UI 표시용 이름 (그리드 순서와 동일)
ANCHOR_LABELS: dict[str, str] = {
"top-left": "좌상단",
"top-center": "상단 중앙",
"top-right": "우상단",
"middle-left": "좌측 중앙",
"middle-center": "정중앙",
"middle-right": "우측 중앙",
"bottom-left": "좌하단",
"bottom-center": "하단 중앙",
"bottom-right": "우하단",
}
#: 자유 배치 — 사용자가 드래그로 직접 옮긴 상태
ANCHOR_CUSTOM = "custom"
DEFAULT_ANCHOR = "bottom-center"
@dataclass(frozen=True)
class ScreenInfo:
"""모니터 하나. Qt 의 QScreen 에서 뽑아낸 값만 담는다."""
index: int
name: str
x: int
y: int
width: int
height: int
is_primary: bool = False
@property
def label(self) -> str:
tag = " (주 모니터)" if self.is_primary else ""
return f"{self.index + 1}. {self.name} · {self.width}×{self.height}{tag}"
def resolve_rect(
screen: ScreenInfo,
anchor: str,
width: int,
height: int,
margin: int = 60,
) -> tuple[int, int, int, int]:
"""앵커와 크기로 최종 창 위치를 계산한다.
창이 모니터보다 크면 모니터 안에 들어가도록 줄인다. 그러지 않으면
자막이 화면 밖으로 나가 영영 안 보이는 상태가 될 수 있다.
"""
width = max(200, min(width, screen.width - margin * 2))
height = max(80, min(height, screen.height - margin * 2))
horizontal, vertical = ANCHORS.get(anchor, ANCHORS[DEFAULT_ANCHOR])
if horizontal == "left":
x = screen.x + margin
elif horizontal == "right":
x = screen.x + screen.width - width - margin
else:
x = screen.x + (screen.width - width) // 2
if vertical == "top":
y = screen.y + margin
elif vertical == "bottom":
y = screen.y + screen.height - height - margin
else:
y = screen.y + (screen.height - height) // 2
return x, y, width, height
def screen_for_point(screens: list[ScreenInfo], x: int, y: int) -> ScreenInfo | None:
"""해당 좌표를 품고 있는 모니터. 드래그로 옮긴 뒤 소속을 다시 찾을 때 쓴다."""
for screen in screens:
if screen.x <= x < screen.x + screen.width and screen.y <= y < screen.y + screen.height:
return screen
return None
def pick_screen(screens: list[ScreenInfo], index: int) -> ScreenInfo | None:
"""저장된 인덱스로 모니터를 찾는다. 모니터 구성이 바뀌었으면 주 모니터로."""
if not screens:
return None
for screen in screens:
if screen.index == index:
return screen
for screen in screens:
if screen.is_primary:
return screen
return screens[0]
def list_screens() -> list[ScreenInfo]:
"""현재 연결된 모니터 목록 (Qt 필요)."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is None:
return []
primary = app.primaryScreen()
out: list[ScreenInfo] = []
for index, screen in enumerate(app.screens()):
geo = screen.geometry()
out.append(
ScreenInfo(
index=index,
name=screen.name() or f"모니터 {index + 1}",
x=geo.x(),
y=geo.y(),
width=geo.width(),
height=geo.height(),
is_primary=screen is primary,
)
)
return out

210
src/livesub/ui/theme.py Normal file
View File

@@ -0,0 +1,210 @@
"""디자인 토큰과 QSS 스타일시트.
의도적으로 외부 테마 패키지를 쓰지 않는다. 자막 오버레이가 반투명·무테두리라
플랫폼 기본 테마와 섞이면 오히려 지저분해지고, 색/간격을 한곳에서 관리하는 편이
설정 화면의 '미리보기'와 실제 자막을 일치시키기에도 좋다.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Palette:
bg: str
surface: str
surface_alt: str
border: str
text: str
text_dim: str
accent: str
accent_hover: str
accent_text: str
success: str
warning: str
danger: str
DARK = Palette(
bg="#0F1117",
surface="#171A22",
surface_alt="#1E222C",
border="#2A2F3C",
text="#E7EAF0",
text_dim="#8B93A5",
accent="#5B8CFF",
accent_hover="#7AA2FF",
accent_text="#0B0E14",
success="#3DD68C",
warning="#F5B84E",
danger="#F2555A",
)
LIGHT = Palette(
bg="#F4F6FA",
surface="#FFFFFF",
surface_alt="#EDF0F6",
border="#D9DEE9",
text="#131722",
text_dim="#5C6578",
accent="#3B6EF0",
accent_hover="#2A5AD6",
accent_text="#FFFFFF",
success="#17A46A",
warning="#C9821B",
danger="#D63A3F",
)
RADIUS = 10
SPACING = 12
def palette(theme: str) -> Palette:
return LIGHT if theme == "light" else DARK
def stylesheet(theme: str = "dark") -> str:
p = palette(theme)
return f"""
QWidget {{
background: {p.bg};
color: {p.text};
font-family: "Pretendard", "Segoe UI Variable", "Segoe UI", "Malgun Gothic", sans-serif;
font-size: 14px;
}}
/* 라벨류는 부모(카드/사이드바) 배경이 비쳐야 한다. 지정하지 않으면
QWidget 규칙의 창 배경색을 상속해 카드 위에 검은 박스가 생긴다. */
QLabel, QCheckBox, QRadioButton, QScrollArea > QWidget > QWidget,
QWidget#Row {{
background: transparent;
}}
QLabel#Title {{ font-size: 22px; font-weight: 700; }}
QLabel#Subtitle {{ font-size: 13px; color: {p.text_dim}; }}
QLabel#SectionLabel {{ font-size: 12px; font-weight: 700; color: {p.text_dim};
letter-spacing: 1px; }}
QLabel#Hint {{ font-size: 12px; color: {p.text_dim}; }}
/* --- 사이드바 --- */
QFrame#Sidebar {{
background: {p.surface};
border-right: 1px solid {p.border};
}}
QPushButton#NavButton {{
background: transparent;
border: none;
border-radius: {RADIUS}px;
padding: 11px 14px;
text-align: left;
color: {p.text_dim};
font-size: 14px;
}}
QPushButton#NavButton:hover {{ background: {p.surface_alt}; color: {p.text}; }}
QPushButton#NavButton:checked {{
background: {p.accent};
color: {p.accent_text};
font-weight: 700;
}}
/* --- 카드 --- */
QFrame#Card {{
background: {p.surface};
border: 1px solid {p.border};
border-radius: {RADIUS}px;
}}
QFrame#CardSelected {{
background: {p.surface};
border: 2px solid {p.accent};
border-radius: {RADIUS}px;
}}
/* --- 입력 위젯 --- */
QComboBox, QLineEdit, QSpinBox, QPlainTextEdit, QTextEdit, QListWidget, QTableWidget {{
background: {p.surface_alt};
border: 1px solid {p.border};
border-radius: 8px;
padding: 7px 10px;
selection-background-color: {p.accent};
selection-color: {p.accent_text};
}}
QComboBox:focus, QLineEdit:focus, QSpinBox:focus, QPlainTextEdit:focus {{
border: 1px solid {p.accent};
}}
QComboBox::drop-down {{ border: none; width: 22px; }}
QComboBox QAbstractItemView {{
background: {p.surface_alt};
border: 1px solid {p.border};
selection-background-color: {p.accent};
outline: none;
}}
QHeaderView::section {{
background: {p.surface};
color: {p.text_dim};
border: none;
border-bottom: 1px solid {p.border};
padding: 7px;
font-weight: 600;
}}
QTableWidget {{ gridline-color: {p.border}; }}
/* --- 버튼 --- */
QPushButton {{
background: {p.surface_alt};
border: 1px solid {p.border};
border-radius: 8px;
padding: 8px 16px;
color: {p.text};
}}
QPushButton:hover {{ border-color: {p.accent}; }}
QPushButton:disabled {{ color: {p.text_dim}; border-color: {p.border}; }}
QPushButton#Primary {{
background: {p.accent};
color: {p.accent_text};
border: none;
font-weight: 700;
padding: 11px 22px;
}}
QPushButton#Primary:hover {{ background: {p.accent_hover}; }}
QPushButton#Primary:disabled {{ background: {p.border}; color: {p.text_dim}; }}
QPushButton#Danger {{
background: {p.danger};
color: #FFFFFF;
border: none;
font-weight: 700;
padding: 11px 22px;
}}
/* --- 기타 --- */
QSlider::groove:horizontal {{
height: 4px; background: {p.border}; border-radius: 2px;
}}
QSlider::handle:horizontal {{
width: 16px; height: 16px; margin: -6px 0;
background: {p.accent}; border-radius: 8px;
}}
QSlider::sub-page:horizontal {{ background: {p.accent}; border-radius: 2px; }}
QProgressBar {{
background: {p.surface_alt}; border: none; border-radius: 4px;
height: 6px; text-align: center; color: transparent;
}}
QProgressBar::chunk {{ background: {p.accent}; border-radius: 4px; }}
QCheckBox::indicator, QRadioButton::indicator {{
width: 17px; height: 17px;
border: 1px solid {p.border}; border-radius: 4px;
background: {p.surface_alt};
}}
QCheckBox::indicator:checked, QRadioButton::indicator:checked {{
background: {p.accent}; border-color: {p.accent};
}}
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 0; }}
QScrollBar::handle:vertical {{
background: {p.border}; border-radius: 5px; min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{ background: {p.text_dim}; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
QScrollArea {{ border: none; }}
QToolTip {{
background: {p.surface_alt}; color: {p.text};
border: 1px solid {p.border}; padding: 6px; border-radius: 6px;
}}
"""

View File

@@ -0,0 +1,4 @@
from .anchor_grid import AnchorGrid
from .common import Card, LevelMeter, PageHeader, StatusPill, labelled_row
__all__ = ["AnchorGrid", "Card", "LevelMeter", "PageHeader", "StatusPill", "labelled_row"]

View File

@@ -0,0 +1,69 @@
"""디스코드 오버레이식 9분할 위치 선택기.
모니터 모양의 사각형 안에 3x3 버튼을 놓고, 누르면 자막이 그 자리로 붙는다.
좌표를 숫자로 입력하게 하는 것보다 훨씬 직관적이다.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QButtonGroup, QFrame, QGridLayout, QPushButton, QWidget
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, ANCHORS
from ..theme import palette
GRID_ORDER = [
["top-left", "top-center", "top-right"],
["middle-left", "middle-center", "middle-right"],
["bottom-left", "bottom-center", "bottom-right"],
]
class AnchorGrid(QFrame):
"""3x3 위치 버튼. 선택된 칸이 강조된다."""
anchor_selected = Signal(str)
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setObjectName("Card")
self.setFixedHeight(190)
p = palette("dark")
layout = QGridLayout(self)
layout.setContentsMargins(14, 14, 14, 14)
layout.setSpacing(6)
self._buttons: dict[str, QPushButton] = {}
self._group = QButtonGroup(self)
self._group.setExclusive(True)
for row, keys in enumerate(GRID_ORDER):
for col, key in enumerate(keys):
button = QPushButton()
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setToolTip(ANCHOR_LABELS[key])
button.setMinimumHeight(42)
button.setStyleSheet(
f"QPushButton {{ background: {p.surface_alt};"
f"border: 1px solid {p.border}; border-radius: 6px; }}"
f"QPushButton:hover {{ border-color: {p.accent}; }}"
f"QPushButton:checked {{ background: {p.accent};"
f"border-color: {p.accent}; }}"
)
button.clicked.connect(lambda _, k=key: self.anchor_selected.emit(k))
self._group.addButton(button)
self._buttons[key] = button
layout.addWidget(button, row, col)
def set_anchor(self, anchor: str) -> None:
"""현재 선택 표시. 자유 배치(custom)면 아무 칸도 켜지 않는다."""
self._group.setExclusive(False)
for key, button in self._buttons.items():
button.setChecked(anchor != ANCHOR_CUSTOM and key == anchor)
self._group.setExclusive(True)
@staticmethod
def is_valid(anchor: str) -> bool:
return anchor in ANCHORS

View File

@@ -0,0 +1,141 @@
"""여러 화면에서 재사용하는 작은 위젯들."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import (
QFormLayout,
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from ..theme import SPACING, palette
class Card(QFrame):
"""제목 + 본문 레이아웃을 가진 카드 컨테이너."""
def __init__(self, title: str = "", subtitle: str = "", parent: QWidget | None = None):
super().__init__(parent)
self.setObjectName("Card")
outer = QVBoxLayout(self)
outer.setContentsMargins(SPACING + 4, SPACING + 4, SPACING + 4, SPACING + 4)
outer.setSpacing(SPACING)
if title:
label = QLabel(title)
label.setStyleSheet("font-size: 15px; font-weight: 700;")
outer.addWidget(label)
if subtitle:
hint = QLabel(subtitle)
hint.setObjectName("Hint")
hint.setWordWrap(True)
outer.addWidget(hint)
self.body = QVBoxLayout()
self.body.setSpacing(SPACING)
outer.addLayout(self.body)
def add(self, widget: QWidget) -> QWidget:
# 레이아웃용 맨 QWidget 은 창 배경색을 상속해 카드 위에 검은 막대가 된다.
# 이름을 달아주면 QSS 가 투명하게 처리한다 (theme.py 의 QWidget#Row).
if type(widget) is QWidget:
widget.setObjectName("Row")
self.body.addWidget(widget)
return widget
def add_form(self) -> QFormLayout:
form = QFormLayout()
form.setSpacing(10)
form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.body.addLayout(form)
return form
class PageHeader(QWidget):
def __init__(self, title: str, subtitle: str = "", parent: QWidget | None = None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
head = QLabel(title)
head.setObjectName("Title")
layout.addWidget(head)
if subtitle:
sub = QLabel(subtitle)
sub.setObjectName("Subtitle")
sub.setWordWrap(True)
layout.addWidget(sub)
class StatusPill(QLabel):
"""상태를 색 점 + 문구로 보여주는 배지."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._color = "#8B93A5"
self.setContentsMargins(10, 6, 12, 6)
self.set_status("대기 중", "idle")
def set_status(self, text: str, kind: str = "idle") -> None:
p = palette("dark")
self._color = {
"idle": p.text_dim,
"busy": p.warning,
"ok": p.success,
"error": p.danger,
}.get(kind, p.text_dim)
self.setText(f"● {text}")
self.setStyleSheet(
f"color: {self._color}; background: {p.surface_alt};"
f"border-radius: 13px; padding: 6px 14px; font-weight: 600;"
)
class LevelMeter(QWidget):
"""입력 오디오 레벨 바. 소리가 잡히는지 한눈에 확인하는 용도."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._level = 0.0
self._peak = 0.0
self.setFixedHeight(8)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
def set_level(self, value: float) -> None:
# RMS를 그대로 그리면 거의 안 움직여서 제곱근으로 펴준다.
self._level = min(1.0, max(0.0, value) ** 0.5 * 2.2)
self._peak = max(self._level, self._peak * 0.92)
self.update()
def paintEvent(self, event) -> None: # noqa: N802
p = palette("dark")
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(p.surface_alt))
painter.drawRoundedRect(self.rect(), 4, 4)
if self._level > 0.01:
color = QColor(p.danger if self._level > 0.92 else p.success)
rect = self.rect().adjusted(0, 0, -int(self.width() * (1 - self._level)), 0)
painter.setBrush(color)
painter.drawRoundedRect(rect, 4, 4)
def labelled_row(label: str, widget: QWidget) -> QWidget:
row = QWidget()
row.setObjectName("Row")
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
text = QLabel(label)
text.setMinimumWidth(110)
layout.addWidget(text)
layout.addWidget(widget, 1)
return row