"""자막 오버레이 창. 게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은 '외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다. 평소에는 **잠겨 있다.** 게임 중에 자막을 실수로 끌고 다니는 일이 없어야 하기 때문이다. 자막 화면의 '자막 위치 조정'을 켜야 아래 조작이 열린다. 위치 조정 모드에서: * 드래그 — 위치 이동 * 네 모서리 — 크기 조절 (글자 크기도 같은 비율로 따라온다) * 휠 — 글자 크기 조절 * 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴 """ 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) 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 pad = max(0, st.padding) available = rect.width() - pad * 2 blocks: list[tuple[list[str], QColor, QFont]] = [] total_h = 0 text_w = 0 for text, color, size in rows: font = QFont(st.font_family, size) font.setBold(st.bold) fm = QFontMetrics(font) lines = _wrap(text, fm, available, st.max_lines) blocks.append((lines, color, font)) total_h += len(lines) * (fm.height() + st.line_spacing) for line in lines: text_w = max(text_w, fm.horizontalAdvance(line)) box = _content_box(rect, text_w, total_h, pad, st.align) 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(box, 12, 12) y = box.y() + pad 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 = box.x() + pad elif st.align == "right": x = box.right() - pad - width else: x = box.x() + (box.width() - width) // 2 _draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st) y += fm.height() + st.line_spacing def _content_box(rect: QRect, text_w: int, text_h: int, pad: int, align: str) -> QRect: """글자를 감싸는 배경 상자. 창 밖으로는 넘지 않는다.""" width = min(rect.width(), text_w + pad * 2) height = min(rect.height(), text_h + pad * 2) if align == "left": x = rect.x() elif align == "right": x = rect.right() - width + 1 else: x = rect.x() + (rect.width() - width) // 2 y = rect.y() + (rect.height() - height) // 2 return QRect(x, y, width, height) 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) #: 크기 조정으로 글자 크기가 같이 바뀌었을 때 (설정 화면이 따라와야 한다) font_size_changed = Signal(int) #: 위치 조정 모드가 켜지고 꺼질 때 edit_mode_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._edit_mode = False self._resize_corner = "" self._resize_geo = QRect() self._resize_mouse = QPoint() self._resize_font = 0 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 and not self._edit_mode, ) # 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다. 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() # --- 위치 조정 모드 -------------------------------------------------- @property def edit_mode(self) -> bool: return self._edit_mode def set_edit_mode(self, on: bool) -> None: """자막을 옮기고 크기를 바꿀 수 있는 상태로 전환한다. 평소에는 잠겨 있다. 게임 중에 자막을 잘못 건드려 끌고 다니는 일이 없어야 하기 때문이다. 이 모드에서만 드래그·모서리 크기 조절·휠이 동작하고, 켜는 동안에는 자막이 안 보여도 잡을 수 있게 창을 띄운다. """ if on == self._edit_mode: return self._edit_mode = on if on and not self.isVisible(): self.setVisible(True) self.setAttribute( Qt.WidgetAttribute.WA_TransparentForMouseEvents, self.overlay_cfg.click_through and not on, ) self.setCursor( Qt.CursorShape.SizeAllCursor if on else Qt.CursorShape.ArrowCursor ) if not on: self._drag_from = None self._resize_corner = "" self.update() self.edit_mode_changed.emit(on) @property def _interactive(self) -> bool: return self._edit_mode or not self.overlay_cfg.locked def _corner_at(self, pos: QPoint) -> str: """모서리 손잡이를 집었는지. 집었으면 어느 모서리인지.""" left = pos.x() <= GRIP right = pos.x() >= self.width() - GRIP top = pos.y() <= GRIP bottom = pos.y() >= self.height() - GRIP if (left or right) and (top or bottom): return ("top" if top else "bottom") + ("left" if left else "right") return "" 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 self._edit_mode: self._draw_edit_chrome(painter) def _draw_edit_chrome(self, painter) -> None: """위치 조정 중이라는 것과 어디를 잡으면 되는지 보여준다.""" painter.setBrush(Qt.BrushStyle.NoBrush) painter.setPen(QPen(QColor(90, 170, 255, 220), 2, Qt.PenStyle.DashLine)) painter.drawRect(self.rect().adjusted(1, 1, -2, -2)) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QColor(90, 170, 255, 235)) for rect in self._corner_handles(): painter.drawRect(rect) if not (self._final_text or self._partial_text): painter.setPen(QColor(255, 255, 255, 170)) font = QFont(self.style_cfg.font_family, 13) painter.setFont(font) painter.drawText( self.rect(), Qt.AlignmentFlag.AlignCenter, "끌어서 이동 · 모서리를 잡아 크기 조절 (글자 크기도 같이 바뀝니다)", ) def _corner_handles(self) -> list[QRect]: w, h = self.width(), self.height() return [ QRect(0, 0, GRIP, GRIP), QRect(w - GRIP, 0, GRIP, GRIP), QRect(0, h - GRIP, GRIP, GRIP), QRect(w - GRIP, h - GRIP, GRIP, GRIP), ] # --- 마우스 --------------------------------------------------------- def mousePressEvent(self, event) -> None: # noqa: N802 if not self._interactive or event.button() != Qt.MouseButton.LeftButton: return corner = self._corner_at(event.position().toPoint()) if corner: self._resize_corner = corner self._resize_geo = QRect(self.geometry()) self._resize_mouse = event.globalPosition().toPoint() self._resize_font = self.style_cfg.font_size else: self._drag_from = event.globalPosition().toPoint() - self.pos() def mouseMoveEvent(self, event) -> None: # noqa: N802 if self._resize_corner: self._resize_to(event.globalPosition().toPoint()) elif self._drag_from is not None: self.move(event.globalPosition().toPoint() - self._drag_from) def _resize_to(self, mouse: QPoint) -> None: """잡은 모서리만 움직이고 반대쪽 모서리는 제자리에 둔다. 글자 크기는 높이 비율을 그대로 따라간다. 상자만 커지고 글자는 그대로면 여백만 늘어나므로, 크기 조절이 '자막을 키우는' 조작이 되지 않는다. """ start = self._resize_geo dx = mouse.x() - self._resize_mouse.x() dy = mouse.y() - self._resize_mouse.y() left, top = start.left(), start.top() right, bottom = start.right(), start.bottom() if "left" in self._resize_corner: left = min(left + dx, right - self.minimumWidth()) else: right = max(right + dx, left + self.minimumWidth()) if "top" in self._resize_corner: top = min(top + dy, bottom - self.minimumHeight()) else: bottom = max(bottom + dy, top + self.minimumHeight()) rect = QRect(QPoint(left, top), QPoint(right, bottom)) self.setGeometry(rect) scaled = round(self._resize_font * rect.height() / max(1, start.height())) size = max(12, min(120, scaled)) if size != self.style_cfg.font_size: self.style_cfg.font_size = size self.font_size_changed.emit(size) self.update() def mouseReleaseEvent(self, event) -> None: # noqa: N802 if self._drag_from is None and not self._resize_corner: return self._drag_from = None self._resize_corner = "" 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 not self._interactive: return step = 2 if event.angleDelta().y() > 0 else -2 size = max(12, min(120, self.style_cfg.font_size + step)) if size != self.style_cfg.font_size: self.style_cfg.font_size = size self.font_size_changed.emit(size) 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 and not self._edit_mode, ) 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