From 730729ccd509c7cf96b82caa1eb1a0912fff5018 Mon Sep 17 00:00:00 2001
From: HJfod <60038575+HJfod@users.noreply.github.com>
Date: Sat, 30 Mar 2024 21:07:29 +0200
Subject: [PATCH] impl filtering by tags

---
 loader/src/ui/GeodeUI.cpp                    |   5 +-
 loader/src/ui/mods/GeodeStyle.cpp            |  28 ++++-
 loader/src/ui/mods/GeodeStyle.hpp            |   6 +-
 loader/src/ui/mods/list/ModItem.cpp          |   6 +-
 loader/src/ui/mods/list/ModList.cpp          |  12 +-
 loader/src/ui/mods/list/ModList.hpp          |   2 +-
 loader/src/ui/mods/popups/FiltersPopup.cpp   | 126 +++++++++++++++++++
 loader/src/ui/mods/popups/FiltersPopup.hpp   |  27 ++++
 loader/src/ui/mods/popups/ModPopup.cpp       |  37 ++----
 loader/src/ui/mods/popups/TagsPopup.cpp      |  27 ----
 loader/src/ui/mods/popups/TagsPopup.hpp      |  19 ---
 loader/src/ui/mods/sources/ModListSource.cpp |  30 +++++
 loader/src/ui/mods/sources/ModListSource.hpp |  16 ++-
 13 files changed, 242 insertions(+), 99 deletions(-)
 create mode 100644 loader/src/ui/mods/popups/FiltersPopup.cpp
 create mode 100644 loader/src/ui/mods/popups/FiltersPopup.hpp
 delete mode 100644 loader/src/ui/mods/popups/TagsPopup.cpp
 delete mode 100644 loader/src/ui/mods/popups/TagsPopup.hpp

diff --git a/loader/src/ui/GeodeUI.cpp b/loader/src/ui/GeodeUI.cpp
index 40a9daef..0a7c4711 100644
--- a/loader/src/ui/GeodeUI.cpp
+++ b/loader/src/ui/GeodeUI.cpp
@@ -5,6 +5,7 @@
 #include <Geode/ui/MDPopup.hpp>
 #include <Geode/utils/web.hpp>
 #include <server/Server.hpp>
+#include "mods/GeodeStyle.hpp"
 
 void geode::openModsList() {
     ModsLayer::scene();
@@ -98,9 +99,7 @@ protected:
         }
         // Asynchronously fetch from server
         else {
-            this->setSprite(CCSprite::create("loadingCircle.png"));
-            static_cast<CCSprite*>(m_sprite)->setBlendFunc({ GL_ONE, GL_ONE });
-            m_sprite->runAction(CCRepeatForever::create(CCRotateBy::create(1.f, 360.f)));
+            this->setSprite(createLoadingCircle(25));
             m_listener.setFilter(server::ServerResultCache<&server::getModLogo>::shared().get(id).listen());
         }
 
diff --git a/loader/src/ui/mods/GeodeStyle.cpp b/loader/src/ui/mods/GeodeStyle.cpp
index d83d383b..fe609e7c 100644
--- a/loader/src/ui/mods/GeodeStyle.cpp
+++ b/loader/src/ui/mods/GeodeStyle.cpp
@@ -63,6 +63,21 @@ GeodeSquareSprite* GeodeSquareSprite::createWithSpriteFrameName(const char* top,
     return nullptr;
 }
 
+CCNode* createLoadingCircle(float sideLength, const char* id) {
+    auto spinnerContainer = CCNode::create();
+    spinnerContainer->setContentSize({ sideLength, sideLength });
+    spinnerContainer->setID(id);
+    spinnerContainer->setAnchorPoint({ .5f, .5f });
+
+    auto spinner = CCSprite::create("loadingCircle.png");
+    spinner->setBlendFunc({ GL_ONE, GL_ONE });
+    spinner->runAction(CCRepeatForever::create(CCRotateBy::create(1.f, 360.f)));
+    limitNodeSize(spinner, spinnerContainer->getContentSize(), 1.f, .1f);
+    spinnerContainer->addChildAtPosition(spinner, Anchor::Center);
+
+    return spinnerContainer;
+}
+
 IconButtonSprite* createGeodeButton(CCNode* icon, std::string const& text, std::string const& bg) {
     return IconButtonSprite::create(bg.c_str(), icon, text.c_str(), "bigFont.fnt");
 }
@@ -89,10 +104,17 @@ CircleButtonSprite* createGeodeCircleButton(const char* topFrameName) {
     return CircleButtonSprite::createWithSpriteFrameName(topFrameName, 1.f, CircleBaseColor::DarkPurple);
 }
 
-ButtonSprite* createGeodeTagLabel(std::string const& text, ccColor3B color, ccColor3B bg) {
+ButtonSprite* createGeodeTagLabel(std::string const& text, std::optional<std::pair<ccColor3B, ccColor3B>> const& color) {
     auto label = ButtonSprite::create(text.c_str(), "bigFont.fnt", "white-square.png"_spr, .8f);
-    label->m_label->setColor(color);
-    label->m_BGSprite->setColor(bg);
+    if (color) {
+        label->m_label->setColor(color->first);
+        label->m_BGSprite->setColor(color->second);
+    }
+    else {
+        auto def = geodeTagColor(text);
+        label->m_label->setColor(def.first);
+        label->m_BGSprite->setColor(def.second);
+    }
     return label;
 }
 
diff --git a/loader/src/ui/mods/GeodeStyle.hpp b/loader/src/ui/mods/GeodeStyle.hpp
index 3dc9d1b5..9e8320e4 100644
--- a/loader/src/ui/mods/GeodeStyle.hpp
+++ b/loader/src/ui/mods/GeodeStyle.hpp
@@ -19,7 +19,7 @@ protected:
         
         // Replace the close button with a Geode style one
         auto spr = CircleButtonSprite::createWithSpriteFrameName(
-            "close.png"_spr, 1.f,
+            "close.png"_spr, .85f,
             (altBG ? CircleBaseColor::DarkAqua : CircleBaseColor::DarkPurple)
         );
         Popup<Args...>::m_closeBtn->setNormalImage(spr);
@@ -48,13 +48,15 @@ public:
     static GeodeSquareSprite* createWithSpriteFrameName(const char* top, bool* state = nullptr);
 };
 
+CCNode* createLoadingCircle(float sideLength, const char* id = "loading-spinner");
+
 IconButtonSprite* createGeodeButton(CCNode* icon, std::string const& text, std::string const& bg = "GE_button_05.png"_spr);
 CCNode* createGeodeButton(CCNode* icon, float width, std::string const& text, std::string const& bg = "GE_button_05.png"_spr);
 ButtonSprite* createGeodeButton(std::string const& text, std::string const& bg = "GE_button_05.png"_spr);
 
 CircleButtonSprite* createGeodeCircleButton(const char* topFrameName);
 
-ButtonSprite* createGeodeTagLabel(std::string const& text, ccColor3B color, ccColor3B bg);
+ButtonSprite* createGeodeTagLabel(std::string const& text, std::optional<std::pair<ccColor3B, ccColor3B>> const& color = std::nullopt);
 std::pair<ccColor3B, ccColor3B> geodeTagColor(std::string_view const& text);
 
 class GeodeTabSprite : public CCNode {
diff --git a/loader/src/ui/mods/list/ModItem.cpp b/loader/src/ui/mods/list/ModItem.cpp
index f6ee5b5a..f2fcc16b 100644
--- a/loader/src/ui/mods/list/ModItem.cpp
+++ b/loader/src/ui/mods/list/ModItem.cpp
@@ -68,8 +68,10 @@ bool ModItem::init(ModSource&& source) {
 
     m_restartRequiredLabel = createGeodeTagLabel(
         "Restart Required",
-        to3B(ColorProvider::get()->color("mod-list-restart-required-label"_spr)),
-        to3B(ColorProvider::get()->color("mod-list-restart-required-label-bg"_spr))
+        {{
+            to3B(ColorProvider::get()->color("mod-list-restart-required-label"_spr)),
+            to3B(ColorProvider::get()->color("mod-list-restart-required-label-bg"_spr))
+        }}
     );
     m_restartRequiredLabel->setLayoutOptions(AxisLayoutOptions::create()->setMaxScale(.75f));
     m_infoContainer->addChild(m_restartRequiredLabel);
diff --git a/loader/src/ui/mods/list/ModList.cpp b/loader/src/ui/mods/list/ModList.cpp
index d4dff567..0e9fb38b 100644
--- a/loader/src/ui/mods/list/ModList.cpp
+++ b/loader/src/ui/mods/list/ModList.cpp
@@ -1,6 +1,6 @@
 #include "ModList.hpp"
 #include <Geode/utils/ColorProvider.hpp>
-#include "../popups/TagsPopup.hpp"
+#include "../popups/FiltersPopup.hpp"
 #include "../GeodeStyle.hpp"
 #include "../ModsLayer.hpp"
 
@@ -218,9 +218,7 @@ bool ModList::init(ModListSource* src, CCSize const& size) {
     m_statusDetails->setAlignment(kCCTextAlignmentCenter);
     m_statusContainer->addChild(m_statusDetails);
 
-    m_statusLoadingCircle = CCSprite::create("loadingCircle.png");
-    m_statusLoadingCircle->setBlendFunc({ GL_ONE, GL_ONE });
-    m_statusLoadingCircle->setScale(.6f);
+    m_statusLoadingCircle = createLoadingCircle(50);
     m_statusContainer->addChild(m_statusLoadingCircle);
 
     m_statusLoadingBar = Slider::create(this, nullptr);
@@ -452,10 +450,6 @@ void ModList::showStatus(ModListStatus status, std::string const& message, std::
     m_statusLoadingCircle->setVisible(std::holds_alternative<ModListUnkProgressStatus>(status));
     m_statusLoadingBar->setVisible(std::holds_alternative<ModListProgressStatus>(status));
 
-    // The loading circle action gets stopped for some reason so just reactivate it
-    if (m_statusLoadingCircle->isVisible()) {
-        m_statusLoadingCircle->runAction(CCRepeatForever::create(CCRotateBy::create(1.f, 360.f)));
-    }
     // Update progress bar
     if (auto per = std::get_if<ModListProgressStatus>(&status)) {
         m_statusLoadingBar->setValue(per->percentage / 100.f);
@@ -467,7 +461,7 @@ void ModList::showStatus(ModListStatus status, std::string const& message, std::
 }
 
 void ModList::onFilters(CCObject*) {
-    TagsPopup::create(m_source)->show();
+    FiltersPopup::create(m_source)->show();
 }
 
 void ModList::onClearFilters(CCObject*) {
diff --git a/loader/src/ui/mods/list/ModList.hpp b/loader/src/ui/mods/list/ModList.hpp
index 6384f539..db86e723 100644
--- a/loader/src/ui/mods/list/ModList.hpp
+++ b/loader/src/ui/mods/list/ModList.hpp
@@ -25,7 +25,7 @@ protected:
     CCLabelBMFont* m_statusTitle;
     SimpleTextArea* m_statusDetails;
     CCMenuItemSpriteExtra* m_statusDetailsBtn;
-    CCSprite* m_statusLoadingCircle;
+    CCNode* m_statusLoadingCircle;
     Slider* m_statusLoadingBar;
     ModListSource::PageLoadEventListener m_listener;
     CCMenuItemSpriteExtra* m_pagePrevBtn;
diff --git a/loader/src/ui/mods/popups/FiltersPopup.cpp b/loader/src/ui/mods/popups/FiltersPopup.cpp
new file mode 100644
index 00000000..f80880db
--- /dev/null
+++ b/loader/src/ui/mods/popups/FiltersPopup.cpp
@@ -0,0 +1,126 @@
+#include "FiltersPopup.hpp"
+
+bool FiltersPopup::setup(ModListSource* src) {
+    m_noElasticity = true;
+    m_source = src;
+    m_selectedTags = src->getModTags();
+
+    this->setTitle("Select Filters");
+
+    auto tagsContainer = CCNode::create();
+    tagsContainer->setContentSize(ccp(220, 80));
+    tagsContainer->setAnchorPoint({ .5f, .5f });
+
+    auto tagsBG = CCScale9Sprite::create("square02b_001.png");
+    tagsBG->setColor({ 0, 0, 0 });
+    tagsBG->setOpacity(75);
+    tagsBG->setScale(.3f);
+    tagsBG->setContentSize(tagsContainer->getContentSize() / tagsBG->getScale());
+    tagsContainer->addChildAtPosition(tagsBG, Anchor::Center);
+
+    m_tagsMenu = CCMenu::create();
+    m_tagsMenu->setContentSize(tagsContainer->getContentSize() - ccp(10, 10));
+    m_tagsMenu->addChild(createLoadingCircle(40));
+    m_tagsMenu->setLayout(
+        RowLayout::create()
+            ->setDefaultScaleLimits(.1f, 1.f)
+            ->setGrowCrossAxis(true)
+            ->setCrossAxisOverflow(false)
+            ->setAxisAlignment(AxisAlignment::Center)
+            ->setCrossAxisAlignment(AxisAlignment::Center)
+    );
+    tagsContainer->addChildAtPosition(m_tagsMenu, Anchor::Center);
+
+    auto tagsResetMenu = CCMenu::create();
+    tagsResetMenu->setAnchorPoint({ .5f, 1 });
+    tagsResetMenu->setContentWidth(tagsContainer->getContentWidth());
+
+    auto resetSpr = createGeodeButton("Reset Tags");
+    auto resetBtn = CCMenuItemSpriteExtra::create(
+        resetSpr, this, menu_selector(FiltersPopup::onResetTags)
+    );
+    tagsResetMenu->addChild(resetBtn);
+
+    tagsResetMenu->setLayout(
+        RowLayout::create()
+            ->setDefaultScaleLimits(.1f, .5f)
+            ->setAxisAlignment(AxisAlignment::End)
+            ->setAxisReverse(true)
+    );
+    tagsContainer->addChildAtPosition(tagsResetMenu, Anchor::Bottom, ccp(0, -2));
+
+    m_mainLayer->addChildAtPosition(tagsContainer, Anchor::Center);
+
+    m_tagsListener.bind(this, &FiltersPopup::onLoadTags);
+    m_tagsListener.setFilter(server::ServerResultCache<&server::getTags>::shared().get().listen());
+
+    return true;
+}
+
+void FiltersPopup::onLoadTags(PromiseEvent<std::unordered_set<std::string>, server::ServerError>* event) {
+    if (auto tags = event->getResolve()) {
+        m_tagsMenu->removeAllChildren();
+        for (auto& tag : *tags) {
+            auto offSpr = createGeodeTagLabel(tag);
+            offSpr->m_BGSprite->setOpacity(105);
+            offSpr->m_label->setOpacity(105);
+            auto onSpr = createGeodeTagLabel(tag);
+            auto btn = CCMenuItemToggler::create(
+                offSpr, onSpr, this, menu_selector(FiltersPopup::onSelectTag)
+            );
+            btn->m_notClickable = true;
+            btn->setUserObject("tag", CCString::create(tag));
+            m_tagsMenu->addChild(btn);
+        }
+        m_tagsMenu->updateLayout();
+        this->updateTags();
+    }
+    else if (event->getReject()) {
+        m_tagsMenu->removeAllChildren();
+        auto label = CCLabelBMFont::create("Unable to load tags", "bigFont.fnt");
+        label->setOpacity(105);
+        m_tagsMenu->addChild(label);
+        m_tagsMenu->updateLayout();
+    }
+}
+
+void FiltersPopup::updateTags() {
+    for (auto node : CCArrayExt<CCNode*>(m_tagsMenu->getChildren())) {
+        if (auto toggle = typeinfo_cast<CCMenuItemToggler*>(node)) {
+            auto tag = static_cast<CCString*>(toggle->getUserObject("tag"))->getCString();
+            toggle->toggle(m_selectedTags.contains(tag));
+        }
+    }
+}
+
+void FiltersPopup::onSelectTag(CCObject* sender) {
+    auto toggle = static_cast<CCMenuItemToggler*>(sender);
+    auto tag = static_cast<CCString*>(toggle->getUserObject("tag"))->getCString();
+    if (m_selectedTags.contains(tag)) {
+        m_selectedTags.erase(tag);
+    }
+    else {
+        m_selectedTags.insert(tag);
+    }
+    this->updateTags();
+}
+
+void FiltersPopup::onResetTags(CCObject*) {
+    m_selectedTags.clear();
+    this->updateTags();
+}
+
+void FiltersPopup::onClose(CCObject* sender) {
+    m_source->setModTags(m_selectedTags);
+    Popup::onClose(sender);
+}
+
+FiltersPopup* FiltersPopup::create(ModListSource* src) {
+    auto ret = new FiltersPopup();
+    if (ret && ret->init(310, 250, src)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
diff --git a/loader/src/ui/mods/popups/FiltersPopup.hpp b/loader/src/ui/mods/popups/FiltersPopup.hpp
new file mode 100644
index 00000000..5b693f92
--- /dev/null
+++ b/loader/src/ui/mods/popups/FiltersPopup.hpp
@@ -0,0 +1,27 @@
+#pragma once
+
+#include <Geode/ui/Popup.hpp>
+#include "../sources/ModListSource.hpp"
+#include "../GeodeStyle.hpp"
+#include <server/Server.hpp>
+
+using namespace geode::prelude;
+
+class FiltersPopup : public GeodePopup<ModListSource*> {
+protected:
+    ModListSource* m_source;
+    CCMenu* m_tagsMenu;
+    std::unordered_set<std::string> m_selectedTags;
+    EventListener<PromiseEventFilter<std::unordered_set<std::string>, server::ServerError>> m_tagsListener;
+
+    bool setup(ModListSource* src) override;
+    void updateTags();
+
+    void onLoadTags(PromiseEvent<std::unordered_set<std::string>, server::ServerError>* event);
+    void onResetTags(CCObject*);
+    void onSelectTag(CCObject* sender);
+    void onClose(CCObject* sender) override;
+
+public:
+    static FiltersPopup* create(ModListSource* src);
+};
diff --git a/loader/src/ui/mods/popups/ModPopup.cpp b/loader/src/ui/mods/popups/ModPopup.cpp
index 2af8b78d..224e3cd4 100644
--- a/loader/src/ui/mods/popups/ModPopup.cpp
+++ b/loader/src/ui/mods/popups/ModPopup.cpp
@@ -109,21 +109,7 @@ bool ModPopup::setup(ModSource&& src) {
         valueLabel->setID("value-label");
         labelContainer->addChild(valueLabel);
 
-        // todo: refactor these spinners into a reusable class that's not the ass LoadingCircle is
-        auto spinnerContainer = CCNode::create();
-        spinnerContainer->setContentSize({
-            container->getContentHeight() / labelContainer->getScale(),
-            container->getContentHeight() / labelContainer->getScale()
-        });
-        spinnerContainer->setID("loading-spinner");
-
-        auto spinner = CCSprite::create("loadingCircle.png");
-        spinner->setBlendFunc({ GL_ONE, GL_ONE });
-        spinner->runAction(CCRepeatForever::create(CCRotateBy::create(1.f, 360.f)));
-        limitNodeSize(spinner, spinnerContainer->getContentSize(), 1.f, .1f);
-        spinnerContainer->addChildAtPosition(spinner, Anchor::Center);
-
-        labelContainer->addChild(spinnerContainer);
+        labelContainer->addChild(createLoadingCircle(container->getContentHeight() / labelContainer->getScale()));
 
         this->setStatIcon(container, std::get<0>(stat));
         this->setStatLabel(container, std::get<1>(stat));
@@ -165,18 +151,7 @@ bool ModPopup::setup(ModSource&& src) {
     m_tags->setContentSize(tagsContainer->getContentSize() - ccp(10, 10));
     m_tags->setAnchorPoint({ .5f, .5f });
 
-    // todo: refactor these spinners into a reusable class that's not the ass LoadingCircle is
-    auto tagsSpinnerContainer = CCNode::create();
-    tagsSpinnerContainer->setContentSize({ 50, 50 });
-    tagsSpinnerContainer->setID("loading-spinner");
-
-    auto tagsSpinner = CCSprite::create("loadingCircle.png");
-    tagsSpinner->setBlendFunc({ GL_ONE, GL_ONE });
-    tagsSpinner->runAction(CCRepeatForever::create(CCRotateBy::create(1.f, 360.f)));
-    limitNodeSize(tagsSpinner, tagsSpinnerContainer->getContentSize(), 1.f, .1f);
-    tagsSpinnerContainer->addChildAtPosition(tagsSpinner, Anchor::Center);
-
-    m_tags->addChild(tagsSpinnerContainer);
+    m_tags->addChild(createLoadingCircle(50));
 
     m_tags->setLayout(
         RowLayout::create()
@@ -202,8 +177,10 @@ bool ModPopup::setup(ModSource&& src) {
 
     m_restartRequiredLabel = createGeodeTagLabel(
         "Restart Required",
-        to3B(ColorProvider::get()->color("mod-list-restart-required-label"_spr)),
-        to3B(ColorProvider::get()->color("mod-list-restart-required-label-bg"_spr))
+        {{
+            to3B(ColorProvider::get()->color("mod-list-restart-required-label"_spr)),
+            to3B(ColorProvider::get()->color("mod-list-restart-required-label-bg"_spr))
+        }}
     );
     m_restartRequiredLabel->setLayoutOptions(AxisLayoutOptions::create()->setMaxScale(.75f));
     m_restartRequiredLabel->setScale(.3f);
@@ -578,7 +555,7 @@ void ModPopup::onLoadTags(PromiseEvent<std::unordered_set<std::string>, server::
             auto readable = tag;
             readable[0] = std::toupper(readable[0]);
             auto colors = geodeTagColor(tag);
-            m_tags->addChild(createGeodeTagLabel(readable, colors.first, colors.second));
+            m_tags->addChild(createGeodeTagLabel(readable));
         }
         
         if (data->empty()) {
diff --git a/loader/src/ui/mods/popups/TagsPopup.cpp b/loader/src/ui/mods/popups/TagsPopup.cpp
deleted file mode 100644
index 296e80d9..00000000
--- a/loader/src/ui/mods/popups/TagsPopup.cpp
+++ /dev/null
@@ -1,27 +0,0 @@
-#include "TagsPopup.hpp"
-
-bool TagsPopup::setup(ModListSource* src) {
-    m_noElasticity = true;
-    m_source = src;
-
-    this->setTitle("Select Tags");
-
-    // todo: need a "get available tags" endpoint first...
-
-    return true;
-}
-
-void TagsPopup::onClose(CCObject* sender) {
-    InvalidateCacheEvent(m_source).post();
-    Popup::onClose(sender);
-}
-
-TagsPopup* TagsPopup::create(ModListSource* src) {
-    auto ret = new TagsPopup();
-    if (ret && ret->init(260, 200, src)) {
-        ret->autorelease();
-        return ret;
-    }
-    CC_SAFE_DELETE(ret);
-    return nullptr;
-}
diff --git a/loader/src/ui/mods/popups/TagsPopup.hpp b/loader/src/ui/mods/popups/TagsPopup.hpp
deleted file mode 100644
index 067a4710..00000000
--- a/loader/src/ui/mods/popups/TagsPopup.hpp
+++ /dev/null
@@ -1,19 +0,0 @@
-#pragma once
-
-#include <Geode/ui/Popup.hpp>
-#include "../sources/ModListSource.hpp"
-#include "../GeodeStyle.hpp"
-
-using namespace geode::prelude;
-
-class TagsPopup : public GeodePopup<ModListSource*> {
-protected:
-    ModListSource* m_source;
-
-    bool setup(ModListSource* src) override;
-
-    void onClose(CCObject*) override;
-
-public:
-    static TagsPopup* create(ModListSource* src);
-};
diff --git a/loader/src/ui/mods/sources/ModListSource.cpp b/loader/src/ui/mods/sources/ModListSource.cpp
index cdeb8a3e..b7a24f65 100644
--- a/loader/src/ui/mods/sources/ModListSource.cpp
+++ b/loader/src/ui/mods/sources/ModListSource.cpp
@@ -33,6 +33,15 @@ static void filterModsWithQuery(InstalledModListSource::ProvidedMods& mods, Inst
         if (auto updates = src.hasUpdates(); query.onlyUpdates && !(updates && updates->hasUpdateForInstalledMod())) {
             addToList = false;
         }
+        // If some tags are provided, only return mods that match
+        if (addToList && query.tags.size()) {
+            auto compare = mod->getMetadata().getTags();
+            for (auto& tag : query.tags) {
+                if (!compare.contains(tag)) {
+                    addToList = false;
+                }
+            }
+        }
         // Don't bother with unnecessary fuzzy match calculations if this mod isn't going to be added anyway
         if (addToList && query.query) {
             // By default don't add anything
@@ -221,6 +230,14 @@ void InstalledModListSource::setSearchQuery(std::string const& query) {
     m_query.query = query.size() ? std::optional(query) : std::nullopt;
 }
 
+std::unordered_set<std::string> InstalledModListSource::getModTags() const {
+    return m_query.tags;
+}
+void InstalledModListSource::setModTags(std::unordered_set<std::string> const& tags) {
+    m_query.tags = tags;
+    this->clearCache();
+}
+
 InstalledModsQuery const& InstalledModListSource::getQuery() const {
     return m_query;
 }
@@ -323,6 +340,14 @@ void ServerModListSource::setSearchQuery(std::string const& query) {
     m_query.query = query.size() ? std::optional(query) : std::nullopt;
 }
 
+std::unordered_set<std::string> ServerModListSource::getModTags() const {
+    return m_query.tags;
+}
+void ServerModListSource::setModTags(std::unordered_set<std::string> const& tags) {
+    m_query.tags = tags;
+    this->clearCache();
+}
+
 server::ModsQuery const& ServerModListSource::getQuery() const {
     return m_query;
 }
@@ -355,6 +380,11 @@ ModPackListSource* ModPackListSource::get() {
 
 void ModPackListSource::setSearchQuery(std::string const& query) {}
 
+std::unordered_set<std::string> ModPackListSource::getModTags() const {
+    return {};
+}
+void ModPackListSource::setModTags(std::unordered_set<std::string> const& set) {}
+
 bool ModPackListSource::isInstalledMods() const {
     return false;
 }
diff --git a/loader/src/ui/mods/sources/ModListSource.hpp b/loader/src/ui/mods/sources/ModListSource.hpp
index 70301494..1486e35d 100644
--- a/loader/src/ui/mods/sources/ModListSource.hpp
+++ b/loader/src/ui/mods/sources/ModListSource.hpp
@@ -30,6 +30,7 @@ public:
 struct InstalledModsQuery final {
     std::optional<std::string> query;
     bool onlyUpdates = false;
+    std::unordered_set<std::string> tags = {};
     size_t page = 0;
     size_t pageSize = 10;
 };
@@ -77,6 +78,9 @@ public:
     void clearCache();
     void search(std::string const& query);
 
+    virtual std::unordered_set<std::string> getModTags() const = 0;
+    virtual void setModTags(std::unordered_set<std::string> const& tags) = 0;
+
     // Load page, uses cache if possible unless `update` is true
     PagePromise loadPage(size_t page, bool update = false);
     std::optional<size_t> getPageCount() const;
@@ -112,13 +116,15 @@ protected:
 
     void resetQuery() override;
     ProviderPromise fetchPage(size_t page, size_t pageSize) override;
+    void setSearchQuery(std::string const& query) override;
 
     InstalledModListSource(bool onlyUpdates);
 
 public:
     static InstalledModListSource* get(bool onlyUpdates);
 
-    void setSearchQuery(std::string const& query) override;
+    std::unordered_set<std::string> getModTags() const override;
+    void setModTags(std::unordered_set<std::string> const& tags) override;
 
     InstalledModsQuery const& getQuery() const;
     InvalidateQueryAfter<InstalledModsQuery> getQueryMut();
@@ -141,13 +147,15 @@ protected:
 
     void resetQuery() override;
     ProviderPromise fetchPage(size_t page, size_t pageSize) override;
+    void setSearchQuery(std::string const& query) override;
 
     ServerModListSource(ServerModListType type);
 
 public:
     static ServerModListSource* get(ServerModListType type);
 
-    void setSearchQuery(std::string const& query) override;
+    std::unordered_set<std::string> getModTags() const override;
+    void setModTags(std::unordered_set<std::string> const& tags) override;
 
     server::ModsQuery const& getQuery() const;
     InvalidateQueryAfter<server::ModsQuery> getQueryMut();
@@ -160,13 +168,15 @@ class ModPackListSource : public ModListSource {
 protected:
     void resetQuery() override;
     ProviderPromise fetchPage(size_t page, size_t pageSize) override;
+    void setSearchQuery(std::string const& query) override;
 
     ModPackListSource();
 
 public:
     static ModPackListSource* get();
 
-    void setSearchQuery(std::string const& query) override;
+    std::unordered_set<std::string> getModTags() const override;
+    void setModTags(std::unordered_set<std::string> const& tags) override;
 
     bool isInstalledMods() const override;
     bool wantsRestart() const override;