Version 3.2.0: fast lists with loading spinners, names from the game files, INT docs and screenshots, cleanup

This commit is contained in:
2026-10-10 14:58:17 +02:00
parent fbde809370
commit 1586abb8e9
40 changed files with 449 additions and 19279 deletions

View File

@@ -19,7 +19,7 @@ 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 .spinner import BusyOverlay, Spinner
from .textpanel import TextPanel
from .userdata import UserData
from .workers import Runner
@@ -181,6 +181,7 @@ class MainWindow(QMainWindow):
self.proxy.setSourceModel(self.model)
self.table = QTableView()
self.table.setModel(self.proxy)
self.table_busy = BusyOverlay(self.table) # while the sound list loads (start, language change)
self.table.setSortingEnabled(True)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
@@ -294,6 +295,7 @@ class MainWindow(QMainWindow):
self.loading = True
self.statusBar().showMessage("Loading sound list…")
self.spinner.show()
self.table_busy.start("Loading sounds…")
store = self.settings.get("store_root")
full = self.settings.get("full_backup", True)
@@ -305,6 +307,7 @@ class MainWindow(QMainWindow):
def done(ws):
self.loading = False
self.spinner.hide()
self.table_busy.stop()
self.ws = ws
self.settings["game_dir"] = game.root
self.save_settings()
@@ -327,6 +330,7 @@ class MainWindow(QMainWindow):
def failed(e, tb):
self.loading = False
self.spinner.hide()
self.table_busy.stop()
self.show_no_game("Could not load the game audio: %s" % e)
self.runner.start(work, done, failed, lambda m: self.statusBar().showMessage(m))
@@ -512,8 +516,7 @@ class MainWindow(QMainWindow):
# --- changes ----------------------------------------------------------------------------------
def _after_change(self, key=None):
self.model.refresh_keys(modified=self.ws.modified_keys())
self.proxy.refilter()
self.model.refresh_keys(modified=self.ws.modified_keys()) # the view refilters/re-sorts itself
self._update_count()
def revert_sounds(self, sounds):

View File

@@ -1,5 +1,5 @@
"""Table model for the sound browser + a combined filter (search text and facet filters)."""
from PySide6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt
from PySide6.QtCore import QAbstractProxyModel, QAbstractTableModel, QModelIndex, Qt
from PySide6.QtGui import QFont, QPalette
from PySide6.QtWidgets import QApplication
@@ -40,6 +40,44 @@ class SoundModel(QAbstractTableModel):
if self.sounds:
self.dataChanged.emit(self.index(0, 0), self.index(len(self.sounds) - 1, len(COLS) - 1))
def sort_key(self, row, c):
"""Value a column sorts by (plain Python values: the view sorts all rows in one Python sort)."""
s, ud = self.sounds[row], self.userdata
if c == C_FAV:
return 0 if ud and ud.favorite(s.key) else 1
if c == C_LEN:
return s.seconds
if c == C_SIZE:
return 1e12 if s.is_pack_media else s.size
if c == C_FMT:
return (s.rate, s.channels)
if c == C_STATUS:
return 0 if s.key in self.modified else 1
return self.display(s, c).lower()
def display(self, s, c):
ud = self.userdata
if c == C_NAME:
return s.name
if c == C_CAT:
return s.category
if c == C_CHAR:
return s.character or (("~ " + s.characters_inferred) if s.characters_inferred else "")
if c == C_LEN:
return "%.2f s" % s.seconds
if c == C_ARCH:
return s.archive
if c == C_FMT:
return "%d Hz %s" % (s.rate, {1: "mono", 2: "stereo"}.get(s.channels, "%d ch" % s.channels))
if c == C_SIZE:
return "any" if s.is_pack_media else "%.1f KB" % (s.size / 1024.0)
if c == C_STATUS:
return "Modified" if s.key in self.modified else ""
if c == C_NOTES and ud:
t = ud.tags(s.key)
return " ".join("#" + x for x in t) + (" " if t else "") + ud.note(s.key)
return ""
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.sounds)
@@ -66,41 +104,9 @@ class SoundModel(QAbstractTableModel):
if role == SOUND_ROLE:
return s
if role == Qt.ItemDataRole.DisplayRole:
if c == C_FAV:
return ""
if c == C_NAME:
return s.name
if c == C_CAT:
return s.category
if c == C_CHAR:
return s.character or (("~ " + s.characters_inferred) if s.characters_inferred else "")
if c == C_LEN:
return "%.2f s" % s.seconds
if c == C_ARCH:
return s.archive
if c == C_FMT:
return "%d Hz %s" % (s.rate, {1: "mono", 2: "stereo"}.get(s.channels, "%d ch" % s.channels))
if c == C_SIZE:
return "any" if s.is_pack_media else "%.1f KB" % (s.size / 1024.0)
if c == C_STATUS:
return "Modified" if s.key in self.modified else ""
if c == C_NOTES:
if not ud:
return ""
t = ud.tags(s.key)
return " ".join("#" + x for x in t) + (" " if t else "") + ud.note(s.key)
return self.display(s, c)
if role == SORT_ROLE:
if c == C_FAV:
return 0 if ud and ud.favorite(s.key) else 1
if c == C_LEN:
return s.seconds
if c == C_SIZE:
return 1e12 if s.is_pack_media else s.size
if c == C_FMT:
return (s.rate, s.channels)
if c == C_STATUS:
return 0 if s.key in self.modified else 1
return (self.data(index, Qt.ItemDataRole.DisplayRole) or "").lower()
return self.sort_key(index.row(), c)
if role == Qt.ItemDataRole.DecorationRole and c == C_FAV:
fav = bool(ud and ud.favorite(s.key))
return icons.icon("star" if fav else "star-off", icons.ACCENT if fav else "#4a4a4a")
@@ -122,9 +128,122 @@ class SoundModel(QAbstractTableModel):
return None
class SoundFilter(QSortFilterProxyModel):
class RowView(QAbstractProxyModel):
"""The rows a table shows: the source rows a subclass accepts, in the chosen order. Computed in Python in
one pass (filter, then one sort over cached keys): ~10 ms for 21,000 sounds, where QSortFilterProxyModel
needed up to ~1.5 s per sort or widened filter (each comparison called back into Python). Selection and the
current row survive every change (persistent indexes are moved, as Qt's own proxies do).
Subclasses: source_len(), accepts(row), sort_key(row, column)."""
def __init__(self, parent=None):
super().__init__(parent)
self.sort_column, self.sort_order = -1, Qt.SortOrder.AscendingOrder
self.rows = [] # proxy row -> source row
self.pos = {} # source row -> proxy row
self._keys = {} # column -> [sort key per source row] (until the source changes)
def source_len(self):
return self.sourceModel().rowCount()
def accepts(self, row):
return True
def sort_key(self, row, column):
v = self.sourceModel().index(row, column).data()
return v.lower() if isinstance(v, str) else v
# --- source model --------------------------------------------------------------------------------
def setSourceModel(self, model):
"""One source model for the view's lifetime."""
self.beginResetModel()
super().setSourceModel(model)
model.modelAboutToBeReset.connect(self.beginResetModel)
model.modelReset.connect(self._source_reset)
model.dataChanged.connect(self._source_changed)
self._recompute_all()
self.endResetModel()
def _recompute_all(self):
self._keys = {}
self.rows = self._compute()
self.pos = {r: i for i, r in enumerate(self.rows)}
def _source_reset(self):
self._recompute_all()
self.endResetModel()
def _source_changed(self, top_left, bottom_right, roles=()):
"""Values changed (status, notes, favorites, texts): sort keys and filters may depend on them."""
self._keys = {}
self.refilter()
if self.rows:
self.dataChanged.emit(self.index(0, 0), self.index(len(self.rows) - 1, self.columnCount() - 1))
# --- filter and order ----------------------------------------------------------------------------
def refilter(self):
"""Recompute the rows; the view keeps its selection and current row (layout change)."""
rows = self._compute()
if rows == self.rows:
return
self.layoutAboutToBeChanged.emit()
old = self.persistentIndexList()
src = [(self.rows[i.row()], i.column()) if i.isValid() and i.row() < len(self.rows) else None for i in old]
self.rows = rows
self.pos = {r: i for i, r in enumerate(rows)}
new = [self.index(self.pos[s[0]], s[1]) if s and s[0] in self.pos else QModelIndex() for s in src]
self.changePersistentIndexList(old, new)
self.layoutChanged.emit()
def sort(self, column, order=Qt.SortOrder.AscendingOrder):
self.sort_column, self.sort_order = column, order
self.refilter()
def _compute(self):
if self.sourceModel() is None:
return []
n = self.source_len()
rows = [r for r in range(n) if self.accepts(r)]
c = self.sort_column
if c >= 0:
if c not in self._keys:
self._keys[c] = [self.sort_key(r, c) for r in range(n)]
rows.sort(key=self._keys[c].__getitem__, reverse=self.sort_order == Qt.SortOrder.DescendingOrder)
return rows
# --- QAbstractProxyModel -------------------------------------------------------------------------
def index(self, row, column, parent=QModelIndex()):
if parent.isValid() or not (0 <= row < len(self.rows)) or not (0 <= column < self.columnCount()):
return QModelIndex()
return self.createIndex(row, column)
def parent(self, index=QModelIndex()):
return QModelIndex()
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.rows)
def columnCount(self, parent=QModelIndex()):
m = self.sourceModel()
return 0 if parent.isValid() or m is None else m.columnCount()
def mapToSource(self, proxy_index):
if not proxy_index.isValid() or proxy_index.row() >= len(self.rows):
return QModelIndex()
return self.sourceModel().index(self.rows[proxy_index.row()], proxy_index.column())
def mapFromSource(self, source_index):
if not source_index.isValid() or source_index.row() not in self.pos:
return QModelIndex()
return self.index(self.pos[source_index.row()], source_index.column())
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
return self.sourceModel().headerData(section, orientation, role)
class SoundFilter(RowView):
"""The sound browser's rows: search words and facet filters, sorted by any column."""
def __init__(self, parent=None):
self.words = []
self.group = None
self.category = None
@@ -134,15 +253,7 @@ class SoundFilter(QSortFilterProxyModel):
self.modified_only = False
self.favorites_only = False
self.tag = None
self.setSortRole(SORT_ROLE)
self.setDynamicSortFilter(False)
def refilter(self):
if hasattr(self, "beginFilterChange"): # Qt >= 6.10
self.beginFilterChange()
self.endFilterChange(QSortFilterProxyModel.Direction.Rows)
else:
self.invalidateRowsFilter()
super().__init__(parent)
def set(self, **kw):
for k, v in kw.items():
@@ -153,7 +264,13 @@ class SoundFilter(QSortFilterProxyModel):
self.words = [w for w in text.lower().replace("_", " ").split() if w]
self.refilter()
def filterAcceptsRow(self, row, parent):
def source_len(self):
return len(self.sourceModel().sounds)
def sort_key(self, row, column):
return self.sourceModel().sort_key(row, column)
def accepts(self, row):
m = self.sourceModel()
s = m.sounds[row]
if self.group and s.group != self.group:
@@ -181,4 +298,4 @@ class SoundFilter(QSortFilterProxyModel):
return True
def sound_at(self, proxy_index):
return self.sourceModel().sounds[self.mapToSource(proxy_index).row()]
return self.sourceModel().sounds[self.rows[proxy_index.row()]]

View File

@@ -1,6 +1,6 @@
"""Small activity spinner: a rotating accent-coloured arc. Runs only while visible."""
from PySide6.QtCore import QRectF, QTimer, Qt
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtCore import QEvent, QRectF, QTimer, Qt
from PySide6.QtGui import QColor, QPainter, QPalette, QPen
from PySide6.QtWidgets import QWidget
from . import icons
@@ -51,3 +51,68 @@ class Spinner(QWidget):
def paintEvent(self, _):
p = QPainter(self)
paint_arc(p, QRectF(self.rect()), self.angle)
class BusyOverlay(QWidget):
"""A list being (re)loaded: a light veil over the widget with the spinner and a message in its centre, the
same arc as the waveform's loading state. Covers the target and follows its size; blocks clicks while on."""
def __init__(self, target):
super().__init__(target)
self.target = target
self.message = ""
self.angle = 0
self.timer = QTimer(self)
self.timer.setInterval(STEP_MS)
self.timer.timeout.connect(self._step)
target.installEventFilter(self)
self.hide()
def start(self, message):
self.message = message
self.setGeometry(self.target.rect())
self.raise_()
self.show()
self.timer.start()
self.update()
def stop(self):
self.timer.stop()
self.hide()
@property
def busy(self):
return not self.isHidden() # on, whether or not the window is on screen yet
def _step(self):
self.angle = (self.angle + STEP_DEG) % 360
if self.isVisible():
self.update()
def eventFilter(self, obj, e):
if obj is self.target and e.type() == QEvent.Type.Resize:
self.setGeometry(self.target.rect())
return False
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
pal = self.palette()
veil = QColor(pal.color(QPalette.ColorRole.Base))
veil.setAlpha(210)
p.fillRect(self.rect(), veil)
fm = p.fontMetrics()
d, gap, pad = 20, 12, 16
tw = fm.horizontalAdvance(self.message)
w, h = pad + d + gap + tw + pad, max(d, fm.height()) + 2 * pad - 8
box = QRectF((self.width() - w) / 2, (self.height() - h) / 2, w, h)
border = QColor(pal.color(QPalette.ColorRole.Text))
border.setAlpha(40)
p.setPen(QPen(border, 1))
p.setBrush(pal.color(QPalette.ColorRole.Window))
p.drawRoundedRect(box, 8, 8)
y = box.center().y()
paint_arc(p, QRectF(box.left() + pad, y - d / 2, d, d), self.angle)
p.setPen(pal.color(QPalette.ColorRole.Text))
p.drawText(QRectF(box.left() + pad + d + gap, y - fm.height() / 2, tw + 2, fm.height()),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, self.message)

View File

@@ -4,7 +4,7 @@ One TextPanel per section: filters + table on top, the editor of the selected te
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.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer, Signal
from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSplitter, QTableView,
QVBoxLayout, QWidget)
@@ -12,6 +12,8 @@ from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog,
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"}
@@ -74,20 +76,24 @@ class TextModel(QAbstractTableModel):
return None
class TextFilter(QSortFilterProxyModel):
class TextFilter(RowView):
"""The text table's rows: search words, column filters, modified only; sorted case-insensitively."""
def __init__(self, parent=None):
super().__init__(parent)
self.words, self.col_filters, self.only_modified = [], {}, False
self.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
super().__init__(parent)
def refilter(self):
if hasattr(self, "beginFilterChange"):
self.beginFilterChange()
self.endFilterChange(QSortFilterProxyModel.Direction.Rows)
else:
self.invalidateFilter()
def source_len(self):
return len(self.sourceModel().rows)
def filterAcceptsRow(self, row, parent):
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:
@@ -101,7 +107,7 @@ class TextFilter(QSortFilterProxyModel):
return True
def row_at(self, index):
return self.sourceModel().rows[self.mapToSource(index).row()]
return self.sourceModel().rows[self.rows[index.row()]]
class FindReplaceDialog(QDialog):
@@ -288,6 +294,9 @@ class TextPanel(QWidget):
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)
@@ -367,9 +376,28 @@ class TextPanel(QWidget):
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":
@@ -384,6 +412,9 @@ class TextPanel(QWidget):
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
@@ -394,6 +425,9 @@ class TextPanel(QWidget):
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)
@@ -405,8 +439,7 @@ class TextPanel(QWidget):
k.startswith("SUB:" if self.kind == "subs" else "MENU:")}
def refresh(self):
self.model.set_modified(self._modified())
self.proxy.refilter()
self.model.set_modified(self._modified()) # the view refilters/re-sorts itself
self._update_count()
if self.current:
self._show(self.current)
@@ -426,6 +459,9 @@ class TextPanel(QWidget):
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)