diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index ccc0e93..34fc344 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -43,7 +43,7 @@ import shutil import tempfile from pathlib import Path -from PyQt5.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal, QEvent +from PyQt5.QtCore import Qt, QSettings, QSize, QThread, QTimer, pyqtSignal, QEvent from PyQt5.QtGui import QColor, QFont from PyQt5.QtWidgets import ( QApplication, @@ -2454,11 +2454,9 @@ class WolfWorkflowTab(QWidget): """Search-first fix wrapping: find sheet by in-game text, wrap, re-inject.""" layout.addWidget(_make_section_label("Step 7 · Fix wrapping")) layout.addWidget(self._desc( - "Paste overflowing in-game text to find it in translated JSON. Choose " - "Relayout (cell width + max lines) or Manual (wrap width + body font), " - "then wrap one line or the whole group and inject. Manual mode scales " - "emphasis ``\\f[N]`` with the body font (e.g. ``\\f[20]``/``\\f[18]`` → " - "``\\f[16]``/``\\f[14]``). names.json Relayout uses WolfDawn ``names-wrap``." + "Paste overflowing in-game text to find it in translated JSON. " + "Relayout uses cell width + max lines (names.json → wolf names-wrap). " + "Manual uses wrap width + body font; emphasis \\f[N] scales with the body." )) search_row = QHBoxLayout() @@ -2474,11 +2472,15 @@ class WolfWorkflowTab(QWidget): layout.addLayout(search_row) self._wrap_results_list = QListWidget() - self._wrap_results_list.setMaximumHeight(160) + self._wrap_results_list.setMaximumHeight(220) + self._wrap_results_list.setWordWrap(True) + self._wrap_results_list.setTextElideMode(Qt.ElideNone) + self._wrap_results_list.setUniformItemSizes(False) + self._wrap_results_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._wrap_results_list.setStyleSheet( "QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;" "color:#cccccc;font-size:12px;padding:2px;}" - "QListWidget::item{padding:4px 6px;}" + "QListWidget::item{padding:8px;}" "QListWidget::item:selected{background:#264f78;color:#ffffff;}" ) self._wrap_results_list.currentItemChanged.connect(self._on_wrap_hit_selected) @@ -2605,9 +2607,9 @@ class WolfWorkflowTab(QWidget): self._wrap_preview = QTextEdit() self._wrap_preview.setReadOnly(True) - self._wrap_preview.setMaximumHeight(160) + self._wrap_preview.setMaximumHeight(200) self._wrap_preview.setPlaceholderText( - "Select a search result to preview wrap at the current width." + "Select a search result to preview wrap with simulated \\c / \\f styling." ) self._wrap_preview.setStyleSheet( "QTextEdit{background-color:#1e1e1e;color:#b5cea8;border:1px solid #3c3c3c;" @@ -2747,6 +2749,14 @@ class WolfWorkflowTab(QWidget): def _on_wrap_font_changed(self, value: int): self._save_setting("wrap_fix_font", value) + self._update_wrap_preview() + hit = self._current_wrap_hit + doc = self._current_wrap_doc + if hit is not None and doc is not None and hasattr(self, "_wrap_detail_label"): + text = self._current_wrap_line_text() or hit.text + self._wrap_detail_label.setText( + self._format_wrap_detail(hit, doc, text, self._active_wrap_width()) + ) def _wrap_max_lines_value(self) -> int: spin = getattr(self, "_wrap_max_lines_spin", None) @@ -2892,9 +2902,16 @@ class WolfWorkflowTab(QWidget): if self._current_wrap_hit is not None and not text.strip(): text = self._current_wrap_hit.text width = self._active_wrap_width() - summary = wolf_ws.wrap_preview_summary(text, width) - preview = wolf_ws.format_wrap_preview(text, width) - info = wolf_ws.wrap_preview_info(text, width) + body_font = self._wrap_font_value() if self._wrap_mode() == "manual" else None + # Manual mode: preview the scaled fonts Wrap would write (document unchanged). + preview_src = text + if body_font is not None and preview_src.strip(): + preview_src, _ = wolf_codes.scale_font_sizes(preview_src, body_font) + summary = wolf_ws.wrap_preview_summary(preview_src, width) + preview_html = wolf_ws.format_wrap_preview_html( + text, width, body_font=body_font + ) + info = wolf_ws.wrap_preview_info(preview_src, width) if hasattr(self, "_wrap_preview_label"): if summary: self._wrap_preview_label.setText(summary) @@ -2907,11 +2924,11 @@ class WolfWorkflowTab(QWidget): self._wrap_preview_label.setStyleSheet( "color:#858585;font-size:11px;background:transparent;" ) - self._wrap_preview.setPlainText(preview) + self._wrap_preview.setHtml(preview_html) border = "#ce9178" if info.get("needs_wrap") else "#007acc" self._wrap_preview.setStyleSheet( - "QTextEdit{background-color:#1e1e1e;color:#b5cea8;border:1px solid #3c3c3c;" - f"border-left:3px solid {border};font-family:monospace;font-size:12px;padding:6px;}}" + "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;border:1px solid #3c3c3c;" + f"border-left:3px solid {border};padding:6px;}}" ) def _run_wrap_search(self): @@ -2937,10 +2954,17 @@ class WolfWorkflowTab(QWidget): self._update_wrap_preview() return width = self._active_wrap_width() + list_w = max(self._wrap_results_list.viewport().width(), 320) + metrics = self._wrap_results_list.fontMetrics() for hit in hits: - item = QListWidgetItem(hit.summary(width)) + summary = hit.summary(width) + item = QListWidgetItem(summary) item.setData(Qt.UserRole, hit.hit_id) item.setData(Qt.UserRole + 1, hit) + # Size for wrapped two-line (or more) summaries without eliding. + text_w = max(list_w - 24, 200) + br = metrics.boundingRect(0, 0, text_w, 4000, Qt.TextWordWrap, summary) + item.setSizeHint(QSize(list_w, max(br.height() + 16, 48))) self._wrap_results_list.addItem(item) self._wrap_results_list.setCurrentRow(0) self._wrap_status_label.setText(f"Found {len(hits)} match(es).") diff --git a/tests/test_wolf_wrap_search.py b/tests/test_wolf_wrap_search.py index c84d884..0b2a677 100644 --- a/tests/test_wolf_wrap_search.py +++ b/tests/test_wolf_wrap_search.py @@ -213,6 +213,38 @@ class TestWrapPreview(unittest.TestCase): self.assertLess(info["longest"], len(text)) self.assertLessEqual(info["longest"], 30 + 10) + def test_render_wolf_text_html_hides_codes_and_styles(self): + text = r'That \c[21]\f[20]cafe\c[19]\f[18] over there' + html = ws.render_wolf_text_html(text, default_font_px=18) + self.assertNotIn(r"\c[", html) + self.assertNotIn(r"\f[", html) + self.assertIn("cafe", html) + self.assertIn("font-size:20px", html) + self.assertIn("font-size:18px", html) + # Emphasis colour for \\c[21] + self.assertIn(ws.wolf_preview_color(21), html) + + def test_format_wrap_preview_html_includes_gutters(self): + text = ( + r'\f[18]"That \c[21]\f[20]cafe\c[19]\f[18] over there has been' + '\npacked lately."' + ) + html = ws.format_wrap_preview_html(text, 40) + self.assertIn("(37)", html) + self.assertIn("cafe", html) + self.assertNotIn(r"\f[18]", html) + self.assertIn("font-size:20px", html) + self.assertIn(ws.wolf_preview_color(21), html) + + def test_format_wrap_preview_html_simulates_body_font(self): + text = r'That \c[21]\f[20]cafe\c[19]\f[18] over there' + html = ws.format_wrap_preview_html(text, 40, body_font=13) + # 20*13/18 ≈ 14; body 13 + self.assertIn("font-size:14px", html) + self.assertIn("font-size:13px", html) + self.assertNotIn("font-size:20px", html) + self.assertNotIn("font-size:18px", html) + class TestNamesCategoryWrap(unittest.TestCase): def setUp(self): @@ -262,6 +294,28 @@ class TestNamesCategoryWrap(unittest.TestCase): long_entry = doc2["names"][1]["text"] self.assertIn("\n", long_entry) + def test_names_search_summary_shows_translated_text(self): + hits = ws.search_translated_text("maids", self.translated) + # Seed a maid line so EN query matches EN text, not JP source. + doc = json.loads(self.path.read_text(encoding="utf-8")) + doc["names"].append( + { + "source": "「メイドがどんどん辞めていく」", + "text": '"Yeah, maids keep quitting one after another."', + "note": self.category, + } + ) + self.path.write_text(json.dumps(doc, ensure_ascii=False, indent=4), encoding="utf-8") + hits = ws.search_translated_text("maids", self.translated) + self.assertEqual(len(hits), 1) + summary = hits[0].summary(40) + self.assertIn("maids", summary) + self.assertNotIn("メイド", summary) + self.assertIn("entry #2", summary) + # Translated preview is the first line of the summary. + self.assertIn("\n", summary) + self.assertTrue(summary.split("\n", 1)[0].strip().startswith('"Yeah')) + class TestEventScopeWrap(unittest.TestCase): def setUp(self): diff --git a/util/wolfdawn/wrap_search.py b/util/wolfdawn/wrap_search.py index ceaaa6f..ee1205f 100644 --- a/util/wolfdawn/wrap_search.py +++ b/util/wolfdawn/wrap_search.py @@ -65,23 +65,27 @@ class WrapHit: return loc def summary(self, width: int = 0) -> str: - overflow = f" longest line: {self.max_line_len} chars" + overflow = "" if width > 0 and self.max_line_len > width: - overflow += " (overflow)" - preview = self.text.replace("\n", " ")[:90] - if len(self.text) > 90: - preview += "…" + overflow = " · overflow" + elif self.max_line_len: + overflow = f" · {self.max_line_len} chars" + preview = self.text.replace("\n", " ").replace("\r", " ").strip() + if len(preview) > 120: + preview = preview[:120] + "…" if self.kind == "db": - loc = f"row {self.row} · {self.field_name}" + loc = f"row {self.row} · {self.field_name}" elif self.kind == "names": - loc = f"entry #{self.row} · {self.field_name[:60]}" + loc = f"entry #{self.row}" elif self.kind == "gamedat": loc = self.field_name or f"line {self.line_index}" else: loc = self.field_name or self.map_file or self.json_file + # Translated text first so it stays visible even if the row is short; + # metadata is secondary. return ( - f"Sheet: {self.sheet_name} · {self.json_file} · {loc}\n" - f" {preview}{overflow}" + f"{preview}\n" + f"{self.sheet_name} · {self.json_file} · {loc}{overflow}" ) @@ -282,10 +286,12 @@ def search_translated_text( if not isinstance(entry, dict): continue text = entry.get("text") - if not _text_matches(text, q): + source = entry.get("source") + # Match translated text (preferred) or JP source so either side finds the row. + if not (_text_matches(text, q) or _text_matches(source, q)): continue note = str(entry.get("note") or "names.json") - source = str(entry.get("source") or "")[:80] + display = str(text) if isinstance(text, str) and text.strip() else str(source or "") key = (jf, "names", idx) if key in seen: continue @@ -296,9 +302,9 @@ def search_translated_text( kind="names", sheet_name=note, row=idx, - field_name=source or f"entry {idx}", - text=str(text), - max_line_len=dazedwrap.max_line_visible_length(str(text)), + field_name=f"entry {idx}", + text=display, + max_line_len=dazedwrap.max_line_visible_length(display), ) ) elif kind == "gamedat": @@ -424,6 +430,154 @@ def wrap_preview_summary(text: str, width: int) -> str: ) +# Approximate System DB Type 12 colours for preview only (games-specific in real Wolf). +_WOLF_PREVIEW_COLORS: dict[int, str] = { + 0: "#d4d4d4", + 1: "#ff6b6b", + 2: "#4ecdc4", + 3: "#ffe66d", + 4: "#95e1a3", + 5: "#a78bfa", + 6: "#f9a8d4", + 7: "#67e8f9", + 8: "#fdba74", + 9: "#86efac", + 10: "#f87171", + 13: "#c4b5fd", # common ruby colour slot + 16: "#fbbf24", + 18: "#f472b6", + 19: "#c8b89a", # body / restore (cafe-style games) + 20: "#93c5fd", + 21: "#f0c040", # emphasis / place names +} + +_WOLF_PREVIEW_TOKEN_RE = re.compile( + r"\\c\[(\d+)\]|" + r"\\f\[(\d+)\]|" + r"\\r\[[^\]]*\]|" + r"\\[A-Za-z]+\[[^\]]*\]|" + r"\\." +) + + +def wolf_preview_color(index: int, *, default: str = "#d4d4d4") -> str: + """Return a CSS hex colour for Wolf ``\\c[index]`` (preview approximation).""" + try: + idx = int(index) + except (TypeError, ValueError): + return default + if idx in _WOLF_PREVIEW_COLORS: + return _WOLF_PREVIEW_COLORS[idx] + # Stable distinct hue for unknown System DB slots. + hue = (idx * 47) % 360 + return f"hsl({hue}, 55%, 68%)" + + +def _html_escape(text: str) -> str: + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def render_wolf_text_html( + text: str, + *, + default_color: str = "#d4d4d4", + default_font_px: int = 18, +) -> str: + """Render Wolf text as HTML, applying ``\\c`` / ``\\f`` and hiding other codes. + + Used by the Step 7 wrap preview so emphasis like + ``\\c[21]\\f[20]cafe\\c[19]\\f[18]`` shows as larger coloured glyphs instead + of raw escape sequences. Colours are approximate (System DB Type 12 is + per-game). + """ + if not isinstance(text, str) or not text: + return "" + color = default_color + font_px = max(8, int(default_font_px)) + parts: list[str] = [] + last = 0 + + def _flush(literal: str) -> None: + if not literal: + return + parts.append( + f'' + f"{_html_escape(literal)}" + ) + + for m in _WOLF_PREVIEW_TOKEN_RE.finditer(text): + if m.start() > last: + _flush(text[last : m.start()]) + raw = m.group(0) + if raw.startswith(r"\c[") and m.group(1) is not None: + color = wolf_preview_color(int(m.group(1)), default=default_color) + elif raw.startswith(r"\f[") and m.group(2) is not None: + font_px = max(8, int(m.group(2))) + # Other codes (\\r, \\^, \\cdb, …) are omitted from the visual preview. + last = m.end() + if last < len(text): + _flush(text[last:]) + return "".join(parts) + + +def format_wrap_preview_html( + text: str, + width: int, + *, + body_font: int | None = None, +) -> str: + """Rich HTML wrap preview: line gutters + simulated ``\\c`` / ``\\f`` styling. + + When *body_font* is set, proportionally scale every ``\\f[N]`` (and ensure a + leading body size) before wrapping/rendering - same as Manual wrap would do. + """ + from util.wolfdawn import codes as wolf_codes + + preview_text = text if isinstance(text, str) else "" + if body_font is not None and int(body_font) > 0 and preview_text.strip(): + preview_text, _ = wolf_codes.scale_font_sizes(preview_text, int(body_font)) + + info = wrap_preview_info(preview_text, width) + wrapped = info.get("wrapped") or "" + if not wrapped: + return "" + + body = ( + int(body_font) + if body_font is not None and int(body_font) > 0 + else wolf_codes.infer_base_font_size(preview_text, default=18) + ) + lines = wrapped.replace("\r\n", "\n").replace("\r", "\n").split("\n") + rows: list[str] = [] + for i, line in enumerate(lines): + vis = dazedwrap.max_line_visible_length(line) + overflow = vis > width > 0 + gutter_color = "#ce9178" if overflow else "#858585" + marker = "⚠" if overflow else " " + gutter = ( + f'' + f"{marker} {i + 1:2d} ({vis:2d})  " + ) + body_html = render_wolf_text_html( + line, default_color="#d4d4d4", default_font_px=body + ) + rows.append( + f'
' + f"{gutter}{body_html}
" + ) + return ( + '
' + + "".join(rows) + + "
" + ) + + def split_line_at_visible_width(line: str, width: int) -> tuple[int, int]: """Return ``(fit_end, line_len)`` char indices in *line* for UI highlighting.""" line_len = len(line)