Convert tag filter to multi-select with search

Replaces the single-select tag QComboBox with a TagsButton + TagsPopup
mirroring the friends pattern: a combo-styled button labelled "Tags (N) ▾"
opens a borderless popup containing a search input and a checkable list of
tags. Selecting multiple tags AND-filters games — a game must carry every
selected tag to survive — which makes it easy to narrow large libraries to
specific intersections (e.g. Roguelike + Co-op).

The search input is auto-focused on popup open and filters the list as the
user types, with a status line showing "N of M tag(s)". Selection state
survives search filtering and popup close/reopen.

The popup style and button style are renamed from FRIENDS_*_STYLE to
MULTISELECT_*_STYLE and reused by both FriendsPopup and TagsPopup; the
popup objectName moves from "FriendsPopup" to "MultiSelectPopup". The
TagsPopup adds a QLineEdit selector to the shared style.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 20:56:10 -04:00
parent 76dc76a83b
commit c6c062bc50
+178 -47
View File
@@ -307,7 +307,7 @@ COMBO_STYLE = """
}
"""
FRIENDS_BTN_STYLE = """
MULTISELECT_BTN_STYLE = """
QPushButton {
background-color: #2a3f5f;
color: #c6d4df;
@@ -321,22 +321,30 @@ FRIENDS_BTN_STYLE = """
QPushButton:disabled { color: #4a5a6a; border-color: #2a3a50; }
"""
FRIENDS_POPUP_STYLE = """
QFrame#FriendsPopup {
MULTISELECT_POPUP_STYLE = """
QFrame#MultiSelectPopup {
background-color: #1b2838;
border: 1px solid #3d5a7a;
border-radius: 4px;
}
QFrame#FriendsPopup QLabel { color: #8f98a0; }
QFrame#FriendsPopup QPushButton {
QFrame#MultiSelectPopup QLabel { color: #8f98a0; }
QFrame#MultiSelectPopup QPushButton {
background-color: #2a3f5f;
color: #c6d4df;
border: 1px solid #3d5a7a;
border-radius: 4px;
padding: 2px 4px;
}
QFrame#FriendsPopup QPushButton:hover { background-color: #3d5a7a; }
QFrame#FriendsPopup QListWidget {
QFrame#MultiSelectPopup QPushButton:hover { background-color: #3d5a7a; }
QFrame#MultiSelectPopup QLineEdit {
background-color: #2a3f5f;
color: #c6d4df;
border: 1px solid #3d5a7a;
border-radius: 3px;
padding: 3px 6px;
}
QFrame#MultiSelectPopup QLineEdit:focus { border-color: #5a8ab0; }
QFrame#MultiSelectPopup QListWidget {
background-color: #2a3f5f;
color: #c6d4df;
border: 1px solid #3d5a7a;
@@ -344,9 +352,9 @@ FRIENDS_POPUP_STYLE = """
outline: none;
padding: 2px;
}
QFrame#FriendsPopup QListWidget::item { padding: 3px 4px; }
QFrame#FriendsPopup QListWidget::item:selected,
QFrame#FriendsPopup QListWidget::item:hover { background-color: #3d6b9e; }
QFrame#MultiSelectPopup QListWidget::item { padding: 3px 4px; }
QFrame#MultiSelectPopup QListWidget::item:selected,
QFrame#MultiSelectPopup QListWidget::item:hover { background-color: #3d6b9e; }
"""
REFRESH_STYLE = """
@@ -591,8 +599,8 @@ class FriendsPopup(QFrame):
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Popup)
self.setObjectName("FriendsPopup")
self.setStyleSheet(FRIENDS_POPUP_STYLE)
self.setObjectName("MultiSelectPopup")
self.setStyleSheet(MULTISELECT_POPUP_STYLE)
self.setFixedWidth(240)
layout = QVBoxLayout(self)
@@ -671,7 +679,7 @@ class FriendsButton(QPushButton):
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet(FRIENDS_BTN_STYLE)
self.setStyleSheet(MULTISELECT_BTN_STYLE)
self.popup = FriendsPopup(self)
self.popup.selection_changed.connect(self._on_selection_changed)
self.popup.refresh_requested.connect(self.refresh_requested)
@@ -721,6 +729,139 @@ class FriendsButton(QPushButton):
self.setText(f"Friends ({n}) ▾" if n else "Friends ▾")
class TagsPopup(QFrame):
"""Borderless popup with a search input and checkable tag list."""
selection_changed = pyqtSignal(set)
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Popup)
self.setObjectName("MultiSelectPopup")
self.setStyleSheet(MULTISELECT_POPUP_STYLE)
self.setFixedWidth(260)
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.setSpacing(4)
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Search tags…")
self.search_input.textChanged.connect(self._apply_search_filter)
layout.addWidget(self.search_input)
self.status_label = QLabel("")
status_font = QFont()
status_font.setPointSize(8)
self.status_label.setFont(status_font)
layout.addWidget(self.status_label)
self.list_widget = QListWidget()
self.list_widget.setMinimumHeight(220)
self.list_widget.setMaximumHeight(420)
self.list_widget.itemChanged.connect(self._on_item_changed)
layout.addWidget(self.list_widget)
self._suppress = False
def populate(self, tags, selected):
"""tags: sorted iterable of tag-name strings; selected: set of names."""
self._suppress = True
self.list_widget.clear()
for t in tags:
item = QListWidgetItem(t)
item.setData(Qt.ItemDataRole.UserRole, t)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(
Qt.CheckState.Checked if t in selected else Qt.CheckState.Unchecked
)
self.list_widget.addItem(item)
self._suppress = False
self._apply_search_filter(self.search_input.text())
def _apply_search_filter(self, text):
needle = text.lower().strip()
total = self.list_widget.count()
visible = 0
for i in range(total):
it = self.list_widget.item(i)
if it is None:
continue
label = (it.data(Qt.ItemDataRole.UserRole) or it.text() or "").lower()
match = needle in label if needle else True
it.setHidden(not match)
if match:
visible += 1
if needle:
self.status_label.setText(f"{visible} of {total} tag(s)")
else:
self.status_label.setText(f"{total} tag(s)")
def showEvent(self, a0):
super().showEvent(a0)
# Auto-focus the search field so the user can start typing immediately.
self.search_input.setFocus()
def _on_item_changed(self, _item):
if self._suppress:
return
selected = set()
for i in range(self.list_widget.count()):
it = self.list_widget.item(i)
if it is None:
continue
if it.checkState() == Qt.CheckState.Checked:
selected.add(it.data(Qt.ItemDataRole.UserRole))
self.selection_changed.emit(selected)
class TagsButton(QPushButton):
"""Combo-styled button that opens a checkable tag list popup with search."""
selection_changed = pyqtSignal(set)
locked = pyqtSignal() # clicked while no tags are loaded
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet(MULTISELECT_BTN_STYLE)
self.popup = TagsPopup(self)
self.popup.selection_changed.connect(self._on_selection_changed)
self._tags = []
self._selected = set()
self._unlocked = False
self._update_label()
self.clicked.connect(self._handle_click)
def _handle_click(self):
if self._unlocked and self._tags:
self._show_popup()
else:
self.locked.emit()
def _show_popup(self):
pos = self.mapToGlobal(QPoint(0, self.height()))
self.popup.move(pos)
self.popup.show()
def populate(self, tags):
self._tags = list(tags)
# Drop any selected tag that disappeared from the cache.
self._selected = {t for t in self._selected if t in self._tags}
if self._tags:
self._unlocked = True
self.popup.populate(self._tags, self._selected)
self._update_label()
def selected(self):
return set(self._selected)
def _on_selection_changed(self, selected):
self._selected = selected
self._update_label()
self.selection_changed.emit(selected)
def _update_label(self):
n = len(self._selected)
self.setText(f"Tags ({n}) ▾" if n else "Tags ▾")
DIALOG_STYLE = """
QDialog, QWidget { background-color: #1b2838; }
QLabel { color: #c6d4df; }
@@ -915,7 +1056,7 @@ class SteamDice(QMainWindow):
self.genre_combo.popup_blocked.connect(self._prompt_genre_fetch)
# Always visible (empty text when idle) so the column height stays the
# same as the filter and tag columns — keeps all three combos aligned.
# same as the sibling columns — keeps all four controls aligned.
self.genre_progress_label = QLabel()
progress_font = QFont()
progress_font.setPointSize(8)
@@ -923,13 +1064,14 @@ class SteamDice(QMainWindow):
self.genre_progress_label.setStyleSheet("color: #4a5a6a;")
top_row.addLayout(_combo_column(self.genre_combo, self.genre_progress_label))
# Tag filter (uses store_tags from appinfo.vdf)
self.tag_combo = LazyComboBox()
self.tag_combo.addItem("All tags")
self.tag_combo.setEnabled(False)
self.tag_combo.currentIndexChanged.connect(self._apply_filter)
self.tag_combo.popup_blocked.connect(self._prompt_tags_fetch)
top_row.addLayout(_combo_column(self.tag_combo, QLabel()))
# Tag filter (multi-select via checklist popup with search; uses
# store_tags from appinfo.vdf — same source as before, just AND'd
# over multiple selections).
self.tags_btn = TagsButton()
self.tags_btn.setEnabled(False)
self.tags_btn.selection_changed.connect(self._apply_filter)
self.tags_btn.locked.connect(self._prompt_tags_fetch)
top_row.addLayout(_combo_column(self.tags_btn, QLabel()))
# Friends filter (multi-select via checklist popup). Intersects the user's
# library with each selected friend's owned-game set so only games
@@ -946,8 +1088,6 @@ class SteamDice(QMainWindow):
# When the cache already has data, allow the dropdowns to open immediately.
if any(isinstance(e, dict) and e.get("genres") for e in self.taxonomy_cache.values()):
self.genre_combo.set_allow_popup(True)
if any(isinstance(e, dict) and e.get("tags") for e in self.taxonomy_cache.values()):
self.tag_combo.set_allow_popup(True)
top_row.addStretch()
@@ -1131,7 +1271,7 @@ class SteamDice(QMainWindow):
self.installed_appids = _scan_installed_appids()
self.filter_combo.setEnabled(True)
self.genre_combo.setEnabled(True)
self.tag_combo.setEnabled(True)
self.tags_btn.setEnabled(True)
self.friends_btn.setEnabled(True)
# Try Steam's local appinfo.vdf cache first — instant, no network.
@@ -1140,7 +1280,7 @@ class SteamDice(QMainWindow):
self._read_appinfo_into_cache()
self._rebuild_genre_combo()
self._rebuild_tag_combo()
self._rebuild_tags_btn()
self.refresh_btn.setEnabled(True)
self._apply_filter()
@@ -1161,8 +1301,6 @@ class SteamDice(QMainWindow):
pass
if any(e.get("genres") for e in local.values()):
self.genre_combo.set_allow_popup(True)
if any(e.get("tags") for e in local.values()):
self.tag_combo.set_allow_popup(True)
def _fetch_tags_table(self):
if self.tags_table_thread and self.tags_table_thread.isRunning():
@@ -1185,7 +1323,7 @@ class SteamDice(QMainWindow):
pass
# Re-read appinfo now that we can translate tag IDs.
self._read_appinfo_into_cache()
self._rebuild_tag_combo()
self._rebuild_tags_btn()
self._apply_filter()
def _on_library_error(self, msg):
@@ -1210,11 +1348,13 @@ class SteamDice(QMainWindow):
if genre in self.taxonomy_cache.get(str(g["appid"]), {}).get("genres", [])
]
tag = self.tag_combo.currentData()
if tag:
tags = self.tags_btn.selected()
if tags:
games = [
g for g in games
if tag in self.taxonomy_cache.get(str(g["appid"]), {}).get("tags", [])
if tags.issubset(
self.taxonomy_cache.get(str(g["appid"]), {}).get("tags", [])
)
]
# Friend filter: intersection — keep only games every selected friend
@@ -1263,24 +1403,15 @@ class SteamDice(QMainWindow):
self.genre_combo.setCurrentIndex(idx)
self.genre_combo.blockSignals(False)
def _rebuild_tag_combo(self):
"""Rebuild the tag dropdown from the current cache, preserving selection."""
current = self.tag_combo.currentData()
def _rebuild_tags_btn(self):
"""Rebuild the tag list from the current cache. Selection is preserved
by TagsButton.populate (drops any tag that's no longer in the cache)."""
all_tags = sorted({
t for entry in self.taxonomy_cache.values()
if isinstance(entry, dict)
for t in entry.get("tags", [])
})
self.tag_combo.blockSignals(True)
self.tag_combo.clear()
self.tag_combo.addItem("All tags", None)
for t in all_tags:
self.tag_combo.addItem(t, t)
if current:
idx = self.tag_combo.findData(current)
if idx >= 0:
self.tag_combo.setCurrentIndex(idx)
self.tag_combo.blockSignals(False)
self.tags_btn.populate(all_tags)
def _prompt_genre_fetch(self):
if self.genres_thread and self.genres_thread.isRunning():
@@ -1321,9 +1452,9 @@ class SteamDice(QMainWindow):
self.genres_thread.start()
def _prompt_tags_fetch(self):
# The tag combo only has data when both the tag-id table AND appinfo.vdf
# are available. There's no API fallback for tags, so just explain the
# situation if we can't populate it.
# The tag list only populates when both the tag-id table AND
# appinfo.vdf are available. There's no API fallback for tags, so just
# explain the situation if we can't populate it.
try:
from steam.utils.appcache import parse_appinfo # noqa: F401
has_steam = True