diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp index 0b9b73f9e..2b99447ec 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 a9ec9e830..d6e26ed47 100644 --- a/src/citra_qt/game_list.cpp +++ b/src/citra_qt/game_list.cpp @@ -4,9 +4,9 @@ #include #include +#include #include #include -#include #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(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"); 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& 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 b141fa3a5..3c06cddc8 100644 --- a/src/citra_qt/game_list.h +++ b/src/citra_qt/game_list.h @@ -5,13 +5,19 @@ #pragma once #include +#include +#include +#include #include #include #include #include #include +#include #include +#include #include +#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& 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 b17ed6968..ea66cc425 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 ec841eaa5..2f398eb7b 100644 --- a/src/citra_qt/main.h +++ b/src/citra_qt/main.h @@ -7,6 +7,7 @@ #include #include +#include #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 47dbb6ef7..f64b878f0 100644 --- a/src/citra_qt/main.ui +++ b/src/citra_qt/main.ui @@ -88,6 +88,7 @@ + @@ -167,6 +168,14 @@ Display Dock Widget Headers + + + true + + + Show Filter Bar + + true diff --git a/src/citra_qt/ui_settings.h b/src/citra_qt/ui_settings.h index 6408ece2b..bc37f81c5 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;