Subtitles and menu text sections in the editor

This commit is contained in:
2026-10-09 22:42:50 +02:00
parent ef68edbe45
commit a10593ec24
9 changed files with 816 additions and 31 deletions

View File

@@ -133,7 +133,7 @@ class ExportModDialog(QDialog):
class ImportModDialog(QDialog):
"""Shows how each item of a package matches this game before installing."""
def __init__(self, manifest, items, parent=None):
def __init__(self, manifest, items, parent=None, texts=()):
super().__init__(parent)
self.setWindowTitle("Import mod package")
lay = QVBoxLayout(self)
@@ -151,9 +151,15 @@ class ImportModDialog(QDialog):
"missing": im.note}[im.how]
QTreeWidgetItem(self.tree, [im.item.get("name") or im.item["target"], im.how, note])
n_ok += im.how != "missing"
t_ok = 0
for key, _text, problem in texts:
QTreeWidgetItem(self.tree, [key, "text", problem or "will be applied"])
t_ok += not problem
self.tree.resizeColumnToContents(0)
lay.addWidget(self.tree)
lay.addWidget(QLabel("%d of %d sounds will be installed." % (n_ok, len(items))))
lay.addWidget(QLabel("%d of %d sounds and %d of %d texts will be installed."
% (n_ok, len(items), t_ok, len(texts))))
n_ok += t_ok
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
bb.button(QDialogButtonBox.StandardButton.Ok).setText("Install")
bb.button(QDialogButtonBox.StandardButton.Ok).setEnabled(n_ok > 0)

View File

@@ -200,6 +200,14 @@ class EditorPanel(QWidget):
act.addWidget(w)
act.addStretch(1)
lay.addLayout(act)
# subtitle of this sound (edited side by side with the audio) -------------------------------
from .textpanel import SubtitleBox
gs = QGroupBox("Subtitle")
gsl = QVBoxLayout(gs)
self.subtitle = SubtitleBox(self.ctx, gs)
gsl.addWidget(self.subtitle)
lay.addWidget(gs)
lay.addStretch(1)
self.recorder.level.connect(lambda v: self.meter.setValue(int(min(1.0, v) * 100)))
@@ -252,6 +260,7 @@ class EditorPanel(QWidget):
self.wf_inst.set_audio(None, 1, "")
self.wf_new.set_audio(None, 1, "load a file or record a replacement")
self.lbl_new.setText("")
self.subtitle.set_sound(s)
if s is None:
self.title.setText("No sound selected")
self.meta.setText("Search and select a sound in the list.")

View File

@@ -50,6 +50,13 @@ def smoke_test(game_dir=None, report=None):
assert read_target(out, s.target) == wem
print("pipeline OK: %s (%s), %d sounds loaded, re-encoded %d bytes (limit %d), injected into a temp copy"
% (s.name, s.archive, len(ws.catalog.sounds), len(wem), ws.limit(s)))
lang = ws.game.language()
b = next((x for x in ws.catalog.sounds if x.archive == "BANK"), None)
if b:
audio.decode_wem(ws.current_bytes(b))
subs = ws.texts.subtitles(lang) if lang in ws.texts.subtitle_languages() else []
print("texts OK: %d subtitles, %d menu texts (%s); in-memory sound decoded: %s"
% (len(subs), len(ws.texts.menu(lang)), lang, b.name if b else "none"))
return 0

View File

@@ -1,22 +1,26 @@
"""Main window: filters + sound table (browser) on top, single-sound editor below."""
"""Main window. Tabs: Sounds (filters + sound table on top, single-sound editor below, with the sound's
subtitle), Subtitles and Menu text (app/textpanel.py)."""
import os
from PySide6.QtCore import QByteArray, QEvent, QItemSelectionModel, QSize, Qt, QTimer
from PySide6.QtGui import QFont, QKeySequence, QShortcut
from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox,
QPushButton, QScrollArea, QSplitter, QTableView, QToolBar, QVBoxLayout, QWidget)
QPushButton, QScrollArea, QSplitter, QTableView, QTabWidget, QToolBar, QVBoxLayout,
QWidget)
from ackit import settings as settings_mod, workspace as W
from ackit.catalog import available_archives
from ackit.game import Game, GameError, default_game_dir
from ackit.modpack import export_mod, match_mod, prepare_import
from ackit.modpack import export_mod, match_mod, match_texts, prepare_import
from ackit.texts import TextConflictError
from . import icons, theme
from .audioio import Player, find_device, output_devices
from .dialogs import ExportModDialog, ImportModDialog, SettingsDialog, pick_game_dir
from .editor import EditorPanel
from .models import C_FAV, C_NAME, COLS, SoundFilter, SoundModel
from .spinner import Spinner
from .textpanel import TextPanel
from .userdata import UserData
from .workers import Runner
@@ -201,12 +205,28 @@ class MainWindow(QMainWindow):
hs.addWidget(self.vsplit)
hs.setStretchFactor(1, 1)
hs.setSizes([SIDEBAR_MAX, 100000])
# sections: sounds, subtitles, menu text
self.tabs = QTabWidget()
self.tabs.setDocumentMode(True)
self.tabs.addTab(hs, icons.icon("wave"), "Sounds")
self.subs = TextPanel(self, "subs")
self.menu = TextPanel(self, "menu")
self.tabs.addTab(self.subs, icons.icon("ab"), "Subtitles")
self.tabs.addTab(self.menu, icons.icon("settings"), "Menu text")
self._text_loaded = set()
for panel in (self.subs, self.menu):
panel.message.connect(lambda m: self.statusBar().showMessage(m, 8000))
panel.changed.connect(self._texts_changed)
self.subs.go_to_sound.connect(self.show_sound)
self.editor.subtitle.changed.connect(self._texts_changed)
self.editor.subtitle.open_in_list.connect(self.show_subtitle)
self.tabs.currentChanged.connect(self._tab_changed)
central = QWidget()
cl = QVBoxLayout(central)
cl.setContentsMargins(0, 0, 0, 0)
cl.setSpacing(0)
cl.addWidget(self.banner)
cl.addWidget(hs, 1)
cl.addWidget(self.tabs, 1)
self.setCentralWidget(central)
self.spinner = Spinner(14)
self.statusBar().addWidget(self.spinner)
@@ -275,6 +295,8 @@ class MainWindow(QMainWindow):
self.cmb_lang.setCurrentText(lang)
self.cmb_lang.blockSignals(False)
self.populate()
self._text_loaded.clear()
self._tab_changed(self.tabs.currentIndex())
self.banner.hide()
self.lbl_game.setText("%s • store: %s" % (game.root, ws.store.root))
self.statusBar().showMessage("Loaded %d sounds (%s)." % (len(ws.catalog.sounds), ", ".join(ws.catalog.archives)), 6000)
@@ -434,6 +456,42 @@ class MainWindow(QMainWindow):
self.cmb_tag.setCurrentText(cur)
self.cmb_tag.blockSignals(False)
# --- text sections --------------------------------------------------------------------------
def _tab_changed(self, i):
panel = self.tabs.widget(i)
if self.ws and panel in (self.subs, self.menu) and panel.kind not in self._text_loaded:
self._text_loaded.add(panel.kind)
panel.load()
def _texts_changed(self):
for panel in (self.subs, self.menu):
if panel.kind in self._text_loaded:
panel.refresh()
self.editor.subtitle.set_sound(self.editor.sound)
def show_sound(self, key):
"""Sounds tab with `key` selected (from a subtitle)."""
s = self.ws.catalog.by_key.get(key) if self.ws else None
if not s:
self.inform("Sound", "%s is not in the sounds loaded now (choose its language at the top)." % key)
return
self.tabs.setCurrentIndex(0)
self.clear_filters()
self.select_name(s.name)
def show_subtitle(self, key):
"""Subtitles tab with `key` selected (from the sound editor)."""
self.tabs.setCurrentWidget(self.subs)
lang = key.split(":")[1]
if self.subs.lang != lang:
self.subs.load(lang)
self.subs.search.clear()
self.subs.cb_mod.setChecked(False)
for cb in self.subs.filters.values():
cb.setCurrentIndex(0)
self.subs._filter()
self.subs.select_key(key)
# --- changes ----------------------------------------------------------------------------------
def _after_change(self, key=None):
self.model.refresh_keys(modified=self.ws.modified_keys())
@@ -468,6 +526,7 @@ class MainWindow(QMainWindow):
self.spinner.hide()
self.statusBar().showMessage(ok_msg, 8000)
self._after_change()
self._texts_changed()
if self.editor.sound:
self.editor.set_sound(self.editor.sound)
self.check_problems()
@@ -493,11 +552,20 @@ class MainWindow(QMainWindow):
st = ws.state(s)
if st in (W.LOST, W.CHANGED):
out.append((s, st))
return out
texts = []
for key in list(ws.texts.records()):
try:
st = ws.texts.state(key)
except (GameError, OSError, ValueError):
continue
if st in (W.LOST, W.CHANGED):
texts.append((key, st))
return out, texts
def done(problems):
self.problems = problems
if not problems:
def done(res):
problems, texts = res
self.problems, self.text_problems = problems, texts
if not problems and not texts:
if self.banner.isVisible() and not self.b_choose.isVisible():
self.banner.hide()
return
@@ -510,6 +578,9 @@ class MainWindow(QMainWindow):
if changed:
txt.append("%d sound(s) were changed by something else since you modified them: %s. "
"Revert puts the original back." % (len(changed), ", ".join(changed[:4])))
if texts:
txt.append("%d of your text changes were undone or changed outside this editor: %s"
% (len(texts), ", ".join(k for k, _ in texts[:3]) + (" …" if len(texts) > 3 else "")))
self.banner_text.setText("\n".join(txt))
self.b_reapply.show()
self.b_forget.show()
@@ -522,15 +593,24 @@ class MainWindow(QMainWindow):
ws = self.ws
items = [(s, ws.store.get_blob(ws.store.get(s.key)["applied"]), ws.store.get(s.key).get("meta", {}))
for s, st in getattr(self, "problems", []) if ws.store.get(s.key) and ws.store.get(s.key).get("applied")]
if items:
self._run_change("Re-applying…", lambda job: ws.apply(items, force=True, progress=job.report),
"Re-applied %d change(s)." % len(items))
texts = {k: ws.texts.records()[k]["applied"] for k, st in getattr(self, "text_problems", [])
if k in ws.texts.records() and ws.texts.records()[k].get("applied") is not None}
def work(job):
if items:
ws.apply(items, force=True, progress=job.report)
if texts:
ws.texts.apply(texts, force=True, progress=job.report)
if items or texts:
self._run_change("Re-applying…", work, "Re-applied %d change(s)." % (len(items) + len(texts)))
def forget_problems(self):
if not self.confirm("Forget", "Stop tracking these sounds? Their current game files are kept as they are."):
if not self.confirm("Forget", "Stop tracking these changes? The current game files are kept as they are."):
return
for s, _ in getattr(self, "problems", []):
self.ws.store.forget(s.key)
for k, _ in getattr(self, "text_problems", []):
self.ws.texts.records().pop(k, None)
self.ws.store.save()
self.ws.store.gc()
self.banner.hide()
@@ -541,11 +621,13 @@ class MainWindow(QMainWindow):
if not self.ws:
return
mods = self.ws.modified_keys()
if not mods:
self.inform("Export mod", "You have not changed any sound yet.")
texts = self.ws.texts.modified()
if not mods and not texts:
self.inform("Export mod", "You have not changed any sound or text yet.")
return
if keys is None:
names = [(k, self.ws.catalog.by_key[k].name if k in self.ws.catalog.by_key else k) for k in mods]
names += [(k, "Text " + k) for k in texts]
dlg = ExportModDialog(names, self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
@@ -555,8 +637,10 @@ class MainWindow(QMainWindow):
self.settings.get("last_dir") or "", (title or "my_mod") + ".acmod"), "Unheard City mod (*.acmod)")
if not path:
return
self._run_change("Exporting…", lambda job: export_mod(self.ws, path, keys, title, author, description),
"Saved %s" % path)
text_keys = [k for k in keys if k.startswith(("SUB:", "MENU:"))]
keys = [k for k in keys if k not in text_keys]
self._run_change("Exporting…", lambda job: export_mod(self.ws, path, keys, title, author, description,
texts=text_keys), "Saved %s" % path)
def import_mod_dialog(self, path=None, confirm=True):
if not self.ws:
@@ -571,18 +655,27 @@ class MainWindow(QMainWindow):
except Exception as e: # noqa: BLE001
self.inform("Import mod", "Cannot read this package: %s" % e)
return
if confirm and ImportModDialog(man, items, self).exec() != QDialog.DialogCode.Accepted:
dlg = ImportModDialog(man, items, self, match_texts(self.ws, man)) if confirm else None
if dlg and dlg.exec() != QDialog.DialogCode.Accepted:
return
ws = self.ws
def work(job):
prepare_import(ws, path, items, progress=job.report)
changes = [(im.sound, im.wem, {"source": "mod:" + os.path.basename(path)}) for im in items if im.wem]
try:
ws.apply(changes, progress=job.report)
except W.ConflictError:
ws.apply(changes, force=True, progress=job.report)
return len(changes)
source = {"source": "mod:" + os.path.basename(path)}
changes = [(im.sound, im.wem, source) for im in items if im.wem]
if changes:
try:
ws.apply(changes, progress=job.report)
except W.ConflictError:
ws.apply(changes, force=True, progress=job.report)
texts = {k: v for k, v, problem in match_texts(ws, man) if not problem}
if texts:
try:
ws.texts.apply(texts, progress=job.report, meta=source)
except TextConflictError:
ws.texts.apply(texts, force=True, progress=job.report, meta=source)
return len(changes) + len(texts)
self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path))

556
app/textpanel.py Normal file
View File

@@ -0,0 +1,556 @@
"""Text sections of the main window: Subtitles (linked to the speech sounds) and Menu text.
One TextPanel per section: filters + table on top, the editor of the selected text below. Texts are written
through Workspace.texts (ackit/texts.py): subtitles in place into every copy (limited to the original's
length), menu texts into the localization files. Revert per text or all; changed-underneath warnings.
"""
from PySide6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, QTimer, Signal
from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSplitter, QTableView,
QVBoxLayout, QWidget)
from ackit import subtitles
from ackit.texts import CHANGED, FOREIGN, LOST, MODIFIED, ORIGINAL, TextConflictError
from . import icons
ANY = "Any"
STATE_TEXT = {ORIGINAL: "", MODIFIED: "modified", LOST: "lost", CHANGED: "changed outside", FOREIGN: "not original"}
class Row:
__slots__ = ("key", "cols", "text", "sound", "search")
def __init__(self, key, cols, text, sound=None):
self.key, self.cols, self.text, self.sound = key, cols, text, sound
self.search = (" ".join(str(c) for c in cols) + " " + text).lower()
class TextModel(QAbstractTableModel):
def __init__(self, headers, parent=None):
super().__init__(parent)
self.headers = headers + ["Text", "State"]
self.rows = []
self.modified = {} # key -> applied text
def reset(self, rows, modified):
self.beginResetModel()
self.rows, self.modified = rows, dict(modified)
self.endResetModel()
def set_modified(self, modified):
self.modified = dict(modified)
if self.rows:
self.dataChanged.emit(self.index(0, 0), self.index(len(self.rows) - 1, len(self.headers) - 1))
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.rows)
def columnCount(self, parent=QModelIndex()):
return len(self.headers)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
return self.headers[section]
return None
def text_of(self, r):
return self.modified.get(r.key, r.text)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
r = self.rows[index.row()]
c = index.column()
n = len(r.cols)
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole):
if c < n:
return r.cols[c]
if c == n:
return self.text_of(r)
return "modified" if r.key in self.modified else ""
if role == Qt.ItemDataRole.UserRole:
return r
if role == Qt.ItemDataRole.ForegroundRole and c == n + 1 and r.key in self.modified:
from PySide6.QtGui import QColor
return QColor(icons.ACCENT)
return None
class TextFilter(QSortFilterProxyModel):
def __init__(self, parent=None):
super().__init__(parent)
self.words, self.col_filters, self.only_modified = [], {}, False
self.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
def refilter(self):
if hasattr(self, "beginFilterChange"):
self.beginFilterChange()
self.endFilterChange(QSortFilterProxyModel.Direction.Rows)
else:
self.invalidateFilter()
def filterAcceptsRow(self, row, parent):
m = self.sourceModel()
r = m.rows[row]
if self.only_modified and r.key not in m.modified:
return False
for c, v in self.col_filters.items():
if v != ANY and r.cols[c] != v:
return False
if self.words:
hay = r.search + " " + m.modified.get(r.key, "").lower()
return all(w in hay for w in self.words)
return True
def row_at(self, index):
return self.sourceModel().rows[self.mapToSource(index).row()]
class FindReplaceDialog(QDialog):
def __init__(self, count, parent=None):
super().__init__(parent)
self.setWindowTitle("Find and replace")
f = QFormLayout(self)
self.find, self.repl = QLineEdit(), QLineEdit()
self.case = QCheckBox("Match case")
f.addRow(QLabel("In the %d text(s) shown by the current filter:" % count))
f.addRow("Find", self.find)
f.addRow("Replace with", self.repl)
f.addRow(self.case)
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
f.addRow(bb)
def replace_in(text, find, repl, case):
if case:
return text.replace(find, repl)
import re
return re.sub(re.escape(find), lambda _: repl, text, flags=re.IGNORECASE)
class SubtitleBox(QWidget):
"""The subtitle of the sound open in the sound editor (side by side with the audio)."""
changed = Signal()
open_in_list = Signal(str) # SUB key
def __init__(self, win, parent=None):
super().__init__(parent)
self.win = win
self.keys = []
self.lay = QFormLayout(self)
self.lay.setContentsMargins(0, 0, 0, 0)
self.edits = []
self.lbl = QLabel()
self.lbl.setObjectName("Muted")
row = QHBoxLayout()
self.b_apply = QPushButton(icons.icon("apply"), "Apply subtitle")
self.b_revert = QPushButton(icons.icon("revert"), "Revert subtitle")
self.b_open = QPushButton(icons.icon("search"), "Show in Subtitles")
self.b_apply.clicked.connect(self.apply)
self.b_revert.clicked.connect(self.revert)
self.b_open.clicked.connect(lambda: self.keys and self.open_in_list.emit(self.keys[0]))
for b in (self.b_apply, self.b_revert, self.b_open):
row.addWidget(b)
row.addStretch(1)
self.row = row
self.lay.addRow(self.lbl)
self.lay.addRow(row)
def set_sound(self, s):
for e in self.edits:
self.lay.removeRow(e)
self.edits, self.keys = [], []
ws = self.win.ws
lines = ws.texts.for_sound(s) if ws and s else []
if not lines:
self.lbl.setText("No subtitle for this sound." if s else "")
for b in (self.b_apply, self.b_revert, self.b_open):
b.setEnabled(False)
return
tx = ws.texts
for i, ln in enumerate(lines):
k = subtitles.key(ln.lang, ln.speech, ln.line)
e = QLineEdit(tx.records().get(k, {}).get("applied") or ln.text)
e.setToolTip("Original: %s\nRoom: %d characters" % (ln.text, ln.room))
e.textChanged.connect(self._check)
self.lay.insertRow(i, "Line %d" % (i + 1) if len(lines) > 1 else "Subtitle", e)
self.edits.append(e)
self.keys.append(k)
self.b_open.setEnabled(True)
self.b_revert.setEnabled(any(k in tx.records() for k in self.keys))
self._check()
def _check(self):
tx = self.win.ws.texts
msgs = []
ok = True
for k, e in zip(self.keys, self.edits):
try:
tx.check(k, e.text())
except ValueError as ex:
ok = False
msgs.append(str(ex))
rooms = [tx.limit(k) for k in self.keys]
self.lbl.setText(" • ".join(msgs) if msgs else "Room: %s characters (%s)" % (
" / ".join(str(r) for r in rooms), self.keys[0].split(":")[1]))
self.b_apply.setEnabled(ok)
def values(self):
return {k: e.text() for k, e in zip(self.keys, self.edits)}
def apply(self):
tx = self.win.ws.texts
changes = {k: v for k, v in self.values().items() if v != tx.original(k) or k in tx.records()}
if not changes:
return
self._run(lambda job, force: tx.apply(changes, force=force, progress=job.report), "Subtitle applied.")
def revert(self):
tx = self.win.ws.texts
self._run(lambda job, force: tx.revert(self.keys, progress=job.report), "Subtitle reverted.")
def _run(self, fn, ok_msg):
def done(_):
self.win.statusBar().showMessage(ok_msg, 6000)
self.changed.emit()
def failed(e, tb):
if isinstance(e, TextConflictError) and self.win.confirm("Changed outside", "%s\n\nOverwrite anyway?" % e):
self.win.runner.start(lambda job: fn(job, True), done, failed)
return
self.win.inform("Error", str(e))
self.win.runner.start(lambda job: fn(job, False), done, failed)
class TextPanel(QWidget):
"""kind 'subs' (subtitles) or 'menu' (menu text)."""
changed = Signal()
message = Signal(str)
go_to_sound = Signal(str) # sound key (subtitles)
def __init__(self, win, kind):
super().__init__(win)
self.win, self.kind = win, kind
self.lang = None
self.current = None # selected Row
self._build()
# --- UI ---------------------------------------------------------------------------------------
def _build(self):
v = QVBoxLayout(self)
v.setContentsMargins(6, 6, 6, 6)
top = QHBoxLayout()
self.search = QLineEdit()
self.search.setClearButtonEnabled(True)
self.search.setPlaceholderText("Search texts, names, characters…" if self.kind == "subs" else
"Search texts, files, keys…")
self.search.addAction(icons.icon("search", icons.MUTED), QLineEdit.ActionPosition.LeadingPosition)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(150)
self._timer.timeout.connect(self._filter)
self.search.textChanged.connect(lambda _: self._timer.start())
self.cmb_lang = QComboBox()
self.cmb_lang.setToolTip("Language of the texts")
self.cmb_lang.activated.connect(lambda _: self.load(self.cmb_lang.currentText()))
top.addWidget(self.search, 1)
top.addWidget(QLabel("Language"))
top.addWidget(self.cmb_lang)
self.filters = {}
names = ("Character", "Category") if self.kind == "subs" else ("File",)
for name in names:
cb = QComboBox()
cb.setMinimumWidth(120)
cb.currentTextChanged.connect(lambda _: self._filter())
top.addWidget(QLabel(name))
top.addWidget(cb)
self.filters[name] = cb
self.cb_mod = QCheckBox("Modified only")
self.cb_mod.toggled.connect(lambda _: self._filter())
top.addWidget(self.cb_mod)
self.lbl_count = QLabel()
top.addWidget(self.lbl_count)
v.addLayout(top)
headers = ["Speech line", "#", "Character", "Category", "Room"] if self.kind == "subs" else \
["File", "Section", "Key"]
self.model = TextModel(headers, self)
self.proxy = TextFilter(self)
self.proxy.setSourceModel(self.model)
self.table = QTableView()
self.table.setModel(self.proxy)
self.table.setSortingEnabled(True)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table.verticalHeader().hide()
self.table.verticalHeader().setDefaultSectionSize(22)
self.table.setAlternatingRowColors(True)
self.table.setWordWrap(False)
h = self.table.horizontalHeader()
h.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
h.setStretchLastSection(False)
widths = (330, 30, 120, 130, 50) if self.kind == "subs" else (140, 150, 220)
for c, w in enumerate(widths):
self.table.setColumnWidth(c, w)
self.table.setColumnWidth(len(widths), 560)
self.table.selectionModel().currentRowChanged.connect(lambda cur, _: self._select(cur))
# editor
ed = QWidget()
f = QFormLayout(ed)
self.title = QLabel("No text selected")
self.title.setObjectName("EditorTitle")
self.lbl_orig = QLabel()
self.lbl_orig.setWordWrap(True)
self.lbl_orig.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.edit = QLineEdit()
self.edit.textChanged.connect(self._edited)
self.edit.returnPressed.connect(self.apply_current)
self.lbl_info = QLabel()
row = QHBoxLayout()
self.b_apply = QPushButton(icons.icon("apply"), "Apply")
self.b_revert = QPushButton(icons.icon("revert"), "Revert")
self.b_replace = QPushButton(icons.icon("search"), "Find and replace…")
self.b_revert_all = QPushButton(icons.icon("revert"), "Revert all texts here")
self.b_apply.clicked.connect(self.apply_current)
self.b_revert.clicked.connect(self.revert_current)
self.b_replace.clicked.connect(self.find_replace)
self.b_revert_all.clicked.connect(self.revert_all)
for b in (self.b_apply, self.b_revert):
row.addWidget(b)
if self.kind == "subs":
self.b_sound = QPushButton(icons.icon("play"), "Go to the sound")
self.b_sound.clicked.connect(lambda: self.current and self.current.sound and
self.go_to_sound.emit(self.current.sound))
row.addWidget(self.b_sound)
row.addStretch(1)
row.addWidget(self.b_replace)
row.addWidget(self.b_revert_all)
f.addRow(self.title)
f.addRow("Original", self.lbl_orig)
f.addRow("Text", self.edit)
f.addRow("", self.lbl_info)
f.addRow(row)
split = QSplitter(Qt.Orientation.Vertical)
split.addWidget(self.table)
split.addWidget(ed)
split.setStretchFactor(0, 1)
split.setStretchFactor(1, 0)
v.addWidget(split, 1)
self._select(None)
# --- data ---------------------------------------------------------------------------------------
def ws(self):
return self.win.ws
def languages(self):
tx = self.ws().texts
return tx.subtitle_languages() if self.kind == "subs" else tx.menu_languages()
def load(self, lang=None):
"""(Re)load the rows of a language (default: the game's language)."""
ws = self.ws()
if not ws:
return
langs = self.languages()
lang = lang if lang in langs else (ws.game.language() if ws.game.language() in langs else
(langs[0] if langs else None))
self.cmb_lang.blockSignals(True)
self.cmb_lang.clear()
self.cmb_lang.addItems(langs)
if lang:
self.cmb_lang.setCurrentText(lang)
self.cmb_lang.blockSignals(False)
self.lang = lang
if not lang:
self.model.reset([], {})
return
tx = ws.texts
rows = []
if self.kind == "subs":
by_key = ws.catalog.by_key
for s in tx.subtitles(lang):
snd = by_key.get(s.sound) # None when the line's archive is not loaded
who = (snd.character or snd.characters_inferred) if snd else ""
cat = snd.category if snd else ""
rows.append(Row(subtitles.key(lang, s.speech, s.line),
[s.speech, s.line + 1, who, cat, s.room], s.text, s.sound or None))
else:
for e in tx.menu(lang):
rows.append(Row("MENU:" + e.key, [e.file, e.section, e.name + ("#%d" % e.occurrence if e.occurrence > 1 else "")],
self._menu_original(e)))
self.model.reset(rows, self._modified())
for i, cb in enumerate(self.filters.values()):
col = (2 + i) if self.kind == "subs" else 0
cur = cb.currentText()
cb.blockSignals(True)
cb.clear()
cb.addItems([ANY] + sorted({str(r.cols[col]) for r in rows if r.cols[col]}))
cb.setCurrentText(cur if cur else ANY)
cb.blockSignals(False)
self._filter()
def _menu_original(self, e):
r = self.ws().texts.records().get("MENU:" + e.key)
return r["orig"] if r else e.value
def _modified(self):
return {k: r["applied"] for k, r in self.ws().texts.records().items()
if r.get("applied") is not None and k.split(":")[1] == self.lang and
k.startswith("SUB:" if self.kind == "subs" else "MENU:")}
def refresh(self):
self.model.set_modified(self._modified())
self.proxy.refilter()
self._update_count()
if self.current:
self._show(self.current)
def _filter(self):
self.proxy.words = [w for w in self.search.text().lower().split() if w]
self.proxy.only_modified = self.cb_mod.isChecked()
cols = (2, 3) if self.kind == "subs" else (0,)
self.proxy.col_filters = {c: cb.currentText() for c, cb in zip(cols, self.filters.values())}
self.proxy.refilter()
self._update_count()
def _update_count(self):
self.lbl_count.setText("%d / %d" % (self.proxy.rowCount(), self.model.rowCount()))
def visible_rows(self):
return [self.proxy.row_at(self.proxy.index(i, 0)) for i in range(self.proxy.rowCount())]
def select_key(self, key):
for i in range(self.proxy.rowCount()):
if self.proxy.row_at(self.proxy.index(i, 0)).key == key:
self.table.selectRow(i)
self.table.scrollTo(self.proxy.index(i, 0))
return True
return False
# --- editing ------------------------------------------------------------------------------------
def _select(self, index):
r = self.proxy.row_at(index) if index is not None and index.isValid() else None
self.current = r
self._show(r)
def _show(self, r):
enabled = r is not None
for w in (self.edit, self.b_apply, self.b_revert):
w.setEnabled(enabled)
if self.kind == "subs":
self.b_sound.setEnabled(bool(r and r.sound))
if not r:
self.title.setText("No text selected")
self.lbl_orig.setText("")
self.edit.setText("")
self.lbl_info.setText("")
return
tx = self.ws().texts
self.title.setText(r.key)
self.lbl_orig.setText(r.text)
try:
cur = tx.current(r.key)
st = tx.state(r.key, cur)
except Exception as e: # noqa: BLE001 - unreadable file: show it, do not crash
cur, st = r.text, "error: %s" % e
self.edit.blockSignals(True)
self.edit.setText(cur)
self.edit.blockSignals(False)
self._state = st
self.b_revert.setEnabled(r.key in tx.records())
self._edited()
def _edited(self):
r = self.current
if not r:
return
tx = self.ws().texts
lim = tx.limit(r.key)
n = len(self.edit.text())
info = []
if lim is not None:
info.append("%d / %d characters" % (n, lim))
st = STATE_TEXT.get(self._state, self._state)
if st:
info.append("state: " + st)
try:
tx.check(r.key, self.edit.text())
ok = True
except ValueError as e:
info.append(str(e))
ok = False
self.lbl_info.setText(" • ".join(info))
self.b_apply.setEnabled(ok)
def _run(self, busy, fn, ok_msg):
def done(_):
self.message.emit(ok_msg)
self.refresh()
self.changed.emit()
def failed(e, tb):
if isinstance(e, TextConflictError) and self.win.confirm(
"Changed outside", "%s\n\nOverwrite anyway?" % e):
self.win.runner.start(lambda job: fn(job, True), done, failed)
return
self.win.inform("Error", str(e))
self.message.emit(busy)
self.win.runner.start(lambda job: fn(job, False), done, failed)
def apply_current(self):
r = self.current
if not r or not self.b_apply.isEnabled():
return
text = self.edit.text()
self._run("Writing…", lambda job, force: self.ws().texts.apply({r.key: text}, force=force, progress=job.report),
"Applied %s" % r.key)
def revert_current(self):
r = self.current
if r:
self._run("Reverting…", lambda job, force: self.ws().texts.revert([r.key], progress=job.report),
"Reverted %s" % r.key)
def revert_all(self):
keys = list(self._modified())
if not keys:
self.win.inform("Revert", "No %s text is modified." % self.lang)
return
if not self.win.confirm("Revert", "Put the original back for %d text(s)?" % len(keys)):
return
self._run("Reverting…", lambda job, force: self.ws().texts.revert(keys, progress=job.report),
"Reverted %d text(s)." % len(keys))
def find_replace(self, find=None, repl=None, case=False):
rows = self.visible_rows()
if find is None:
dlg = FindReplaceDialog(len(rows), self)
if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.find.text():
return
find, repl, case = dlg.find.text(), dlg.repl.text(), dlg.case.isChecked()
tx = self.ws().texts
changes, skipped = {}, []
for r in rows:
old = self.model.text_of(r)
new = replace_in(old, find, repl, case)
if new == old:
continue
try:
tx.check(r.key, new)
changes[r.key] = new
except ValueError as e:
skipped.append("%s: %s" % (r.key, e))
if not changes:
self.win.inform("Find and replace", "Nothing to replace." + ("\n" + "\n".join(skipped[:10]) if skipped else ""))
return
if skipped and not self.win.confirm("Find and replace", "%d text(s) cannot take the replacement and are "
"skipped:\n%s\n\nReplace in the other %d?" % (
len(skipped), "\n".join(skipped[:10]), len(changes))):
return
self._run("Replacing in %d text(s)…" % len(changes),
lambda job, force: tx.apply(changes, force=force, progress=job.report),
"Replaced in %d text(s)." % len(changes))
return len(changes)