""" RPGMaker Workflow Tab - Automation hub for the full translation pipeline. Provides a guided, step-by-step interface: Step 0 – Select game project folder and import data files into files/ Step 1 – (Optional) Pre-process game files Step 2 – Setup: speaker flags, Project Setup skill, vocab / quirks / game skill Step 3 – Translation: Phase 0 (DB), Phase 1 (dialogue), Phase 1b (111 cache) Step 4 – Translation Phase 2 (risky codes) Step 5 – Plugins.js prompt helpers + export translated/ to the game Step 6 – Install TL Inspector and/or Forge playtest plugins """ from __future__ import annotations import json import os import sys import threading from pathlib import Path from util.paths import VOCAB_PATH, APP_NAME, ORG_NAME from util.skills import load_project_setup from util.vocab import BASE_SEPARATOR as _SHARED_BASE_SEPARATOR import jsbeautifier from PyQt5.QtCore import Qt, QEvent, QSettings, QSize, QThread, QTimer, pyqtSignal from PyQt5.QtGui import QFont from PyQt5.QtWidgets import ( QApplication, QCheckBox, QComboBox, QFileDialog, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QSplitter, QStyle, QTabWidget, QTextEdit, QToolButton, QVBoxLayout, QWidget, QAbstractItemView, ) from gui.setup_skills_editors import SetupSkillsEditors from gui.translation_tab import ( BATCH_MODE_LABEL, BATCH_MODE_BENEFIT_NOTE, BATCH_COLLECT_LIVE_CHARGE_NOTE, ) WORKFLOW_TL_NORMAL_LABEL = "Normal Translate" # --------------------------------------------------------------------------- # Phase profiles applied to rpgmakermvmz.py before each translation run # --------------------------------------------------------------------------- # Core database files — translated first (names/descriptions) _DB_FILES = { "Actors.json", "Armors.json", "Classes.json", "Enemies.json", "Items.json", "MapInfos.json", "Skills.json", "States.json", "System.json", "Weapons.json", } # Event files — translated in phases 1 / 1b / 2 _EVENT_FILES_EXACT = {"CommonEvents.json", "Troops.json"} # Any Map????.json is also an event file (matched by prefix below) PHASE0_CONFIG = { # All event codes OFF — DB files use top-level name/description fields "CODE101": False, "CODE401": False, "CODE405": False, "CODE102": False, "CODE408": False, "CODE111": False, "CODE122": False, "CODE357": False, "CODE355655": False, "CODE657": False, "CODE356": False, "CODE320": False, "CODE324": False, "CODE325": False, "CODE108": False, } PHASE1_CONFIG = { # Safe dialogue / choices "CODE101": True, "CODE401": True, "CODE405": True, "CODE102": True, "CODE408": True, # Risky codes OFF "CODE122": False, "CODE355655": False, "CODE357": False, "CODE657": False, "CODE356": False, "CODE320": False, "CODE324": False, "CODE325": False, "CODE111": False, "CODE108": False, } PHASE1B_CONFIG = { # Dialogue OFF (handled by Phase 1) "CODE101": False, "CODE401": False, "CODE405": False, "CODE102": False, "CODE408": False, # Only 111 ON — build the var-translation cache from string comparisons "CODE111": True, "CODE122": False, "CODE357": False, "CODE355655": False, "CODE657": False, "CODE356": False, "CODE320": False, "CODE324": False, "CODE325": False, "CODE108": False, } PHASE2_CONFIG = { # Dialogue OFF (already handled by Phase 1) "CODE101": False, "CODE401": False, "CODE405": False, "CODE102": False, "CODE408": False, # Risky codes ON (111 OFF — cache already built by Phase 1b) "CODE122": True, "CODE357": True, "CODE111": False, "CODE356": False, # plugin cmd — user can enable manually if needed "CODE108": False, # comment — rarely needed } # ───────────────────────────────────────────────────────────────────────────── # Background workers # ───────────────────────────────────────────────────────────────────────────── class _ScanWorker(QThread): """Run project_scanner.list_data_files in a thread.""" done = pyqtSignal(object) # list[dict] error = pyqtSignal(str) def __init__(self, data_path: str, engine: str): super().__init__() self.data_path = data_path self.engine = engine def run(self): try: from util.project_scanner import list_data_files result = list_data_files(self.data_path, self.engine) self.done.emit(result) except Exception as exc: self.error.emit(str(exc)) class _ImportWorker(QThread): """Copy selected files into files/ directory.""" done = pyqtSignal(int, list) # count_copied, errors log = pyqtSignal(str) def __init__(self, file_items: list[dict], dest_dir: str): super().__init__() self.file_items = file_items self.dest_dir = dest_dir def run(self): try: import shutil from util.project_scanner import import_to_files # Clear existing files/ contents before importing so stale files # from a previous game don't linger. translated/ is intentionally # left untouched. dest = Path(self.dest_dir) if dest.exists(): removed = 0 for fp in dest.iterdir(): if fp.name == ".gitkeep": continue if fp.is_file(): try: fp.unlink() removed += 1 except Exception as e: self.log.emit(f" ⚠ Could not remove {fp.name}: {e}") elif fp.is_dir(): try: shutil.rmtree(fp) removed += 1 except Exception as e: self.log.emit(f" ⚠ Could not remove {fp.name}: {e}") if removed: self.log.emit(f"Cleared {removed} existing file(s) from {dest.name}/") self.log.emit(f"Importing {len(self.file_items)} file(s) into files/ …") count, errors = import_to_files(self.file_items, self.dest_dir) self.done.emit(count, errors) except Exception as exc: self.done.emit(0, [str(exc)]) class _ExportWorker(QThread): done = pyqtSignal(int, list) log = pyqtSignal(str) def __init__(self, game_data_path: str, filter_names: list[str] | None = None): super().__init__() self.game_data_path = game_data_path self.filter_names = filter_names # if set, only export these filenames def run(self): try: from util.project_scanner import export_to_game if self.filter_names: self.log.emit( f"Exporting {len(self.filter_names)} active file(s) → {self.game_data_path} …" ) else: self.log.emit(f"Exporting translated/ → {self.game_data_path} …") count, errors = export_to_game( "translated", self.game_data_path, filenames=self.filter_names ) self.done.emit(count, errors) except Exception as exc: self.done.emit(0, [str(exc)]) class _SubprocessWorker(QThread): """Run an arbitrary shell command in a given working directory, streaming output.""" done = pyqtSignal(bool, str) # success, final message log = pyqtSignal(str) def __init__(self, cmd: list, cwd: str, label: str = ""): super().__init__() self.cmd = cmd self.cwd = cwd self.label = label or cmd[0] def run(self): import subprocess import shutil as _shutil try: exe = _shutil.which(self.cmd[0]) if exe is None: self.done.emit( False, f"'{self.cmd[0]}' not found on PATH. " "Make sure it is installed and accessible from the terminal.", ) return self.log.emit(f"$ {' '.join(str(c) for c in self.cmd)} — cwd: {self.cwd}") proc = subprocess.Popen( self.cmd, cwd=self.cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", ) for line in proc.stdout: stripped = line.rstrip("\n") if stripped: self.log.emit(stripped) proc.wait() if proc.returncode == 0: self.done.emit(True, f"{self.label}: finished successfully.") else: self.done.emit(False, f"{self.label}: exited with code {proc.returncode}.") except Exception as exc: self.done.emit(False, f"{self.label}: {exc}") class _JsonFormatWorker(QThread): """Format all JSON files in a directory using the bundled dazedformat utility.""" done = pyqtSignal(bool, str) log = pyqtSignal(str) def __init__(self, data_path: str): super().__init__() self.data_path = data_path def run(self): try: from util.dazedformat import format_json_files self.log.emit(f"Formatting JSON files in {self.data_path} …") count, errors = format_json_files(self.data_path, log=self.log.emit) for e in errors: self.log.emit(f" ⚠ {e}") if errors: self.done.emit(False, f"dazedformat: {count} formatted, {len(errors)} error(s).") else: self.done.emit(True, f"dazedformat: {count} file(s) formatted successfully.") except Exception as exc: self.done.emit(False, f"dazedformat error: {exc}") # Never copy these into a game root when applying gameupdate/ (local updater state # or stray translator assets that must not overwrite a live install). _GAMEUPDATE_COPY_SKIP_NAMES = frozenset({ "previous_patch_sha.txt", }) class _FileCopyWorker(QThread): """Recursively copy a source folder into a destination folder.""" done = pyqtSignal(int, list) # count_copied, errors log = pyqtSignal(str) def __init__(self, src: str, dst: str, skip_names: frozenset[str] | None = None): super().__init__() self.src = src self.dst = dst self.skip_names = skip_names or frozenset() def run(self): import shutil src = Path(self.src) dst = Path(self.dst) if not src.is_dir(): self.done.emit(0, [f"Source folder not found: {src}"]) return dst.mkdir(parents=True, exist_ok=True) copied = 0 errors: list[str] = [] self.log.emit(f"Copying {src} → {dst} …") for fp in src.rglob("*"): if not fp.is_file(): continue if fp.name in self.skip_names: self.log.emit(f" skipped {fp.relative_to(src)}") continue rel = fp.relative_to(src) target = dst / rel try: target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(fp, target) copied += 1 self.log.emit(f" copied {rel}") except Exception as exc: errors.append(f"{rel}: {exc}") self.done.emit(copied, errors) class _JsFormatWorker(QThread): """Format a JavaScript file using jsbeautifier (pure Python, no Node required).""" done = pyqtSignal(bool, str) log = pyqtSignal(str) def __init__(self, js_path: str): super().__init__() self.js_path = js_path def run(self): try: p = Path(self.js_path) self.log.emit(f"Formatting {p.name} …") original = p.read_text(encoding="utf-8") opts = jsbeautifier.default_options() opts.indent_size = 2 opts.indent_char = " " opts.max_preserve_newlines = 2 opts.preserve_newlines = True opts.end_with_newline = True formatted = jsbeautifier.beautify(original, opts) p.write_text(formatted, encoding="utf-8") self.done.emit(True, f"plugins.js formatted successfully ({len(formatted):,} chars).") except Exception as exc: self.done.emit(False, f"Format error: {exc}") def _make_section_label(text: str) -> QLabel: lbl = QLabel(text) lbl.setStyleSheet( "font-size: 14px; font-weight: bold; color: #e0e0e0;" "padding: 7px 0px 6px 10px; background-color: transparent;" "border-left: 3px solid #007acc; margin-top: 6px;" ) return lbl def _make_help_button() -> QToolButton: """Small circular ? control for step help dialogs.""" btn = QToolButton() btn.setText("?") btn.setFixedSize(24, 24) btn.setCursor(Qt.PointingHandCursor) btn.setToolTip("How to use this step") btn.setStyleSheet( "QToolButton{" "background-color:#2d2d30;color:#9d9d9d;border:1px solid #555;" "border-radius:12px;font-size:12px;font-weight:bold;padding:0;}" "QToolButton:hover{background-color:#3a3a3a;color:#ffffff;border-color:#007acc;}" "QToolButton:pressed{background-color:#1e1e1e;}" ) return btn # Per-step help copy shown by the header ? button (keeps step UIs compact). _STEP_HELP: dict[int, str] = { 0: ( "Step 0 - Project

" "Select the game root folder (the folder that contains the game's data files).

" "The tool detects MV/MZ/Ace layout, lists JSON files, and lets you import them into " "files/ for translation.

" "Tip: when switching games, clear files/ and translated/ " "so old project data does not mix in." ), 1: ( "Step 1 - Pre-process (optional)

" "Optional prep against the game folder before importing:
" "• Format data JSON (dazedformat)
" "• Format plugins.js (MV/MZ) for easier editing
" "• Copy a gameupdate/ folder into the project

" "Paths auto-fill when a project folder is detected. " "Skip this step if you do not need those tasks." ), 2: ( "Step 2 - Setup (Speakers + Glossary)

" "1. Speaker flags - enable the formats this game uses " "(INLINE401 / FIRSTLINE / FACENAME). Tooltips explain each flag. " "Project Setup's speakers block can recommend ENABLE/SKIP.

" "2. Parse Speakers - collect speaker names from event files into " "vocab.txt (# Speakers). Does not translate dialogue.

" "3. Copy Project Setup - paste into Cursor/Copilot with the game repo open. " "The AI returns labeled code blocks:
" "• glossary → Vocab tab
" "• speakers → apply flags above
" "• translation_quirks → Quirks tab " "(skills/quirks.md)
" "• game_skill → Game skills → translation tab " "(skills/game.md - Translation Frame, merged into the API prompt)

" "Optional: + on Game skills adds custom skills/*.md " "overlays (also merged into the API prompt - can hurt quality; use sparingly).

" "Use one game folder per translation run so frame/quirks stay in the prompt cache." ), 3: ( "Step 3 - TL Phase 1

" "Safe dialogue / database translation.

" "• Set text-wrap widths if needed (or copy the wrap analysis prompt)
" "• Phase 0 - database names/descriptions
" "• Phase 1 - dialogue / choices
" "• Phase 1b - build the code 111 variable-translation cache

" "Choose Normal or Batch mode at the top. Run each phase from the Translation tab " "after the workflow applies the phase profile." ), 4: ( "Step 4 - TL Phase 2

" "Risky / plugin-related codes (variables, plugin commands, etc.).

" "Copy the Plugin Prompt and audit the game first, then enable only codes that " "contain player-visible text. Set code 122 variable ID ranges carefully - " "translating IDs used as logic keys will break the game." ), 5: ( "Step 5 - Plugins / Scripts & Export

" "MV/MZ - Plugins (optional before shipping): copy vocab.txt into " "the game folder, then copy the plugins.js prompt and run it in your IDE with " "plugins.js attached. Only translate player-visible UI strings - never " "plugin parameter keys or internal identifiers.

" "Ace - Ruby scripts (instead of plugins.js): RV2JSON unpacks " "Data/Scripts.rvdata2 into ace_json/scripts/*.rb. " "Copy vocab + the Ace scripts prompt, edit those .rb files in Cursor/VS Code " "the same way you would edit plugins.js (player-visible strings only). " "Then use Export - the tool runs RV2JSON -u and packs " "ace_json/ (including scripts) back into Data/*.rvdata2.

" "Export - copy finished translations from translated/ back " "into the game's data folder:
" "• Export Active Files - only names currently in files/
" "• Export ALL - everything under translated/" ), 6: ( "Step 6 - Playtest

" "Install playtest plugins into the MV/MZ game:
" "• TL Inspector - inspect translated text in-game
" "• Forge - additional playtest helpers

" "Configure hotkeys/UI scale, then Install / Uninstall as needed. " "Not available for Ace projects." ), } def _show_step_help(parent: QWidget | None, title: str, html: str) -> None: box = QMessageBox(parent) box.setWindowTitle(title) box.setIcon(QMessageBox.Information) box.setTextFormat(Qt.RichText) box.setText(html) box.setStandardButtons(QMessageBox.Ok) box.setStyleSheet( "QMessageBox{background-color:#252526;}" "QLabel{color:#d4d4d4;font-size:13px;min-width:420px;}" ) box.exec_() def _make_hr() -> QFrame: hr = QFrame() hr.setFrameShape(QFrame.HLine) hr.setFrameShadow(QFrame.Plain) hr.setStyleSheet("QFrame { color: #333333; margin: 10px 0px 4px 0px; }") return hr def _make_btn(text: str, color: str = "#007acc") -> QPushButton: """Button styled to match the translation tab. Dark utility colours (max channel < 115) use the flat sidebar style. Action colours use flat dark bg + coloured outline + coloured text. """ 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: # Flat dark bg + coloured outline + lightened text 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};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 _ACTION_BTN_STYLE = ( "QPushButton{background-color:#2d2d30;color:white;" "font-weight:bold;font-size:12px;border:1px solid #555555;" "border-radius:4px;padding:4px 10px;" "min-height:36px;max-height:36px;}" "QPushButton:hover{background-color:#3e3e42;border-color:#007acc;}" "QPushButton:pressed{background-color:#007acc;}" "QPushButton:disabled{background-color:#2d2d30;color:#555555;border-color:#444444;}" ) def _make_text_btn(label: str, tooltip: str = "", *, min_width: int = 52) -> QPushButton: """Compact labeled button for file-list actions (All / None / Core / Import).""" btn = QPushButton(label) btn.setToolTip(tooltip) btn.setFont(QFont("Segoe UI", 10)) btn.setMinimumWidth(min_width) btn.setFixedHeight(36) btn.setStyleSheet(_ACTION_BTN_STYLE) return btn def _make_icon_btn(icon_text: str, tooltip: str = "") -> QPushButton: """Compact icon-only button (e.g. folder browse).""" from gui import qt_icons btn = QPushButton() qt_icons.apply_button_icon(btn, icon_text, color="#dddddd") btn.setIconSize(QSize(18, 18)) btn.setToolTip(tooltip) btn.setFont(QFont("Segoe UI", 12)) btn.setFixedSize(40, 36) btn.setStyleSheet( "QPushButton{background-color:#2d2d30;color:white;" "font-weight:bold;font-size:16px;border:1px solid #555555;" "border-radius:4px;min-width:40px;max-width:40px;" "min-height:36px;max-height:36px;}" "QPushButton:hover{background-color:#3e3e42;border-left-color:#007acc;}" "QPushButton:pressed{background-color:#007acc;}" "QPushButton:disabled{background-color:#2d2d30;color:#555555;border-color:#444444;}" ) return btn # ───────────────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────────── # Main widget # ───────────────────────────────────────────────────────────────────────────── class WorkflowTab(QWidget): """Guided automation tab for the full RPGMaker translation workflow.""" def __init__(self, parent=None): super().__init__(parent) self.parent_window = parent try: self.settings = QSettings(ORG_NAME, APP_NAME) except Exception: self.settings = None # State self._data_path: str | None = None self._engine: str = "MVMZ" self._file_items: list[dict] = [] self._worker = None # active background QThread # Pre-process paths (auto-populated after folder detection) self._plugins_js_path: str = "" self._gameupdate_path: str = "" # RPGMaker Ace state self._ace_encrypted: bool = False self._ace_json_dir: str = "" # /JSON/ — used as _data_path for Ace self._ace_rvdata_dir: str = "" # /Data/ with rvdata2 files self._p2_loading_config: bool = False self._p2_auto_apply_timer: QTimer | None = None self._syncing_file_checks: bool = False self._import_buttons: list[QPushButton] = [] self._current_step_index: int = 0 self._last_import_signature: tuple[str, ...] | None = None self._pending_import_signature: tuple[str, ...] | None = None self._init_ui() # ───────────────────────────────── UI setup ────────────────────────────── def _init_ui(self): root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) # Splitter: left=steps, right=log splitter = QSplitter(Qt.Horizontal) splitter.setHandleWidth(1) splitter.setStyleSheet("QSplitter::handle{background:#3a3a3a;}") # ---- Left: tabbed step panels ---- _SCROLL_STYLE = ( "QScrollArea{border:none;background-color:transparent;}" "QScrollBar:vertical{background:#252526;width:10px;border:none;}" "QScrollBar::handle:vertical{background:#555555;border-radius:5px;min-height:20px;}" "QScrollBar::handle:vertical:hover{background:#007acc;}" "QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical{height:0;}" ) self._step_tabs = QTabWidget() self._step_tabs.setDocumentMode(True) self._step_tabs.tabBar().setVisible(False) self._step_tabs.setStyleSheet(""" QTabWidget::pane { border: none; background-color: #1e1e1e; top: 0; } QTabBar { height: 0; max-height: 0; } """) # Compact always-visible strip - avoids the finicky overflow scroll arrows. self._step_labels: list[str] = [] self._step_done: set[int] = set() self._step_buttons: list[QToolButton] = [] self._step_strip = QWidget() self._step_strip.setObjectName("mvStepStrip") self._step_strip.setStyleSheet(""" QWidget#mvStepStrip { background-color: #252526; border-bottom: 1px solid #3a3a3a; } QToolButton { background-color: transparent; color: #8a8a8a; border: none; border-right: 1px solid #333333; padding: 6px 2px; font-size: 11px; font-weight: 600; } QToolButton:hover { background-color: #2d2d30; color: #d0d0d0; } QToolButton:checked { background-color: #1e1e1e; color: #ffffff; border-top: 2px solid #007acc; padding-top: 4px; } QToolButton[done="true"] { color: #6a9955; } QToolButton[done="true"]:checked { color: #ffffff; } """) strip_layout = QHBoxLayout(self._step_strip) strip_layout.setContentsMargins(0, 0, 0, 0) strip_layout.setSpacing(0) _tab_defs = [ ("0 Project", self._build_step0), ("1 Pre-process", self._build_step1_preprocess), ("2 Setup", self._build_step2_setup), ("3 TL Phase 1", self._build_step4_translation), ("4 TL Phase 2", self._build_step5_tl_phase2), ("5 Export", self._build_step5_finish), ("6 Playtest", self._build_step8_playtest), ] self._step_labels = [label for label, _ in _tab_defs] for tab_label, builder in _tab_defs: # Each tab: outer page → scroll area → inner content widget page = QWidget() page_layout = QVBoxLayout(page) page_layout.setContentsMargins(0, 0, 0, 0) page_layout.setSpacing(0) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setStyleSheet(_SCROLL_STYLE) inner = QWidget() vbox = QVBoxLayout(inner) vbox.setContentsMargins(24, 18, 24, 12) vbox.setSpacing(10) builder(vbox) vbox.addStretch() scroll.setWidget(inner) page_layout.addWidget(scroll, 1) # ── Navigation footer ────────────────────────────────────────── nav = QWidget() nav.setStyleSheet( "QWidget{background-color:#252526;" "border-top:1px solid #3a3a3a;}" ) nav_layout = QHBoxLayout(nav) nav_layout.setContentsMargins(16, 6, 16, 6) nav_layout.setSpacing(8) tab_idx = len(self._step_tabs) # current tab index (before addTab) if tab_idx > 0: back_btn = _make_btn("← Back", "#3a3a3a") back_btn.setFixedWidth(100) _idx = tab_idx # capture for lambda back_btn.clicked.connect( lambda _checked, i=_idx: self._goto_step(i - 1) ) nav_layout.addWidget(back_btn) nav_layout.addStretch() if tab_idx < len(_tab_defs) - 1: next_btn = _make_btn("Next →", "#007acc") next_btn.setFixedWidth(100) _idx = tab_idx # capture for lambda next_btn.clicked.connect( lambda _checked, i=_idx: self._advance_step(i) ) nav_layout.addWidget(next_btn) page_layout.addWidget(nav) self._step_tabs.addTab(page, tab_label) btn = QToolButton() btn.setText(self._step_strip_label(tab_idx, done=False)) btn.setCheckable(True) btn.setAutoExclusive(True) btn.setToolButtonStyle(Qt.ToolButtonTextOnly) btn.setToolTip(tab_label) btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) btn.setMinimumHeight(40) btn.clicked.connect(lambda _c=False, i=tab_idx: self._goto_step(i)) strip_layout.addWidget(btn, 1) self._step_buttons.append(btn) self._step_tabs.currentChanged.connect(self._on_step_tab_changed) if self._step_buttons: self._step_buttons[0].setChecked(True) steps_host = QWidget() steps_host_layout = QVBoxLayout(steps_host) steps_host_layout.setContentsMargins(0, 0, 0, 0) steps_host_layout.setSpacing(0) steps_host_layout.addWidget(self._step_strip) steps_host_layout.addWidget(self._step_tabs, 1) splitter.addWidget(steps_host) # ---- Right: log area ---- log_panel = QWidget() lp_layout = QVBoxLayout(log_panel) lp_layout.setContentsMargins(0, 0, 0, 0) lp_layout.setSpacing(0) log_header = QLabel(" ▸ Workflow Log") log_header.setStyleSheet( "background-color:#252526;color:#9d9d9d;font-size:11px;font-weight:bold;" "padding:7px 10px;border-bottom:1px solid #3a3a3a;" "letter-spacing:0.5px;" ) lp_layout.addWidget(log_header) self.log_area = QTextEdit() self.log_area.setReadOnly(True) self.log_area.setFont(QFont("Consolas", 9)) self.log_area.setStyleSheet( "QTextEdit{background-color:#1e1e1e;color:#c8c8c8;" "border:none;padding:10px;" "selection-background-color:#264f78;}" ) lp_layout.addWidget(self.log_area) clear_btn = _make_btn("Clear Log", "#3a3a3a") clear_btn.setStyleSheet( "QPushButton{background-color:#252526;color:#6a6a6a;" "border:none;border-top:1px solid #3a3a3a;" "padding:5px 12px;font-size:11px;font-weight:normal;" "font-family:'Segoe UI','Arial',sans-serif;}" "QPushButton:hover{background-color:#2d2d30;color:#9d9d9d;}" "QPushButton:pressed{color:#cccccc;}" ) clear_btn.clicked.connect(self.log_area.clear) lp_layout.addWidget(clear_btn) splitter.addWidget(log_panel) splitter.setSizes([900, 200]) root.addWidget(splitter) self.setLayout(root) self._apply_theme() self._detected_on_show: bool = False # guard: only auto-detect once per new folder # ── Tab visibility ────────────────────────────────────────────────────── def showEvent(self, event): """Trigger folder detection the first time this tab is shown (or after a new folder is set).""" super().showEvent(event) if not self._detected_on_show and self._setting("last_game_folder", ""): self._detected_on_show = True QTimer.singleShot(100, self._detect_folder) def eventFilter(self, obj, event): """Toggle workflow file checks when clicking a row outside the checkbox.""" try: if ( obj is self.file_list.viewport() and event.type() == QEvent.MouseButtonRelease and event.button() == Qt.LeftButton ): item = self.file_list.itemAt(event.pos()) if item is None: return False # Let native checkbox clicks handle their own checked state. item_rect = self.file_list.visualItemRect(item) if event.pos().x() <= item_rect.left() + 26: return False item.setCheckState( Qt.Unchecked if item.checkState() == Qt.Checked else Qt.Checked ) return False except Exception: pass return super().eventFilter(obj, event) def _step_strip_label(self, idx: int, *, done: bool) -> str: """Compact strip text: number + short name, optional checkmark for done.""" short_names = ( "Project", "Prep", "Setup", "Phase1", "Phase2", "Export", "Playtest", ) name = short_names[idx] if 0 <= idx < len(short_names) else str(idx) mark = "✓" if done else "" return f"{mark}{idx}\n{name}" def _refresh_step_strip(self, current: int | None = None): if current is None: current = self._step_tabs.currentIndex() if hasattr(self, "_step_tabs") else 0 for i, btn in enumerate(getattr(self, "_step_buttons", [])): if not btn.isVisible(): continue done = i in self._step_done btn.setText(self._step_strip_label(i, done=done)) btn.setProperty("done", "true" if done else "false") btn.style().unpolish(btn) btn.style().polish(btn) if i == current and not btn.isChecked(): btn.blockSignals(True) btn.setChecked(True) btn.blockSignals(False) elif i != current and btn.isChecked(): btn.blockSignals(True) btn.setChecked(False) btn.blockSignals(False) def _goto_step(self, idx: int): if not hasattr(self, "_step_tabs"): return idx = max(0, min(idx, self._step_tabs.count() - 1)) # Skip hidden steps (e.g. Playtest on Ace). buttons = getattr(self, "_step_buttons", []) if 0 <= idx < len(buttons) and not buttons[idx].isVisible(): # Prefer previous visible step. for j in range(idx - 1, -1, -1): if buttons[j].isVisible(): idx = j break if self._step_tabs.currentIndex() != idx: self._step_tabs.setCurrentIndex(idx) else: self._refresh_step_strip(idx) def _advance_step(self, from_idx: int): """Mark *from_idx* done and move to the next visible step.""" self._step_done.add(from_idx) nxt = from_idx + 1 buttons = getattr(self, "_step_buttons", []) while nxt < len(buttons) and not buttons[nxt].isVisible(): nxt += 1 self._goto_step(nxt) def _on_step_tab_changed(self, index: int): """Refresh config-backed controls when their workflow page is shown.""" previous_index = self._current_step_index self._current_step_index = index self._refresh_step_strip(index) if previous_index == 0 and index != 0: self._auto_import_if_needed() if index == 4: self._populate_p2_checkboxes() if index == 6: self._refresh_playtest_status() self._load_playtest_settings() def _register_import_button(self, button: QPushButton) -> None: self._import_buttons.append(button) def _set_import_buttons_enabled(self, enabled: bool) -> None: for button in self._import_buttons: button.setEnabled(enabled) def _apply_theme(self): """Apply a unified dark-theme stylesheet to all standard controls.""" self.setStyleSheet(""" QLineEdit { background-color: #3c3c3c; border: 1px solid #555555; border-radius: 4px; padding: 4px 8px; color: #cccccc; font-size: 13px; } QLineEdit:focus { border-color: #007acc; } QLineEdit:disabled { background-color: #2d2d30; color: #666666; } QLineEdit::placeholder { color: #606060; } QSpinBox { background-color: #3c3c3c; border: 1px solid #555555; border-radius: 4px; padding: 3px 6px; color: #cccccc; font-size: 13px; } QSpinBox:focus { border-color: #007acc; } QSpinBox::up-button, QSpinBox::down-button { background-color: #2d2d30; border: none; width: 18px; } QSpinBox::up-button:hover, QSpinBox::down-button:hover { background-color: #007acc; } QCheckBox { color: #cccccc; font-size: 13px; spacing: 7px; } QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #555555; border-radius: 3px; background-color: #3c3c3c; } QCheckBox::indicator:checked { background-color: #007acc; border-color: #007acc; } QCheckBox::indicator:hover { border-color: #007acc; } QCheckBox::indicator:disabled { background-color: #2d2d30; border-color: #444444; } """) # ── Step 0: Project Folder ────────────────────────────────────────────── def _add_step_header( self, layout: QVBoxLayout, title: str, step_idx: int, *, extra_widgets: list | None = None, ) -> QLabel: """Title row with optional trailing widgets and a ? help button on the right.""" row = QHBoxLayout() row.setContentsMargins(0, 0, 0, 0) row.setSpacing(8) lbl = _make_section_label(title) row.addWidget(lbl, 1) for w in extra_widgets or []: row.addWidget(w) help_btn = _make_help_button() help_html = _STEP_HELP.get(step_idx, "No help is available for this step.") help_btn.clicked.connect( lambda _=False, label=lbl, h=help_html: _show_step_help( self, label.text(), h ) ) row.addWidget(help_btn, 0, Qt.AlignTop) layout.addLayout(row) return lbl def _build_step0(self, layout: QVBoxLayout): self._add_step_header(layout, "Step 0 — Project Folder", 0) # Folder picker row row0 = QHBoxLayout() self.folder_edit = QLineEdit() self.folder_edit.setPlaceholderText("Path to game root folder…") saved = self._setting("last_game_folder", "") if saved: self.folder_edit.setText(saved) row0.addWidget(self.folder_edit, 1) self.folder_edit.returnPressed.connect(self._detect_folder) browse_btn = _make_icon_btn("📁", "Browse for a game project folder") browse_btn.clicked.connect(self._browse_folder) row0.addWidget(browse_btn) layout.addLayout(row0) # Detected info self.detected_label = QLabel("No folder detected yet.") self.detected_label.setStyleSheet( "color:#6a9a6a;font-size:13px;padding:4px 8px;" "background-color:#1f2b1f;border:1px solid #2a4a2a;" "border-radius:4px;margin:4px 0;" ) layout.addWidget(self.detected_label) # File list self.file_list = QListWidget() self.file_list.setMinimumHeight(320) self.file_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.file_list.setSelectionMode(QAbstractItemView.ExtendedSelection) self.file_list.itemChanged.connect(self._sync_selected_file_checks) self.file_list.viewport().installEventFilter(self) self.file_list.setStyleSheet( "QListWidget{outline:none;border:1px solid #3c3c3c;" "background-color:#252526;border-radius:4px;}" "QListWidget::item{border:none;outline:none;padding:2px 6px;" "color:#c8c8c8;}" "QListWidget::item:selected{background-color:#252526;color:#c8c8c8;}" "QListWidget::item:hover{background-color:#2d2d30;" "border-left:2px solid #007acc;}" ) layout.addWidget(self.file_list, 1) # Action row row1 = QHBoxLayout() row1.setSpacing(6) select_all_btn = _make_text_btn("All", "Select all importable files", min_width=44) select_all_btn.clicked.connect(self._select_all_files) row1.addWidget(select_all_btn) deselect_all_btn = _make_text_btn("None", "Deselect all files", min_width=52) deselect_all_btn.clicked.connect(self._deselect_all_files) row1.addWidget(deselect_all_btn) sel_core = _make_text_btn("Core", "Core only: select database files and deselect maps", min_width=52) sel_core.setToolTip("Select only core database files; deselect map files") sel_core.clicked.connect(self._select_core_only) row1.addWidget(sel_core) import_btn = _make_text_btn("Import", "Import selected files into files/", min_width=64) import_btn.setEnabled(False) import_btn.setToolTip("Replace files/ with exactly the checked files above") import_btn.clicked.connect(lambda _checked=False: self._import_files()) self._register_import_button(import_btn) row1.addWidget(import_btn) row1.addStretch() layout.addLayout(row1) # ── Step 3: Vocab / Glossary ──────────────────────────────────────────── # ── Copilot prompt templates ──────────────────────────────────────────── _WRAP_PROMPT = ( "You are an expert RPGMaker MV/MZ configuration analyst.\n" "\n" "\n" "Calculate the correct text-wrap width settings for a Japanese-to-English RPGMaker MV/MZ " "translation tool. The tool wraps translated English using character-count limits (not pixels). " "I need three values: width, listWidth, and noteWidth.\n" "\n" "\n" "--- attach System.json and js/plugins.js here before continuing ---\n" "\n" "\n" "1. Read screenWidth and fontSize from System.json.\n" " Check js/plugins.js for any MessageCore or Window plugin that overrides these values.\n" "2. For each window type, estimate its pixel width, subtract ~48px padding, then calculate:\n" " chars = floor(content_px / (font_size × 0.58))\n" " - width: main dialogue/message box (Show Text) — typically full screen width\n" " - listWidth: item/skill/help description windows — typically full or half screen width\n" " - noteWidth: database note fields — typically the narrowest pane (~40–50% screen width)\n" "3. If font size is above 26px and reducing it would meaningfully increase characters per line, " "note where to change it (System.json or the relevant plugin parameter).\n" "\n" "\n" "\n" "Output only the final values — do not show calculations:\n" "\n" "```\n" "width=\n" "listWidth=\n" "noteWidth=\n" "fontSize= # or: no change needed\n" "```\n" "\n" "Followed by one sentence of assumptions if anything was estimated.\n" "\n" ) _PLUGINS_JS_TRANSLATE_PROMPT = ( "You are an expert RPGMaker MV/MZ localisation engineer.\n" "\n" "\n" "Translate visible Japanese strings inside js/plugins.js without breaking any game logic " "or plugin functionality. A vocab.txt glossary is attached — use it as your primary reference. " "Any name or term that appears in the glossary must be translated exactly as shown there.\n" "\n" "\n" "--- attach js/plugins.js and vocab.txt here before continuing ---\n" "\n" "\n" "\n" "Only translate string values directly shown to the player at runtime:\n" "- Menu/button labels in-game UI (e.g. 決定, キャンセル)\n" "- Scene/window title text (e.g. アイテム, スキル, 装備)\n" "- In-game popup, tooltip, or notification messages\n" "- Default NPC names or pronouns used in UI display\n" "- Help or description text shown in help windows\n" "- Battle log messages or status effect messages\n" "\n" "\n" "\n" "Translating the following WILL BREAK THE GAME:\n" "\n" "1. Plugin parameter keys (object property names).\n" " { \"CommandName\": \"セーブ\" } → translate セーブ but NOT CommandName\n" "\n" "2. Strings used as internal identifiers or lookup keys:\n" " - Switch/variable names matching System.json entries\n" " - Actor, skill, item, weapon, armour names used as keys inside other plugin parameters\n" " - Strings passed as plugin command arguments that the engine looks up\n" "\n" "3. File paths, filenames, URLs, colour codes, font names, icon indices.\n" " (e.g. img/faces/Actor1 or #ffffff)\n" "\n" "4. Plugin names and script identifiers:\n" " - The \"name\" property at the top of each plugin block (e.g. { \"name\": \"YEP_CoreEngine\" })\n" " - Function identifiers, class names, JS event names\n" "\n" "5. Any string in the game data files (Actors.json, Skills.json, Items.json, etc.) that is " "also used as a lookup key in the plugin — changing it here would desync it from the data file.\n" "\n" "6. Boolean strings, numeric strings, regex patterns.\n" "\n" "\n" "\n" "Before translating any string, confirm all three are true:\n" "- It is displayed directly to the player as visible text\n" "- It is purely a UI label, not used as a key anywhere else\n" "- Nothing in the codebase compares this exact string to another value\n" "When in doubt, skip it — untranslated Japanese is better than a broken game.\n" "\n" "\n" "\n" "Provide the translated file as a complete replacement of js/plugins.js, inside a single " "fenced code block (```js ... ```) so it can be copied in one click. Only change the " "string values identified above. Preserve all original formatting, indentation, comments, and structure.\n" "\n" "After the code block, output a summary:\n" "\n" "### Translations Made\n" " Plugin: \n" " Parameter: \n" " Before: \n" " After: \n" "\n" "### Skipped (Ambiguous or Internal)\n" "List any Japanese strings you detected but did not translate, with a one-line reason.\n" "\n" ) _ACE_SCRIPTS_TRANSLATE_PROMPT = ( "You are an expert RPGMaker VX Ace (Ruby) localisation engineer.\n" "\n" "\n" "Translate visible Japanese strings inside the game's Ruby scripts (ace_json/scripts/*.rb) " "without breaking any game logic or script functionality. A vocab.txt glossary is attached - " "use it as your primary reference. Any name or term in the glossary must be translated exactly as shown.\n" "\n" "\n" "--- attach the .rb script files and vocab.txt here before continuing ---\n" "\n" "\n" "========================================\n" "## WHAT TO TRANSLATE\n" "========================================\n" "\n" "Only translate string literals that are directly shown to the player at runtime.\n" "These typically appear as:\n" " - Strings passed to msgbox, msgbox_p, print, p\n" " - Labels and text in Window or Scene classes rendered to screen\n" " - draw_text / draw_item calls with a Japanese string literal\n" " - Default UI label text (menu names, button labels, status window text)\n" " - Battle log messages, notifications, popup strings\n" " - Help or description text shown in help windows\n" "\n" "========================================\n" "## WHAT MUST NOT BE TRANSLATED\n" "========================================\n" "\n" "CRITICAL - translating the following will break the game:\n" "\n" " 1. Strings used as hash keys, method names, or symbol equivalents.\n" " Example: vocab[\"HP\"] = \"体力\" -> translate \"体力\" but NOT the key \"HP\"\n" "\n" " 2. Strings used as internal identifiers compared with == or used in case/when:\n" " - Actor/class/skill/item names used as lookup strings\n" " - Script-internal state names or flag strings\n" " Example: if type == \"スキル\" -> do NOT translate \"スキル\"\n" "\n" " 3. File paths, filenames, font names, colour strings, URLs.\n" "\n" " 4. Regular expressions, format strings used with sprintf or % operator\n" " where the placeholders must stay in the same position.\n" " (You may translate the human-readable parts but keep %s / %d / %1 etc intact.)\n" "\n" " 5. Script class names, module names, method names, constants.\n" "\n" " 6. Any string that is read back elsewhere in the scripts with an exact match.\n" "\n" "\n" "Before translating any string, confirm all three are true:\n" "- It is displayed directly to the player as visible text\n" "- It is purely a display string, not compared or looked up anywhere\n" "- Changing it would break no conditional logic or data lookup\n" "When in doubt, skip it - untranslated Japanese is better than a broken game.\n" "\n" "\n" "\n" "For each .rb file that needed changes, provide the full translated file content inside its " "own fenced code block (```ruby ... ```), preceded by the filename, so each file can be " "copied in one click. Only change string values identified as safe. " "Preserve all Ruby syntax, indentation, comments, and structure exactly.\n" "\n" "After all files, output a summary:\n" "\n" "### Translations Made\n" " File: