From 06d4654966ba9909111b1bab36e88f4f15e9cb6d Mon Sep 17 00:00:00 2001
From: Nico Bosshard <nico@bosshome.ch>
Date: Sun, 30 Apr 2017 04:04:39 +0200
Subject: [PATCH] citra-qt: game list search function (#2673)

* citra-qt: game list search function

* Empty search field during game list refresh

* Code improvements

* Code formatting

* Autofocus search field

* JayFoxRox's recommendations

* lioncash's review
---
 src/citra_qt/configuration/config.cpp |   2 +
 src/citra_qt/game_list.cpp            | 233 ++++++++++++++++++++++++--
 src/citra_qt/game_list.h              |  47 +++++-
 src/citra_qt/main.cpp                 |  23 ++-
 src/citra_qt/main.h                   |   3 +
 src/citra_qt/main.ui                  |   9 +
 src/citra_qt/ui_settings.h            |   1 +
 7 files changed, 299 insertions(+), 19 deletions(-)

diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp
index 0b9b73f9e8..2b99447ecf 100644
--- a/src/citra_qt/configuration/config.cpp
+++ b/src/citra_qt/configuration/config.cpp
@@ -177,6 +177,7 @@ void Config::ReadValues() {
 
     UISettings::values.single_window_mode = qt_config->value("singleWindowMode", true).toBool();
     UISettings::values.display_titlebar = qt_config->value("displayTitleBars", true).toBool();
+    UISettings::values.show_filter_bar = qt_config->value("showFilterBar", true).toBool();
     UISettings::values.show_status_bar = qt_config->value("showStatusBar", true).toBool();
     UISettings::values.confirm_before_closing = qt_config->value("confirmClose", true).toBool();
     UISettings::values.first_start = qt_config->value("firstStart", true).toBool();
@@ -295,6 +296,7 @@ void Config::SaveValues() {
 
     qt_config->setValue("singleWindowMode", UISettings::values.single_window_mode);
     qt_config->setValue("displayTitleBars", UISettings::values.display_titlebar);
+    qt_config->setValue("showFilterBar", UISettings::values.show_filter_bar);
     qt_config->setValue("showStatusBar", UISettings::values.show_status_bar);
     qt_config->setValue("confirmClose", UISettings::values.confirm_before_closing);
     qt_config->setValue("firstStart", UISettings::values.first_start);
diff --git a/src/citra_qt/game_list.cpp b/src/citra_qt/game_list.cpp
index a9ec9e830b..d6e26ed470 100644
--- a/src/citra_qt/game_list.cpp
+++ b/src/citra_qt/game_list.cpp
@@ -4,9 +4,9 @@
 
 #include <QFileInfo>
 #include <QHeaderView>
+#include <QKeyEvent>
 #include <QMenu>
 #include <QThreadPool>
-#include <QVBoxLayout>
 #include "common/common_paths.h"
 #include "common/logging/log.h"
 #include "common/string_util.h"
@@ -15,10 +15,189 @@
 #include "game_list_p.h"
 #include "ui_settings.h"
 
-GameList::GameList(QWidget* parent) : QWidget{parent} {
-    QVBoxLayout* layout = new QVBoxLayout;
+GameList::SearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist) {
+    this->gamelist = gamelist;
+    edit_filter_text_old = "";
+}
 
+// EventFilter in order to process systemkeys while editing the searchfield
+bool GameList::SearchField::KeyReleaseEater::eventFilter(QObject* obj, QEvent* event) {
+    // If it isn't a KeyRelease event then continue with standard event processing
+    if (event->type() != QEvent::KeyRelease)
+        return QObject::eventFilter(obj, event);
+
+    QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event);
+    int rowCount = gamelist->tree_view->model()->rowCount();
+    QString edit_filter_text = gamelist->search_field->edit_filter->text().toLower();
+
+    // If the searchfield's text hasn't changed special function keys get checked
+    // If no function key changes the searchfield's text the filter doesn't need to get reloaded
+    if (edit_filter_text == edit_filter_text_old) {
+        switch (keyEvent->key()) {
+        // Escape: Resets the searchfield
+        case Qt::Key_Escape: {
+            if (edit_filter_text_old.isEmpty()) {
+                return QObject::eventFilter(obj, event);
+            } else {
+                gamelist->search_field->edit_filter->clear();
+                edit_filter_text = "";
+            }
+            break;
+        }
+        // Return and Enter
+        // If the enter key gets pressed first checks how many and which entry is visable
+        // If there is only one result launch this game
+        case Qt::Key_Return:
+        case Qt::Key_Enter: {
+            QStandardItemModel* item_model = new QStandardItemModel(gamelist->tree_view);
+            QModelIndex root_index = item_model->invisibleRootItem()->index();
+            QStandardItem* child_file;
+            QString file_path;
+            int resultCount = 0;
+            for (int i = 0; i < rowCount; ++i) {
+                if (!gamelist->tree_view->isRowHidden(i, root_index)) {
+                    ++resultCount;
+                    child_file = gamelist->item_model->item(i, 0);
+                    file_path = child_file->data(GameListItemPath::FullPathRole).toString();
+                }
+            }
+            if (resultCount == 1) {
+                // To avoid loading error dialog loops while confirming them using enter
+                // Also users usually want to run a diffrent game after closing one
+                gamelist->search_field->edit_filter->setText("");
+                edit_filter_text = "";
+                emit gamelist->GameChosen(file_path);
+            } else {
+                return QObject::eventFilter(obj, event);
+            }
+            break;
+        }
+        default:
+            return QObject::eventFilter(obj, event);
+        }
+    }
+    edit_filter_text_old = edit_filter_text;
+    return QObject::eventFilter(obj, event);
+}
+
+void GameList::SearchField::setFilterResult(int visable, int total) {
+    QString result_of_text = tr("of");
+    QString result_text;
+    if (total == 1) {
+        result_text = tr("result");
+    } else {
+        result_text = tr("results");
+    }
+    label_filter_result->setText(
+        QString("%1 %2 %3 %4").arg(visable).arg(result_of_text).arg(total).arg(result_text));
+}
+
+void GameList::SearchField::clear() {
+    edit_filter->setText("");
+}
+
+void GameList::SearchField::setFocus() {
+    if (edit_filter->isVisible()) {
+        edit_filter->setFocus();
+    }
+}
+
+GameList::SearchField::SearchField(GameList* parent) : QWidget{parent} {
+    KeyReleaseEater* keyReleaseEater = new KeyReleaseEater(parent);
+    layout_filter = new QHBoxLayout;
+    layout_filter->setMargin(8);
+    label_filter = new QLabel;
+    label_filter->setText(tr("Filter:"));
+    edit_filter = new QLineEdit;
+    edit_filter->setText("");
+    edit_filter->setPlaceholderText(tr("Enter pattern to filter"));
+    edit_filter->installEventFilter(keyReleaseEater);
+    edit_filter->setClearButtonEnabled(true);
+    connect(edit_filter, SIGNAL(textChanged(const QString&)), parent,
+            SLOT(onTextChanged(const QString&)));
+    label_filter_result = new QLabel;
+    button_filter_close = new QToolButton(this);
+    button_filter_close->setText("X");
+    button_filter_close->setCursor(Qt::ArrowCursor);
+    button_filter_close->setStyleSheet("QToolButton{ border: none; padding: 0px; color: "
+                                       "#000000; font-weight: bold; background: #F0F0F0; }"
+                                       "QToolButton:hover{ border: none; padding: 0px; color: "
+                                       "#EEEEEE; font-weight: bold; background: #E81123}");
+    connect(button_filter_close, SIGNAL(clicked()), parent, SLOT(onFilterCloseClicked()));
+    layout_filter->setSpacing(10);
+    layout_filter->addWidget(label_filter);
+    layout_filter->addWidget(edit_filter);
+    layout_filter->addWidget(label_filter_result);
+    layout_filter->addWidget(button_filter_close);
+    setLayout(layout_filter);
+}
+
+/**
+* Checks if all words separated by spaces are contained in another string
+* This offers a word order insensitive search function
+*
+* @param String that gets checked if it contains all words of the userinput string
+* @param String containing all words getting checked
+* @return true if the haystack contains all words of userinput
+*/
+bool GameList::containsAllWords(QString haystack, QString userinput) {
+    QStringList userinput_split = userinput.split(" ", QString::SplitBehavior::SkipEmptyParts);
+    return std::all_of(userinput_split.begin(), userinput_split.end(),
+                       [haystack](QString s) { return haystack.contains(s); });
+}
+
+// Event in order to filter the gamelist after editing the searchfield
+void GameList::onTextChanged(const QString& newText) {
+    int rowCount = tree_view->model()->rowCount();
+    QString edit_filter_text = newText.toLower();
+
+    QModelIndex root_index = item_model->invisibleRootItem()->index();
+
+    // If the searchfield is empty every item is visible
+    // Otherwise the filter gets applied
+    if (edit_filter_text.isEmpty()) {
+        for (int i = 0; i < rowCount; ++i) {
+            tree_view->setRowHidden(i, root_index, false);
+        }
+        search_field->setFilterResult(rowCount, rowCount);
+    } else {
+        QStandardItem* child_file;
+        QString file_path, file_name, file_title, file_programmid;
+        int result_count = 0;
+        for (int i = 0; i < rowCount; ++i) {
+            child_file = item_model->item(i, 0);
+            file_path = child_file->data(GameListItemPath::FullPathRole).toString().toLower();
+            file_name = file_path.mid(file_path.lastIndexOf("/") + 1);
+            file_title = child_file->data(GameListItemPath::TitleRole).toString().toLower();
+            file_programmid =
+                child_file->data(GameListItemPath::ProgramIdRole).toString().toLower();
+
+            // Only items which filename in combination with its title contains all words
+            // that are in the searchfiel will be visible in the gamelist
+            // The search is case insensitive because of toLower()
+            // I decided not to use Qt::CaseInsensitive in containsAllWords to prevent
+            // multiple conversions of edit_filter_text for each game in the gamelist
+            if (containsAllWords(file_name.append(" ").append(file_title), edit_filter_text) ||
+                (file_programmid.count() == 16 && edit_filter_text.contains(file_programmid))) {
+                tree_view->setRowHidden(i, root_index, false);
+                ++result_count;
+            } else {
+                tree_view->setRowHidden(i, root_index, true);
+            }
+            search_field->setFilterResult(result_count, rowCount);
+        }
+    }
+}
+
+void GameList::onFilterCloseClicked() {
+    main_window->filterBarSetChecked(false);
+}
+
+GameList::GameList(GMainWindow* parent) : QWidget{parent} {
+    this->main_window = parent;
+    layout = new QVBoxLayout;
     tree_view = new QTreeView;
+    search_field = new SearchField(this);
     item_model = new QStandardItemModel(tree_view);
     tree_view->setModel(item_model);
 
@@ -46,7 +225,9 @@ GameList::GameList(QWidget* parent) : QWidget{parent} {
     qRegisterMetaType<QList<QStandardItem*>>("QList<QStandardItem*>");
 
     layout->setContentsMargins(0, 0, 0, 0);
+    layout->setSpacing(0);
     layout->addWidget(tree_view);
+    layout->addWidget(search_field);
     setLayout(layout);
 }
 
@@ -54,6 +235,18 @@ GameList::~GameList() {
     emit ShouldCancelWorker();
 }
 
+void GameList::setFilterFocus() {
+    search_field->setFocus();
+}
+
+void GameList::setFilterVisible(bool visablility) {
+    search_field->setVisible(visablility);
+}
+
+void GameList::clearFilter() {
+    search_field->clear();
+}
+
 void GameList::AddEntry(const QList<QStandardItem*>& entry_items) {
     item_model->invisibleRootItem()->appendRow(entry_items);
 }
@@ -69,11 +262,16 @@ void GameList::ValidateEntry(const QModelIndex& item) {
     std::string std_file_path(file_path.toStdString());
     if (!FileUtil::Exists(std_file_path) || FileUtil::IsDirectory(std_file_path))
         return;
+    // Users usually want to run a diffrent game after closing one
+    search_field->clear();
     emit GameChosen(file_path);
 }
 
 void GameList::DonePopulating() {
     tree_view->setEnabled(true);
+    int rowCount = tree_view->model()->rowCount();
+    search_field->setFilterResult(rowCount, rowCount);
+    search_field->setFocus();
 }
 
 void GameList::PopupContextMenu(const QPoint& menu_location) {
@@ -151,25 +349,26 @@ static bool HasSupportedFileExtension(const std::string& file_name) {
 void GameList::RefreshGameDirectory() {
     if (!UISettings::values.gamedir.isEmpty() && current_worker != nullptr) {
         LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list.");
+        search_field->clear();
         PopulateAsync(UISettings::values.gamedir, UISettings::values.gamedir_deepscan);
     }
 }
 
 /**
- * Adds the game list folder to the QFileSystemWatcher to check for updates.
- *
- * The file watcher will fire off an update to the game list when a change is detected in the game
- * list folder.
- *
- * Notice: This method is run on the UI thread because QFileSystemWatcher is not thread safe and
- * this function is fast enough to not stall the UI thread. If performance is an issue, it should
- * be moved to another thread and properly locked to prevent concurrency issues.
- *
- * @param dir folder to check for changes in
- * @param recursion 0 if recursion is disabled. Any positive number passed to this will add each
- *        directory recursively to the watcher and will update the file list if any of the folders
- *        change. The number determines how deep the recursion should traverse.
- */
+* Adds the game list folder to the QFileSystemWatcher to check for updates.
+*
+* The file watcher will fire off an update to the game list when a change is detected in the game
+* list folder.
+*
+* Notice: This method is run on the UI thread because QFileSystemWatcher is not thread safe and
+* this function is fast enough to not stall the UI thread. If performance is an issue, it should
+* be moved to another thread and properly locked to prevent concurrency issues.
+*
+* @param dir folder to check for changes in
+* @param recursion 0 if recursion is disabled. Any positive number passed to this will add each
+*        directory recursively to the watcher and will update the file list if any of the folders
+*        change. The number determines how deep the recursion should traverse.
+*/
 void GameList::UpdateWatcherList(const std::string& dir, unsigned int recursion) {
     const auto callback = [this, recursion](unsigned* num_entries_out, const std::string& directory,
                                             const std::string& virtual_name) -> bool {
diff --git a/src/citra_qt/game_list.h b/src/citra_qt/game_list.h
index b141fa3a59..3c06cddc8c 100644
--- a/src/citra_qt/game_list.h
+++ b/src/citra_qt/game_list.h
@@ -5,13 +5,19 @@
 #pragma once
 
 #include <QFileSystemWatcher>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QLineEdit>
 #include <QModelIndex>
 #include <QSettings>
 #include <QStandardItem>
 #include <QStandardItemModel>
 #include <QString>
+#include <QToolButton>
 #include <QTreeView>
+#include <QVBoxLayout>
 #include <QWidget>
+#include "main.h"
 
 class GameListWorker;
 
@@ -26,9 +32,40 @@ public:
         COLUMN_COUNT, // Number of columns
     };
 
-    explicit GameList(QWidget* parent = nullptr);
+    class SearchField : public QWidget {
+    public:
+        void setFilterResult(int visable, int total);
+        void clear();
+        void setFocus();
+        explicit SearchField(GameList* parent = nullptr);
+
+    private:
+        class KeyReleaseEater : public QObject {
+        public:
+            explicit KeyReleaseEater(GameList* gamelist);
+
+        private:
+            GameList* gamelist = nullptr;
+            QString edit_filter_text_old;
+
+        protected:
+            bool eventFilter(QObject* obj, QEvent* event);
+        };
+        QHBoxLayout* layout_filter = nullptr;
+        QTreeView* tree_view = nullptr;
+        QLabel* label_filter = nullptr;
+        QLineEdit* edit_filter = nullptr;
+        QLabel* label_filter_result = nullptr;
+        QToolButton* button_filter_close = nullptr;
+    };
+
+    explicit GameList(GMainWindow* parent = nullptr);
     ~GameList() override;
 
+    void clearFilter();
+    void setFilterFocus();
+    void setFilterVisible(bool visablility);
+
     void PopulateAsync(const QString& dir_path, bool deep_scan);
 
     void SaveInterfaceLayout();
@@ -41,6 +78,10 @@ signals:
     void ShouldCancelWorker();
     void OpenSaveFolderRequested(u64 program_id);
 
+private slots:
+    void onTextChanged(const QString& newText);
+    void onFilterCloseClicked();
+
 private:
     void AddEntry(const QList<QStandardItem*>& entry_items);
     void ValidateEntry(const QModelIndex& item);
@@ -49,7 +90,11 @@ private:
     void PopupContextMenu(const QPoint& menu_location);
     void UpdateWatcherList(const std::string& path, unsigned int recursion);
     void RefreshGameDirectory();
+    bool containsAllWords(QString haystack, QString userinput);
 
+    SearchField* search_field;
+    GMainWindow* main_window = nullptr;
+    QVBoxLayout* layout = nullptr;
     QTreeView* tree_view = nullptr;
     QStandardItemModel* item_model = nullptr;
     GameListWorker* current_worker = nullptr;
diff --git a/src/citra_qt/main.cpp b/src/citra_qt/main.cpp
index b17ed69687..ea66cc4254 100644
--- a/src/citra_qt/main.cpp
+++ b/src/citra_qt/main.cpp
@@ -93,7 +93,7 @@ void GMainWindow::InitializeWidgets() {
     render_window = new GRenderWindow(this, emu_thread.get());
     render_window->hide();
 
-    game_list = new GameList();
+    game_list = new GameList(this);
     ui.horizontalLayout->addWidget(game_list);
 
     // Create status bar
@@ -247,6 +247,9 @@ void GMainWindow::RestoreUIState() {
     ui.action_Display_Dock_Widget_Headers->setChecked(UISettings::values.display_titlebar);
     OnDisplayTitleBars(ui.action_Display_Dock_Widget_Headers->isChecked());
 
+    ui.action_Show_Filter_Bar->setChecked(UISettings::values.show_filter_bar);
+    game_list->setFilterVisible(ui.action_Show_Filter_Bar->isChecked());
+
     ui.action_Show_Status_Bar->setChecked(UISettings::values.show_status_bar);
     statusBar()->setVisible(ui.action_Show_Status_Bar->isChecked());
 }
@@ -283,6 +286,8 @@ void GMainWindow::ConnectMenuEvents() {
             &GMainWindow::ToggleWindowMode);
     connect(ui.action_Display_Dock_Widget_Headers, &QAction::triggered, this,
             &GMainWindow::OnDisplayTitleBars);
+    ui.action_Show_Filter_Bar->setShortcut(tr("CTRL+F"));
+    connect(ui.action_Show_Filter_Bar, &QAction::triggered, this, &GMainWindow::OnToggleFilterBar);
     connect(ui.action_Show_Status_Bar, &QAction::triggered, statusBar(), &QStatusBar::setVisible);
 }
 
@@ -444,6 +449,7 @@ void GMainWindow::ShutdownGame() {
     ui.action_Stop->setEnabled(false);
     render_window->hide();
     game_list->show();
+    game_list->setFilterFocus();
 
     // Disable status bar updates
     status_bar_update_timer.stop();
@@ -617,6 +623,15 @@ void GMainWindow::OnConfigure() {
     }
 }
 
+void GMainWindow::OnToggleFilterBar() {
+    game_list->setFilterVisible(ui.action_Show_Filter_Bar->isChecked());
+    if (ui.action_Show_Filter_Bar->isChecked()) {
+        game_list->setFilterFocus();
+    } else {
+        game_list->clearFilter();
+    }
+}
+
 void GMainWindow::OnSwapScreens() {
     Settings::values.swap_screen = !Settings::values.swap_screen;
     Settings::Apply();
@@ -671,6 +686,7 @@ void GMainWindow::closeEvent(QCloseEvent* event) {
 #endif
     UISettings::values.single_window_mode = ui.action_Single_Window_Mode->isChecked();
     UISettings::values.display_titlebar = ui.action_Display_Dock_Widget_Headers->isChecked();
+    UISettings::values.show_filter_bar = ui.action_Show_Filter_Bar->isChecked();
     UISettings::values.show_status_bar = ui.action_Show_Status_Bar->isChecked();
     UISettings::values.first_start = false;
 
@@ -720,6 +736,11 @@ bool GMainWindow::ConfirmChangeGame() {
     return answer != QMessageBox::No;
 }
 
+void GMainWindow::filterBarSetChecked(bool state) {
+    ui.action_Show_Filter_Bar->setChecked(state);
+    emit(OnToggleFilterBar());
+}
+
 #ifdef main
 #undef main
 #endif
diff --git a/src/citra_qt/main.h b/src/citra_qt/main.h
index ec841eaa54..2f398eb7b6 100644
--- a/src/citra_qt/main.h
+++ b/src/citra_qt/main.h
@@ -7,6 +7,7 @@
 
 #include <memory>
 #include <QMainWindow>
+#include <QTimer>
 #include "ui_main.h"
 
 class CallstackWidget;
@@ -41,6 +42,7 @@ class GMainWindow : public QMainWindow {
     };
 
 public:
+    void filterBarSetChecked(bool state);
     GMainWindow();
     ~GMainWindow();
 
@@ -122,6 +124,7 @@ private slots:
     void OnMenuRecentFile();
     void OnSwapScreens();
     void OnConfigure();
+    void OnToggleFilterBar();
     void OnDisplayTitleBars(bool);
     void ToggleWindowMode();
     void OnCreateGraphicsSurfaceViewer();
diff --git a/src/citra_qt/main.ui b/src/citra_qt/main.ui
index 47dbb6ef74..f64b878f0a 100644
--- a/src/citra_qt/main.ui
+++ b/src/citra_qt/main.ui
@@ -88,6 +88,7 @@
     </widget>
     <addaction name="action_Single_Window_Mode"/>
     <addaction name="action_Display_Dock_Widget_Headers"/>
+    <addaction name="action_Show_Filter_Bar"/>
     <addaction name="action_Show_Status_Bar"/>
     <addaction name="menu_View_Debugging"/>
    </widget>
@@ -167,6 +168,14 @@
     <string>Display Dock Widget Headers</string>
    </property>
   </action>
+  <action name="action_Show_Filter_Bar">
+   <property name="checkable">
+    <bool>true</bool>
+   </property>
+   <property name="text">
+    <string>Show Filter Bar</string>
+   </property>
+  </action>
   <action name="action_Show_Status_Bar">
    <property name="checkable">
     <bool>true</bool>
diff --git a/src/citra_qt/ui_settings.h b/src/citra_qt/ui_settings.h
index 6408ece2bf..bc37f81c5b 100644
--- a/src/citra_qt/ui_settings.h
+++ b/src/citra_qt/ui_settings.h
@@ -27,6 +27,7 @@ struct Values {
 
     bool single_window_mode;
     bool display_titlebar;
+    bool show_filter_bar;
     bool show_status_bar;
 
     bool confirm_before_closing;