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