Files
UnheardCity/app/textpanel.py

552 lines
22 KiB
Python

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