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