Files
UnheardCity/app/models.py

302 lines
12 KiB
Python

"""Table model for the sound browser + a combined filter (search text and facet filters)."""
from PySide6.QtCore import QAbstractProxyModel, QAbstractTableModel, QModelIndex, Qt
from PySide6.QtGui import QFont, QPalette
from PySide6.QtWidgets import QApplication
from . import icons
COLS = ["", "Name", "Category", "Character", "Length", "Archive", "Format", "Max size", "Status", "Notes / tags"]
C_FAV, C_NAME, C_CAT, C_CHAR, C_LEN, C_ARCH, C_FMT, C_SIZE, C_STATUS, C_NOTES = range(len(COLS))
SORT_ROLE = Qt.ItemDataRole.UserRole + 1
SOUND_ROLE = Qt.ItemDataRole.UserRole + 2
class SoundModel(QAbstractTableModel):
def __init__(self, parent=None):
super().__init__(parent)
self.sounds = []
self.modified = set()
self.userdata = None
self.hay = []
def reset(self, sounds, modified, userdata):
self.beginResetModel()
self.sounds, self.modified, self.userdata = list(sounds), set(modified), userdata
self._build_hay()
self.endResetModel()
def _build_hay(self):
ud = self.userdata
self.hay = ["\n".join((s.name, s.category, s.character, s.characters_inferred,
ud.note(s.key) if ud else "", " ".join(ud.tags(s.key)) if ud else "")).lower()
for s in self.sounds]
self.ids = [s.target.replace(":", " ").replace("/", " ").lower() for s in self.sounds]
def refresh_keys(self, keys=None, modified=None):
"""Update status / notes of some rows (all if keys is None)."""
if modified is not None:
self.modified = set(modified)
self._build_hay()
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)
def columnCount(self, parent=QModelIndex()):
return len(COLS)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
return COLS[section]
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DecorationRole and section == C_FAV:
return icons.icon("star", icons.ACCENT)
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.ToolTipRole:
return {C_FAV: "Favorite", C_CHAR: "Verified speaker; ~grey = characters mentioned by the name",
C_SIZE: "Largest replacement that fits (speech/effects keep the original's size)",
C_STATUS: "Modified = contains your replacement"}.get(section)
return None
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
s = self.sounds[index.row()]
c = index.column()
ud = self.userdata
if role == SOUND_ROLE:
return s
if role == Qt.ItemDataRole.DisplayRole:
return self.display(s, c)
if role == SORT_ROLE:
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")
if role == Qt.ItemDataRole.DecorationRole and c == C_STATUS and s.key in self.modified:
return icons.icon("apply", icons.ACCENT)
if role == Qt.ItemDataRole.ForegroundRole and c == C_CHAR and not s.character:
return QApplication.palette().color(QPalette.ColorRole.PlaceholderText)
if role == Qt.ItemDataRole.FontRole and s.key in self.modified and c in (C_NAME, C_STATUS):
f = QFont()
f.setBold(True)
return f
if role == Qt.ItemDataRole.ToolTipRole and c in (C_NAME, C_CHAR):
tip = "%s\n%s %s" % (s.name, s.key, s.group + " / " + s.category)
if s.character:
tip += "\nSpeaker: " + s.character
if s.characters_inferred:
tip += "\nMentioned in the name: " + s.characters_inferred
return tip
return None
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
self.character = None
self.include_inferred = False
self.archive = None
self.modified_only = False
self.favorites_only = False
self.tag = None
super().__init__(parent)
def set(self, **kw):
for k, v in kw.items():
setattr(self, k, v)
self.refilter()
def set_text(self, text):
self.words = [w for w in text.lower().replace("_", " ").split() if w]
self.refilter()
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:
return False
if self.category and s.category != self.category:
return False
if self.archive and s.archive != self.archive:
return False
if self.character:
c = self.character.lower()
if c not in s.character.lower() and not (self.include_inferred and c in s.characters_inferred.lower()):
return False
if self.modified_only and s.key not in m.modified:
return False
ud = m.userdata
if self.favorites_only and not (ud and ud.favorite(s.key)):
return False
if self.tag and not (ud and self.tag in ud.tags(s.key)):
return False
if self.words:
h = m.hay[row]
for w in self.words:
if w not in h and not (len(w) >= 6 and w in m.ids[row]): # IDs like b49cdf48 also work
return False
return True
def sound_at(self, proxy_index):
return self.sourceModel().sounds[self.rows[proxy_index.row()]]