Working Wrap
This commit is contained in:
parent
a2411b3e31
commit
ef055cbb33
3 changed files with 263 additions and 31 deletions
|
|
@ -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).")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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'<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 " "
|
||||
gutter = (
|
||||
f'<span style="color:{gutter_color};font-family:monospace;font-size:11px">'
|
||||
f"{marker} {i + 1:2d} ({vis:2d}) </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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue