Version 3.2.0: fast lists with loading spinners, names from the game files, INT docs and screenshots, cleanup
This commit is contained in:
211
app/models.py
211
app/models.py
@@ -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()]]
|
||||
|
||||
Reference in New Issue
Block a user