From c6c062bc50fc179d26b386348e1a9f8d3f93c496 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 8 May 2026 20:56:10 -0400 Subject: [PATCH] Convert tag filter to multi-select with search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- steam_dice.py | 225 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 178 insertions(+), 47 deletions(-) diff --git a/steam_dice.py b/steam_dice.py index 5ac2aa6..37e530a 100755 --- a/steam_dice.py +++ b/steam_dice.py @@ -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