"""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, Qt, QTimer, Signal from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QSplitter, QTableView, QVBoxLayout, QWidget) from ackit import subtitles from ackit.texts import CHANGED, FOREIGN, LOST, MODIFIED, ORIGINAL, TextConflictError from . import icons from .models import RowView from .spinner import BusyOverlay 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(RowView): """The text table's rows: search words, column filters, modified only; sorted case-insensitively.""" def __init__(self, parent=None): self.words, self.col_filters, self.only_modified = [], {}, False super().__init__(parent) def source_len(self): return len(self.sourceModel().rows) def sort_key(self, row, column): m = self.sourceModel() r = m.rows[row] n = len(r.cols) v = r.cols[column] if column < n else m.text_of(r) if column == n else ("modified" if r.key in m.modified else "") return v.lower() if isinstance(v, str) else v def accepts(self, row): 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.rows[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 = [] outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(6) self.lay = QFormLayout() # one line edit per subtitle line self.lay.setContentsMargins(0, 0, 0, 0) self.edits = [] self.lbl = QLabel() self.lbl.setObjectName("Muted") row = QHBoxLayout() row.setSpacing(6) self.b_apply = QPushButton(icons.icon("apply"), "Apply") self.b_revert = QPushButton(icons.icon("revert"), "Revert") self.b_open = QPushButton(icons.icon("search"), "Show in list") self.b_apply.setToolTip("Write this subtitle into the game (every copy)") self.b_revert.setToolTip("Put the original subtitle back") self.b_open.setToolTip("Open this subtitle in the Subtitles tab") 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): # two halves of the column, like the sound's action bar row.addWidget(b, 1) self.lbl.setWordWrap(True) outer.addLayout(self.lay) outer.addWidget(self.lbl) # room counter outer.addWidget(self.b_open) # the way to the Subtitles tab, across the column outer.addStretch(1) # actions at the bottom, level with the sound's outer.addLayout(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, self.win.language) 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.setMinimumWidth(80) e.setCursorPosition(0) # long subtitles: show their beginning 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 SubtitleEdit(QPlainTextEdit): """A subtitle line as a large wrapping text box (one line of text in the game: Enter is ignored and pasted line breaks become spaces). Has the QLineEdit calls SubtitleBox uses: text(), textChanged.""" def __init__(self, text, parent=None): super().__init__(parent) f = self.font() f.setPointSizeF(f.pointSizeF() * 1.3) self.setFont(f) self.setPlainText(text) self.setTabChangesFocus(True) self.setFixedHeight(self.fontMetrics().lineSpacing() * 2 + 14) # 2 lines at the Edit tab's width def text(self): return self.toPlainText() def setText(self, text): self.setPlainText(text) def keyPressEvent(self, e): if e.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): e.ignore() return super().keyPressEvent(e) def insertFromMimeData(self, source): self.insertPlainText(" ".join(source.text().split())) class SubtitlePanel(SubtitleBox): """The subtitle of the sound in the Edit tab: large text, the game's original under it (and the English line when editing another language), room counter, Apply / Revert / Show in list.""" def __init__(self, win, parent=None): super().__init__(win, parent) self.lines = QVBoxLayout() self.lines.setContentsMargins(0, 0, 0, 0) self.lines.setSpacing(4) outer = self.layout() outer.insertLayout(0, self.lines) outer.setSpacing(4) row = outer.itemAt(outer.count() - 1).layout() # one row: room counter | Apply | Revert | Show in list for w in (self.lbl, self.b_open): outer.removeWidget(w) row.insertWidget(0, self.lbl, 3) row.addWidget(self.b_open, 1) self._parts = [] self._english = {} def _english_text(self, ln): if ln.lang == "INT": return None if not self._english: try: self._english = {(x.speech, x.line): x.text for x in self.win.ws.texts.subtitles("INT")} except Exception: # noqa: BLE001 - only a reference text self._english = {None: None} return self._english.get((ln.speech, ln.line)) def set_sound(self, s): for w in self._parts: self.lines.removeWidget(w) w.deleteLater() self._parts, self.edits, self.keys = [], [], [] ws = self.win.ws lines = ws.texts.for_sound(s, self.win.language) 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 = SubtitleEdit(tx.records().get(k, {}).get("applied") or ln.text) e.setToolTip("Room: %d characters" % ln.room) e.textChanged.connect(self._check) self.lines.addWidget(e) part = " (part %d of %d)" % (i + 1, len(lines)) if len(lines) > 1 else "" ref = "Original%s: %s" % (part, ln.text) en = self._english_text(ln) if en and en != ln.text: ref += "\nEnglish: " + en r = QLabel(ref) r.setObjectName("Muted") r.setWordWrap(True) r.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.lines.addWidget(r) self._parts += [e, r] 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() 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.lbl_lang = QLabel() self.lbl_lang.setObjectName("Muted") self.lbl_lang.setToolTip("Change the language with the Language list at the top of the window") top.addWidget(self.search, 1) top.addWidget(self.lbl_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.busy = BusyOverlay(self.table) # while the texts of a language load (background job) self._load_gen = 0 self._pending_key = None 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 window's language).""" ws = self.ws() if not ws: return lang = lang or self.win.language if lang not in self.languages(): lang = None self.lang = lang self.lbl_lang.setText("Language: %s" % lang if lang else "No %s in this language" % ( "subtitles" if self.kind == "subs" else "menu text")) self._load_gen += 1 gen = self._load_gen if not lang: self.busy.stop() self.model.reset([], {}) return self.busy.start("Loading subtitles…" if self.kind == "subs" else "Loading menu text…") def done(rows): if gen == self._load_gen: # a newer load replaces this one self.busy.stop() self._show_rows(rows) def failed(e, tb): if gen == self._load_gen: self.busy.stop() self.message.emit("Could not load the texts: %s" % e) self.win.runner.start(lambda job: self._rows(ws, lang), done, failed) def _rows(self, ws, lang): """The table rows of a language (runs as a background job).""" 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.sounds[0]) if s.sounds else None # None: 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.sounds[0] if s.sounds else 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))) return rows def _show_rows(self, rows): 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() if self._pending_key: # asked for while the rows were loading key, self._pending_key = self._pending_key, None self.select_key(key) 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()) # the view refilters/re-sorts itself 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): if self.busy.busy: # rows still loading: select when they arrive self._pending_key = key return True 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)