diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index f9a80d8..6f65f8d 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -123,6 +123,23 @@ class _ImportWorker(QThread): def run(self): try: 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.is_file() and fp.name != ".gitkeep": + try: + fp.unlink() + 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) @@ -134,15 +151,23 @@ class _ExportWorker(QThread): done = pyqtSignal(int, list) log = pyqtSignal(str) - def __init__(self, game_data_path: 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 - self.log.emit(f"Exporting translated/ → {self.game_data_path} …") - count, errors = export_to_game("translated", self.game_data_path) + 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)]) @@ -456,9 +481,9 @@ class WorkflowTab(QWidget): # Action row row1 = QHBoxLayout() - sel_all = _make_btn("Select All", "#555") - sel_all.clicked.connect(self._select_all_files) - row1.addWidget(sel_all) + self.sel_all_btn = _make_btn("Select All", "#555") + self.sel_all_btn.clicked.connect(self._select_all_files) + row1.addWidget(self.sel_all_btn) sel_core = _make_btn("Core Only", "#555") sel_core.setToolTip("Select only core database files; deselect map files") @@ -654,6 +679,82 @@ class WorkflowTab(QWidget): "translation tool only recognises the plain hyphen." ) + _WRAP_PROMPT = ( + "You are helping me configure text-wrap widths for a Japanese RPGMaker MV/MZ translation tool.\n" + "\n" + "The tool wraps translated English text using a character-count limit — NOT pixels — so\n" + "I need to know how many English characters fit comfortably on one line for three\n" + "different window types:\n" + "\n" + " width — main dialogue / message box (Show Text / code 401)\n" + " listWidth — item / skill / help description windows\n" + " noteWidth — database note fields (item, weapon, armour, skill descriptions)\n" + "\n" + "## Step 1 — Gather screen and font settings\n" + "\n" + "Read System.json and report:\n" + " - screenWidth and screenHeight (or window_width / window_height for older MV)\n" + " - fontSize (default 26 for MV; may be absent in MZ if using default)\n" + " - advanced.uiAreaWidth / advanced.uiAreaHeight if present\n" + "\n" + "## Step 2 — Check plugins for window overrides\n" + "\n" + "Search plugins.js (or js/plugins.js) for any of the following and report their\n" + "relevant parameters if present:\n" + "\n" + " VisuMZ_1_MessageCore → MessageWidth, DefaultRows, FontSize\n" + " YEP_MessageCore → messageWidth, messageRows\n" + " MOG_MessageSystem → message_width, message_height\n" + " Any other plugin whose name contains 'Message' or 'Window' with size config\n" + "\n" + "## Step 3 — Calculate character widths\n" + "\n" + "Use this formula for each window type:\n" + "\n" + " content_px = window_pixel_width - (horizontal_padding × 2)\n" + " assume 24px padding per side if unknown\n" + " avg_char_width_px = font_size_px × 0.58 (empirical average for English sans-serif)\n" + " chars_per_line = floor(content_px / avg_char_width_px)\n" + "\n" + "For listWidth, the relevant window is the help/description pane in the menu\n" + "(usually full screen width or half, depending on the game's menu layout).\n" + "For noteWidth, use the narrowest description area you can find — often an item\n" + "detail window that is roughly 40–50% of screen width.\n" + "\n" + "Show your working for each of the three values.\n" + "\n" + "## Step 4 — Font size advice\n" + "\n" + "If the game's font size is larger than 26px, consider whether reducing it would\n" + "let more translated text fit without excessive wrapping. Only recommend a change\n" + "if the gain is meaningful (e.g. 30px → 26px adds ~6 characters per line).\n" + "\n" + "If a font-size change is advisable, identify exactly where to make it:\n" + " a) System.json 'fontSize' field\n" + " b) VisuMZ_1_MessageCore or YEP_MessageCore 'FontSize' parameter in plugins.js\n" + " c) A CSS / font override file if the game uses a custom web font\n" + "\n" + "## Required output format\n" + "\n" + "Reply with EXACTLY this block — fill in the numbers from your analysis:\n" + "\n" + "```\n" + "# Text wrap recommendations\n" + "\n" + "Screen resolution : x (source: System.json / plugin override)\n" + "Font size : px (source: System.json / plugin override)\n" + "\n" + "Dialogue window : px wide → width=\n" + "List/help window : px wide → listWidth=\n" + "Note window : px wide → noteWidth=\n" + "\n" + "Font size change : <'No change needed' OR 'Change Xpx → Ypx in '>\n" + "```\n" + "\n" + "After the block, include a short paragraph explaining any assumptions you made\n" + "(e.g. padding estimates, windows whose pixel width could not be determined directly).\n" + ) + # ── Step 1 (Optional): Pre-process ──────────────────────────────── def _build_preprocess(self, layout: QVBoxLayout): @@ -823,7 +924,7 @@ class WorkflowTab(QWidget): layout.addWidget(hint) # ---- Parse Speakers ------------------------------------------------- - spk_box = QGroupBox("2a — Parse Speakers (Auto-detect Names)") + spk_box = QGroupBox("3a — Parse Speakers (Auto-detect Names)") spk_box.setStyleSheet( "QGroupBox{color:#ccc;border:1px solid #444;border-radius:3px;" "margin-top:8px;font-size:11px;}" @@ -858,7 +959,7 @@ class WorkflowTab(QWidget): layout.addWidget(spk_box) # ---- Copilot / Cursor prompt helpers -------------------------------- - prompt_box = QGroupBox("2b — AI Prompt Helpers (Copilot / Cursor)") + prompt_box = QGroupBox("3b — AI Prompt Helpers (Copilot / Cursor)") prompt_box.setStyleSheet( "QGroupBox{color:#ccc;border:1px solid #444;border-radius:3px;" "margin-top:8px;font-size:11px;}" @@ -992,6 +1093,71 @@ class WorkflowTab(QWidget): def _build_step4(self, layout: QVBoxLayout): layout.addWidget(_make_section_label("Step 4 — Translation")) + # ---- Pre-flight: text wrap configuration ---------------------------- + wrap_box = QGroupBox("Pre-flight — Text Wrap Width") + wrap_box.setStyleSheet( + "QGroupBox{color:#ccc;border:1px solid #444;border-radius:3px;" + "margin-top:4px;font-size:11px;}" + "QGroupBox::title{padding:0 6px;}" + ) + wrap_inner = QVBoxLayout(wrap_box) + wrap_inner.setSpacing(6) + + wrap_hint = QLabel( + "Copy the prompt, paste it into Copilot with System.json and plugins.js attached. " + "It will calculate how many English characters fit per line for each window type. " + "Enter the recommended values and click Apply to update .env." + ) + wrap_hint.setWordWrap(True) + wrap_hint.setStyleSheet("color:#888;font-size:10px;") + wrap_inner.addWidget(wrap_hint) + + copy_wrap_row = QHBoxLayout() + copy_wrap_btn = _make_btn("📋 Copy Wrap Prompt", "#555") + copy_wrap_btn.setToolTip("Copy the text-wrap analysis prompt to clipboard") + copy_wrap_btn.clicked.connect(self._copy_wrap_prompt) + copy_wrap_row.addWidget(copy_wrap_btn) + copy_wrap_row.addStretch() + wrap_inner.addLayout(copy_wrap_row) + + def _spin_pair(label_text: str, default: int): + lbl = QLabel(label_text) + lbl.setStyleSheet("color:#aaa;font-size:10px;") + sp = QSpinBox() + sp.setRange(20, 300) + sp.setValue(default) + sp.setStyleSheet( + "QSpinBox{background:#2d2d30;color:#ccc;border:1px solid #555;" + "padding:2px 4px;font-size:10px;border-radius:2px;}" + ) + sp.setFixedWidth(68) + return lbl, sp + + lbl_w, self.wrap_width_spin = _spin_pair("Dialogue (width)", 60) + lbl_lw, self.wrap_list_spin = _spin_pair("List/Help (listWidth)", 80) + lbl_nw, self.wrap_note_spin = _spin_pair("Notes (noteWidth)", 60) + + for lbl, sp in [(lbl_w, self.wrap_width_spin), + (lbl_lw, self.wrap_list_spin), + (lbl_nw, self.wrap_note_spin)]: + row = QHBoxLayout() + row.setSpacing(6) + lbl.setFixedWidth(160) + row.addWidget(lbl) + row.addWidget(sp) + row.addStretch() + wrap_inner.addLayout(row) + + apply_wrap_row = QHBoxLayout() + apply_wrap_btn = _make_btn("✔ Apply to .env", "#3a7a3a") + apply_wrap_btn.setToolTip("Write width / listWidth / noteWidth into .env") + apply_wrap_btn.clicked.connect(self._apply_wrap_config) + apply_wrap_row.addStretch() + apply_wrap_row.addWidget(apply_wrap_btn) + wrap_inner.addLayout(apply_wrap_row) + + layout.addWidget(wrap_box) + p1_box = QGroupBox("Phase 1 – Safe Codes (dialogue + choices)") p1_box.setStyleSheet( "QGroupBox{color:#ccc;border:1px solid #444;border-radius:3px;" @@ -999,13 +1165,17 @@ class WorkflowTab(QWidget): "QGroupBox::title{padding:0 6px;}" ) p1_inner = QVBoxLayout(p1_box) - p1_desc = QLabel("Codes: 401 (Show Text), 405 (continued), 102 (Choices), 408 (extra lines)\n" - "These rarely break game logic — safe to translate first.") + p1_desc = QLabel( + "Codes ON: 101 (Name), 401 (Show Text), 405 (continued), 102 (Choices), 408 (extra lines)\n" + "Clicking the button below applies these codes, then takes you to the Translation tab.\n" + "While translating, watch the log — speaker lines should look like [Speaker]: Dialogue." + ) p1_desc.setStyleSheet("color:#888;font-size:10px;") p1_desc.setWordWrap(True) p1_inner.addWidget(p1_desc) p1_row = QHBoxLayout() - run_p1 = _make_btn("▶ Run Phase 1", "#007acc") + run_p1 = _make_btn("⚙ Set Codes \u2192 Go to Translation", "#007acc") + run_p1.setToolTip("Applies Phase 1 code settings and switches to the Translation tab") run_p1.clicked.connect(lambda: self._run_phase(1)) p1_row.addWidget(run_p1) p1_row.addStretch() @@ -1020,9 +1190,9 @@ class WorkflowTab(QWidget): ) p2_inner = QVBoxLayout(p2_box) p2_desc = QLabel( - "Codes: 122 (Control Variables), 357 (Plugin Commands), 111 (Conditional Branch)\n" - "Strings in these codes are often used in script comparisons — mistranslations " - "can break game logic. Scan first, then translate carefully." + "Codes ON: 122 (Control Variables), 357 (Plugin Commands), 111 (Conditional Branch)\n" + "Strings here are often used in script comparisons — mistranslations can break game " + "logic. Use Scan first to preview what will be translated, then apply and go translate." ) p2_desc.setStyleSheet("color:#888;font-size:10px;") p2_desc.setWordWrap(True) @@ -1037,7 +1207,8 @@ class WorkflowTab(QWidget): scan_p2.clicked.connect(self._scan_risky_strings) p2_row.addWidget(scan_p2) - run_p2 = _make_btn("▶ Run Phase 2", "#7a4a00") + run_p2 = _make_btn("⚙ Set Codes \u2192 Go to Translation", "#7a4a00") + run_p2.setToolTip("Applies Phase 2 code settings and switches to the Translation tab") run_p2.clicked.connect(lambda: self._run_phase(2)) p2_row.addWidget(run_p2) p2_row.addStretch() @@ -1049,17 +1220,25 @@ class WorkflowTab(QWidget): def _build_step6(self, layout: QVBoxLayout): layout.addWidget(_make_section_label("Step 5 — Export to Game")) hint = QLabel( - "Copy all files from translated/ back into the game's data folder " - "to patch the game in-place." + "Copy translated files back into the game's data folder to patch the game in-place." ) hint.setWordWrap(True) hint.setStyleSheet("color:#888;font-size:10px;padding-bottom:4px;") layout.addWidget(hint) row = QHBoxLayout() - export_btn = _make_btn("📤 Export translated/ → Game Folder", "#3a7a3a") - export_btn.clicked.connect(self._export_to_game) - row.addWidget(export_btn) + export_active_btn = _make_btn("📤 Export Active Files → Game Folder", "#3a7a3a") + export_active_btn.setToolTip( + "Only export files whose names match those currently in files/\n" + "(i.e. the files you imported for this project)" + ) + export_active_btn.clicked.connect(self._export_active_files) + row.addWidget(export_active_btn) + + export_all_btn = _make_btn("📤 Export ALL translated/ → Game Folder", "#555") + export_all_btn.setToolTip("Export every file in translated/ regardless of what is in files/") + export_all_btn.clicked.connect(self._export_to_game) + row.addWidget(export_all_btn) row.addStretch() layout.addLayout(row) @@ -1130,15 +1309,27 @@ class WorkflowTab(QWidget): self.file_list.addItem(lw) self.import_btn.setEnabled(len(items) > 0) + self.sel_all_btn.setText("Select All") self._log(f"Found {len(items)} importable file(s).") self._populate_preprocess_paths() def _select_all_files(self): count = self.file_list.count() + if not count: + return + all_checked = all( + self.file_list.item(i).checkState() == Qt.Checked + for i in range(count) + ) + new_state = Qt.Unchecked if all_checked else Qt.Checked for i in range(count): - self.file_list.item(i).setCheckState(Qt.Checked) - if count: + self.file_list.item(i).setCheckState(new_state) + if new_state == Qt.Checked: + self.sel_all_btn.setText("Deselect All") self._log(f"✔ Selected all {count} file(s).") + else: + self.sel_all_btn.setText("Select All") + self._log(f"✔ Deselected all {count} file(s).") def _select_core_only(self): core = other = 0 @@ -1316,6 +1507,38 @@ class WorkflowTab(QWidget): QApplication.clipboard().setText(self._SPEAKER_PROMPT) self._log("Speaker format prompt copied to clipboard.") + def _copy_wrap_prompt(self): + QApplication.clipboard().setText(self._WRAP_PROMPT) + self._log("Text-wrap analysis prompt copied to clipboard.") + + def _apply_wrap_config(self): + """Write width / listWidth / noteWidth back into .env.""" + import re as _re + updates = { + "width": str(self.wrap_width_spin.value()), + "listWidth": str(self.wrap_list_spin.value()), + "noteWidth": str(self.wrap_note_spin.value()), + } + env_path = Path(".env") + try: + text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" + for key, val in updates.items(): + text, n = _re.subn( + rf"^({_re.escape(key)}\s*=\s*')[^']*(')", + rf"\g<1>{val}\2", + text, + flags=_re.MULTILINE, + ) + if n == 0: + text = text.rstrip("\n") + f"\n{key}='{val}'\n" + env_path.write_text(text, encoding="utf-8") + self._log( + "✅ .env updated — " + + ", ".join(f"{k}={v}" for k, v in updates.items()) + ) + except Exception as exc: + self._log(f"❌ Could not update .env: {exc}") + def _apply_speaker_flags(self): cfg = { "INLINE401SPEAKERS": self.spk_inline_cb.isChecked(), @@ -1347,57 +1570,61 @@ class WorkflowTab(QWidget): config = PHASE1_CONFIG if phase == 1 else PHASE2_CONFIG label = "Phase 1 (safe codes)" if phase == 1 else "Phase 2 (risky codes)" - # Apply config profile + # Apply config profile so the Translation tab uses the right codes try: from gui.config_integration import ConfigIntegration ci = ConfigIntegration() ci.update_rpgmaker_config(config) - self._log(f"Applied {label} config profile.") + # Sync the live Settings tab if it is open + try: + if self.parent_window and hasattr(self.parent_window, "config_tab"): + ct = self.parent_window.config_tab + if hasattr(ct, "rpgmaker_tab"): + ct.rpgmaker_tab.set_config( + ct.rpgmaker_tab.get_config() | config + ) + except Exception: + pass + self._log(f"✅ {label} config applied — codes set:") + on = [k for k, v in config.items() if v] + off = [k for k, v in config.items() if not v] + if on: + self._log(" ON : " + " ".join(on)) + if off: + self._log(" OFF: " + " ".join(off)) except Exception as exc: self._log(f"❌ Could not apply phase config: {exc}") return - # Launch translation worker + # Phase-specific guidance + if phase == 1: + self._log("") + self._log("─" * 54) + self._log("👉 Switch to the Translation tab and start the run.") + self._log("") + self._log("⚠ While translating, watch the log for speaker lines.") + self._log(" They should look like: [Speaker]: Dialogue text") + self._log(" If names are missing or garbled, stop the run and") + self._log(" revisit Step 2 (speaker flags) before continuing.") + self._log("─" * 54) + else: + self._log("") + self._log("─" * 54) + self._log("👉 Switch to the Translation tab and start the run.") + self._log(" Phase 2 targets script/variable strings — review") + self._log(" the Risky String scan output before proceeding.") + self._log("─" * 54) + + # Navigate to the Translation tab (index 0 in content_stack) try: - from gui.translation_tab import TranslationWorker - except Exception as exc: - self._log(f"❌ Could not import TranslationWorker: {exc}") - return - - project_root = Path(__file__).parent.parent - module_info = ["RPG Maker MV/MZ", [".json"], None] - - # Build file order: core DB files first (they populate the glossary), - # then map files. Within each group, sort alphabetically. - files_dir = project_root / "files" - all_json = sorted( - p.name for p in files_dir.glob("*.json") if p.name != ".gitkeep" - ) if files_dir.exists() else [] - core_files = [f for f in all_json if not f.lower().startswith("map")] - map_files = [f for f in all_json if f.lower().startswith("map")] - ordered_files = core_files + map_files - if ordered_files: - self._log( - f"File order: {len(core_files)} core file(s) first, " - f"then {len(map_files)} map file(s)." - ) - - self._log(f"Starting {label} translation…") - worker = TranslationWorker( - project_root, module_info, estimate_only=False, - selected_files=ordered_files if ordered_files else None, - ) - worker.log_signal.connect(self._log) - worker.progress_signal.connect( - lambda cur, tot, fn: self._log(f" [{cur}/{tot}] {fn}") - ) - worker.finished_signal.connect( - lambda ok, msg: self._log( - f"{'✅' if ok else '❌'} {label} finished: {msg}" - ) - ) - self._worker = worker - worker.start() + if self.parent_window and hasattr(self.parent_window, "content_stack"): + self.parent_window.content_stack.setCurrentIndex(0) + # Keep nav buttons in sync + if hasattr(self.parent_window, "nav_buttons"): + for i, btn in enumerate(self.parent_window.nav_buttons): + btn.setChecked(i == 0) + except Exception: + pass # ───────────────────────────────────────────────────────────────────────── # Risky string scanner (preview mode) @@ -1447,21 +1674,47 @@ class WorkflowTab(QWidget): # Step 5 – Export to game # ───────────────────────────────────────────────────────────────────────── - def _export_to_game(self): - game_data = self._data_path + def _export_active_files(self): + """Export only translated files whose names match what is in files/.""" + files_dir = Path("files") + active = sorted( + fp.name for fp in files_dir.glob("*.json") if fp.name != ".gitkeep" + ) if files_dir.exists() else [] + + if not active: + self._log("⚠ No files found in files/ — run Step 0 (Import) first.") + return + + game_data = self._resolve_export_path() if not game_data: - # Prompt the user - game_data = QFileDialog.getExistingDirectory( - self, "Select Game Data Folder to Export Into" - ) - if not game_data: - return - self._data_path = game_data + return + + reply = QMessageBox.question( + self, + "Export Active Files to Game", + f"Export {len(active)} file(s) matching files/ contents into:\n{game_data}\n\n" + "Make a backup first if needed. Continue?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + + w = _ExportWorker(game_data, filter_names=active) + w.log.connect(self._log) + w.done.connect(self._on_export_done) + self._worker = w + w.start() + + def _export_to_game(self): + game_data = self._resolve_export_path() + if not game_data: + return reply = QMessageBox.question( self, "Export to Game", - f"This will overwrite files in:\n{game_data}\n\n" + f"This will overwrite ALL translated files in:\n{game_data}\n\n" "Make a backup first if needed. Continue?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, @@ -1475,6 +1728,18 @@ class WorkflowTab(QWidget): self._worker = w w.start() + def _resolve_export_path(self) -> str | None: + """Return the game data path, prompting if not yet set.""" + game_data = self._data_path + if not game_data: + game_data = QFileDialog.getExistingDirectory( + self, "Select Game Data Folder to Export Into" + ) + if not game_data: + return None + self._data_path = game_data + return game_data + def _on_export_done(self, count: int, errors: list): if errors: self._log(f"⚠ {len(errors)} error(s) during export:")