Working Wrap

This commit is contained in:
DazedAnon 2026-07-09 07:24:09 -05:00
parent a2411b3e31
commit ef055cbb33
3 changed files with 263 additions and 31 deletions

View file

@ -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).")

View file

@ -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):

View file

@ -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("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
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'<span style="color:{color};font-size:{font_px}px">'
f"{_html_escape(literal)}</span>"
)
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 "&nbsp;"
gutter = (
f'<span style="color:{gutter_color};font-family:monospace;font-size:11px">'
f"{marker} {i + 1:2d} ({vis:2d})&nbsp;&nbsp;</span>"
)
body_html = render_wolf_text_html(
line, default_color="#d4d4d4", default_font_px=body
)
rows.append(
f'<div style="white-space:pre-wrap;line-height:1.35;margin:0 0 2px 0">'
f"{gutter}{body_html}</div>"
)
return (
'<div style="font-family:\'Segoe UI\',\'Noto Sans\',sans-serif;'
'background-color:transparent">'
+ "".join(rows)
+ "</div>"
)
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)