"""Shared Vocab / Quirks / Game skills editors for Workflow Setup steps.""" from __future__ import annotations from collections.abc import Callable from PyQt5.QtCore import Qt, QSize from PyQt5.QtGui import QFont from PyQt5.QtWidgets import ( QHBoxLayout, QInputDialog, QLabel, QMessageBox, QPushButton, QStackedWidget, QTabBar, QTabWidget, QTextEdit, QVBoxLayout, QWidget, ) from util.skills import ( custom_skill_path_for_game, game_skill_path_for_game, list_custom_skill_paths, migrate_game_skill_text, quirks_path_for_game, sanitize_custom_skill_stem, ) from util.vocab import read_game_vocab, write_game_vocab class _PlainPasteTextEdit(QTextEdit): """QTextEdit that always pastes as plain text.""" def insertFromMimeData(self, source): # noqa: N802 self.insertPlainText(source.text()) def _make_btn(text: str, color: str = "#007acc") -> QPushButton: """Match workflow_tab button styling (flat dark + coloured outline).""" btn = QPushButton() try: c = color.lstrip("#") if len(c) == 3: c = c[0] * 2 + c[1] * 2 + c[2] * 2 r, g, b = int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16) is_flat = max(r, g, b) < 115 except Exception: r = g = b = 0 is_flat = False _PAD = "padding:6px 14px;" _icon_color = "#cccccc" if is_flat: btn.setStyleSheet( f"QPushButton{{background-color:#2d2d30;color:#cccccc;" f"border:1px solid #555555;{_PAD}" f"border-radius:4px;font-size:12px;font-weight:bold;" f"font-family:'Segoe UI','Segoe UI Emoji','Apple Color Emoji',sans-serif;}}" f"QPushButton:hover{{background-color:#3e3e42;}}" f"QPushButton:pressed{{background-color:#007acc;color:white;}}" f"QPushButton:disabled{{background-color:#404040;color:#666666;border-color:#444444;}}" ) else: rt = min(255, r + 80) gt = min(255, g + 80) bt = min(255, b + 80) text_color = f"#{rt:02x}{gt:02x}{bt:02x}" _icon_color = text_color base = 0x2d rh = min(255, int(base + (r - base) * 0.18)) gh = min(255, int(base + (g - base) * 0.18)) bh = min(255, int(base + (b - base) * 0.18)) hover_bg = f"#{rh:02x}{gh:02x}{bh:02x}" rb = min(255, r + 35) gb = min(255, g + 35) bb = min(255, b + 35) hover_accent = f"#{rb:02x}{gb:02x}{bb:02x}" btn.setStyleSheet( f"QPushButton{{background-color:#2d2d30;color:{text_color};" f"border:1px solid {color};{_PAD}" f"border-radius:4px;font-size:12px;font-weight:bold;" f"font-family:'Segoe UI','Segoe UI Emoji','Apple Color Emoji',sans-serif;}}" f"QPushButton:hover{{background-color:{hover_bg};border-color:{hover_accent};" f"color:{hover_accent};}}" f"QPushButton:pressed{{background-color:#1a1a1a;}}" f"QPushButton:disabled{{background-color:#2d2d30;color:#555555;border-color:#444444;}}" ) from gui import qt_icons qt_icons.apply_button_icon(btn, text, color=_icon_color) if not btn.icon().isNull(): btn.setIconSize(QSize(16, 16)) return btn class SetupSkillsEditors(QWidget): """Tabbed Vocab / Quirks / Game skills editors for Workflow Setup. Parameters ---------- game_root_fn: Callable returning the current game root path (may be empty). log_fn: Callable used for status / error messages. """ def __init__( self, parent: QWidget | None = None, *, game_root_fn: Callable[[], str], log_fn: Callable[[str], None], ): super().__init__(parent) self._game_root_fn = game_root_fn self._log = log_fn self._custom_skill_editors: dict[str, QTextEdit] = {} self.vocab_editor: QTextEdit | None = None self.quirks_editor: QTextEdit | None = None self.game_skill_editor: QTextEdit | None = None self._build_ui() def _build_ui(self) -> None: root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) editors = QTabWidget() editors.setObjectName("setupEditors") editors.setDocumentMode(False) tab_bar = editors.tabBar() tab_bar.setExpanding(False) tab_bar.setMinimumHeight(34) tab_bar.setStyleSheet( "QTabBar::tab{background:#333337;color:#c0c0c0;min-width:90px;min-height:28px;" "padding:6px 16px;border:1px solid #3c3c3c;border-bottom:none;" "margin-right:3px;font-size:12px;font-weight:bold;}" "QTabBar::tab:selected{background:#1e1e1e;color:#4ec9b0;" "border-top:2px solid #4ec9b0;}" "QTabBar::tab:hover{color:#ffffff;}" ) editors.setStyleSheet( "QTabWidget::pane{border:1px solid #3c3c3c;background:#1e1e1e;}" ) editors.addTab( self._editor_page( "data/vocab.txt (game section)", "Paste the glossary code block. Format: Japanese (English) - notes", self._save_vocab, self._reload_vocab, "vocab_editor", ), "Vocab", ) editors.addTab( self._editor_page( "/skills/quirks.md", "Paste the translation_quirks block. Merged onto the system prompt at translate time.", self._save_quirks, self._reload_quirks, "quirks_editor", ), "Quirks", ) game_skills_page = QWidget() gs_layout = QVBoxLayout(game_skills_page) gs_layout.setContentsMargins(0, 0, 0, 0) gs_layout.setSpacing(0) self._game_skills_bar = QTabBar() self._game_skills_bar.setDocumentMode(True) self._game_skills_bar.setExpanding(False) self._game_skills_bar.setDrawBase(False) self._game_skills_bar.setMinimumHeight(30) self._game_skills_bar.setStyleSheet( "QTabBar::tab{background:#2d2d30;color:#9d9d9d;padding:6px 12px;" "border:1px solid #3c3c3c;border-bottom:none;margin-right:2px;}" "QTabBar::tab:selected{background:#1e1e1e;color:#e0e0e0;}" "QTabBar::tab:hover{color:#ffffff;}" ) self._game_skills_stack = QStackedWidget() self._game_skills_stack.setStyleSheet("background:#1e1e1e;") self._game_skills_bar.currentChanged.connect( self._game_skills_stack.setCurrentIndex ) strip = QWidget() strip.setStyleSheet("background-color:#252526;") strip_l = QHBoxLayout(strip) strip_l.setContentsMargins(8, 6, 10, 0) strip_l.setSpacing(6) strip_l.addWidget(self._game_skills_bar, 0, Qt.AlignBottom) add_btn = QPushButton("+ Add custom") add_btn.setCursor(Qt.PointingHandCursor) add_btn.setFixedHeight(30) add_btn.setToolTip( "Create a custom skills/*.md overlay.\n" "Merged into the translation system prompt - can hurt quality." ) add_btn.setStyleSheet( "QPushButton{background-color:#2d2d30;color:#4ec9b0;" "border:1px solid #3c3c3c;border-bottom:none;" "border-top-left-radius:3px;border-top-right-radius:3px;" "padding:0 12px;font-size:12px;font-weight:bold;}" "QPushButton:hover{background-color:#3a3a3a;color:#7ee0c8;" "border-color:#4ec9b0;}" "QPushButton:pressed{background-color:#1e1e1e;}" ) add_btn.clicked.connect(self._add_custom_skill) strip_l.addWidget(add_btn, 0, Qt.AlignBottom) strip_l.addStretch(1) gs_layout.addWidget(strip) gs_layout.addWidget(self._game_skills_stack, 1) self._add_game_skill_page( "game", self._editor_page( "/skills/game.md", "Paste the game_skill Translation Frame. Merged into the translation " "system prompt (before quirks) when this game folder is selected.", self._save_game_skill, self._reload_game_skill, "game_skill_editor", ), ) editors.addTab(game_skills_page, "Game skills") root.addWidget(editors, 1) def _editor_page( self, path_hint: str, tip: str, save_slot, reload_slot, attr: str, ) -> QWidget: page = QWidget() vl = QVBoxLayout(page) vl.setContentsMargins(8, 8, 8, 8) vl.setSpacing(6) tip_lbl = QLabel(tip) tip_lbl.setWordWrap(True) tip_lbl.setStyleSheet("color:#9d9d9d;font-size:12px;") vl.addWidget(tip_lbl) path_lbl = QLabel(path_hint) path_lbl.setStyleSheet( "color:#569cd6;font-size:11px;font-family:Consolas,monospace;" ) vl.addWidget(path_lbl) ed = _PlainPasteTextEdit() ed.setMinimumHeight(160) ed.setFont(QFont("Consolas", 9)) ed.setStyleSheet( "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;" "border:none;padding:8px;" "selection-background-color:#264f78;}" ) setattr(self, attr, ed) vl.addWidget(ed, 1) row = QHBoxLayout() row.setSpacing(8) save_btn = _make_btn("💾 Save", "#3a7a3a") save_btn.setFixedWidth(110) save_btn.clicked.connect(save_slot) row.addWidget(save_btn) reload_btn = _make_btn("↺ Reload", "#555") reload_btn.setFixedWidth(110) reload_btn.clicked.connect(reload_slot) row.addWidget(reload_btn) row.addStretch() vl.addLayout(row) return page def reload_all(self) -> None: """Reload vocab, quirks, game skill, and custom skill tabs from disk.""" self._reload_vocab() self._reload_quirks() self._reload_game_skill() self._reload_custom_skills() def _game_root(self) -> str: return (self._game_root_fn() or "").strip() def _game_root_or_warn(self) -> str | None: root = self._game_root() if not root: self._log("⚠ Select a game folder in Step 0 first.") return None return root def _reload_vocab(self) -> None: if self.vocab_editor is None: return try: self.vocab_editor.setPlainText(read_game_vocab()) except Exception as exc: self._log(f"❌ Could not load vocab.txt: {exc}") def _save_vocab(self) -> None: if self.vocab_editor is None: return try: write_game_vocab(self.vocab_editor.toPlainText()) self._log("✅ vocab.txt saved (base terms from vocab_base.txt appended).") except Exception as exc: self._log(f"❌ Could not save vocab.txt: {exc}") def _reload_quirks(self) -> None: if self.quirks_editor is None: return path = quirks_path_for_game(self._game_root()) if not path or not path.is_file(): self.quirks_editor.setPlainText("") return try: self.quirks_editor.setPlainText(path.read_text(encoding="utf-8")) except Exception as exc: self._log(f"❌ Could not load skills/quirks.md: {exc}") def _save_quirks(self) -> None: root = self._game_root_or_warn() if not root or self.quirks_editor is None: return path = quirks_path_for_game(root) try: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( self.quirks_editor.toPlainText().rstrip() + "\n", encoding="utf-8" ) self._log(f"✅ Saved {path}") except Exception as exc: self._log(f"❌ Could not save skills/quirks.md: {exc}") def _reload_game_skill(self) -> None: if self.game_skill_editor is None: return path = game_skill_path_for_game(self._game_root()) if not path or not path.is_file(): self.game_skill_editor.setPlainText("") return try: raw = path.read_text(encoding="utf-8") fixed = migrate_game_skill_text(raw) self.game_skill_editor.setPlainText(fixed) if fixed != raw: path.write_text(fixed.rstrip() + "\n", encoding="utf-8") self._log( f"↺ Updated legacy sections in {path.name} for API use" ) except Exception as exc: self._log(f"❌ Could not load game skill: {exc}") def _save_game_skill(self) -> None: root = self._game_root_or_warn() if not root or self.game_skill_editor is None: return path = game_skill_path_for_game(root) try: path.parent.mkdir(parents=True, exist_ok=True) text = migrate_game_skill_text(self.game_skill_editor.toPlainText()) self.game_skill_editor.setPlainText(text) path.write_text(text.rstrip() + "\n", encoding="utf-8") self._log(f"✅ Saved {path}") except Exception as exc: self._log(f"❌ Could not save game skill: {exc}") def _add_game_skill_page(self, title: str, page: QWidget) -> int: idx = self._game_skills_stack.addWidget(page) self._game_skills_bar.addTab(title) return idx def _select_game_skill_tab(self, title: str) -> None: for i in range(self._game_skills_bar.count()): if self._game_skills_bar.tabText(i) == title: self._game_skills_bar.setCurrentIndex(i) return def _custom_skill_editor_page(self, stem: str) -> QWidget: page = QWidget() vl = QVBoxLayout(page) vl.setContentsMargins(8, 8, 8, 8) vl.setSpacing(6) tip = QLabel( "Custom skill - merged into the translation system prompt. " "Extra or conflicting rules can hurt quality." ) tip.setWordWrap(True) tip.setStyleSheet("color:#c9a227;font-size:12px;") vl.addWidget(tip) path_lbl = QLabel(f"/skills/{stem}.md") path_lbl.setStyleSheet( "color:#569cd6;font-size:11px;font-family:Consolas,monospace;" ) vl.addWidget(path_lbl) ed = _PlainPasteTextEdit() ed.setMinimumHeight(160) ed.setFont(QFont("Consolas", 9)) ed.setStyleSheet( "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;" "border:none;padding:8px;" "selection-background-color:#264f78;}" ) self._custom_skill_editors[stem] = ed vl.addWidget(ed, 1) row = QHBoxLayout() row.setSpacing(8) save_btn = _make_btn("💾 Save", "#3a7a3a") save_btn.setFixedWidth(110) save_btn.clicked.connect(lambda _=False, s=stem: self._save_custom_skill(s)) row.addWidget(save_btn) reload_btn = _make_btn("↺ Reload", "#555") reload_btn.setFixedWidth(110) reload_btn.clicked.connect( lambda _=False, s=stem: self._reload_one_custom_skill(s) ) row.addWidget(reload_btn) delete_btn = _make_btn("🗑 Delete", "#8b0000") delete_btn.setFixedWidth(110) delete_btn.setToolTip("Delete this custom skill file from the game folder") delete_btn.clicked.connect(lambda _=False, s=stem: self._delete_custom_skill(s)) row.addWidget(delete_btn) row.addStretch() vl.addLayout(row) return page def _reload_custom_skills(self) -> None: bar = getattr(self, "_game_skills_bar", None) stack = getattr(self, "_game_skills_stack", None) if bar is None or stack is None: return while bar.count() > 1: bar.removeTab(1) while stack.count() > 1: w = stack.widget(1) stack.removeWidget(w) if w is not None: w.deleteLater() self._custom_skill_editors = {} for path in list_custom_skill_paths(self._game_root()): stem = path.stem page = self._custom_skill_editor_page(stem) self._add_game_skill_page(stem, page) try: self._custom_skill_editors[stem].setPlainText( path.read_text(encoding="utf-8") ) except Exception as exc: self._log(f"❌ Could not load skills/{stem}.md: {exc}") def _reload_one_custom_skill(self, stem: str) -> None: path = custom_skill_path_for_game(self._game_root(), stem) ed = self._custom_skill_editors.get(stem) if ed is None: return if not path or not path.is_file(): ed.setPlainText("") return try: ed.setPlainText(path.read_text(encoding="utf-8")) except Exception as exc: self._log(f"❌ Could not load skills/{stem}.md: {exc}") def _save_custom_skill(self, stem: str) -> None: root = self._game_root_or_warn() if not root: return path = custom_skill_path_for_game(root, stem) ed = self._custom_skill_editors.get(stem) if path is None or ed is None: self._log("❌ Invalid custom skill.") return try: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(ed.toPlainText().rstrip() + "\n", encoding="utf-8") self._log(f"✅ Saved {path}") except Exception as exc: self._log(f"❌ Could not save skills/{stem}.md: {exc}") def _add_custom_skill(self) -> None: root = self._game_root_or_warn() if not root: return warn = QMessageBox(self) warn.setIcon(QMessageBox.Warning) warn.setWindowTitle("Custom skill - quality warning") warn.setTextFormat(Qt.RichText) warn.setText( "Custom skills are merged into the translation system prompt.

" "Extra or poorly written skills can distract the model and hurt " "translation quality (conflicting rules, prompt bloat, diluted quirks).

" "Prefer quirks.md for voice rules and game.md " "for the Translation Frame. Add a custom skill only if you need a rare, tightly scoped " "overlay - at your own risk." ) warn.setStandardButtons(QMessageBox.Cancel | QMessageBox.Ok) warn.button(QMessageBox.Ok).setText("I understand - continue") warn.setStyleSheet( "QMessageBox{background-color:#252526;}" "QLabel{color:#d4d4d4;font-size:13px;min-width:420px;}" ) if warn.exec_() != QMessageBox.Ok: return name, ok = QInputDialog.getText( self, "New custom skill", "Skill file name (letters, numbers, ._- ; saved as /skills/.md):", ) if not ok: return stem = sanitize_custom_skill_stem(name) if not stem: QMessageBox.warning( self, "Invalid name", "Use a short name like battle-log or honorifics_extra.\n" "Reserved: quirks, game, translation.", ) return if stem in self._custom_skill_editors: QMessageBox.information( self, "Already exists", f"Custom skill '{stem}' is already open." ) self._select_game_skill_tab(stem) return path = custom_skill_path_for_game(root, stem) if path is None: return if path.is_file(): self._reload_custom_skills() self._select_game_skill_tab(stem) return try: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( f"# {stem}\n\n" "# Keep this short and specific. Conflicting rules hurt quality.\n", encoding="utf-8", ) except Exception as exc: self._log(f"❌ Could not create skills/{stem}.md: {exc}") return page = self._custom_skill_editor_page(stem) idx = self._add_game_skill_page(stem, page) self._game_skills_bar.setCurrentIndex(idx) self._reload_one_custom_skill(stem) self._log(f"✅ Created custom skill {path}") def _delete_custom_skill(self, stem: str) -> None: root = self._game_root_or_warn() if not root: return path = custom_skill_path_for_game(root, stem) reply = QMessageBox.question( self, "Delete custom skill", f"Delete skills/{stem}.md from the game folder?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if reply != QMessageBox.Yes: return try: if path and path.is_file(): path.unlink() self._log(f"🗑 Deleted {path}") except Exception as exc: self._log(f"❌ Could not delete skills/{stem}.md: {exc}") return self._reload_custom_skills()