[CardSearch] Add card completion popups to chats and search fields (#7089)

* Add card completion popups to chats and search fields

Completes @mention and [[card]] in chat, and card names in the deck
editor, EDHREC, Archidekt, card art rules, and user card settings
searches. Pops up a styled list with mana pips and a card image
preview, flipping the list order when the popup opens above the text
field.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-08-11 18:50:30 +02:00 committed by GitHub
parent a0e76607b5
commit 83fd65b34b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1526 additions and 290 deletions

View file

@ -2,12 +2,12 @@
#include "../../../../../client/settings/cache_settings.h"
#include "../../../cards/additional_info/mana_symbol_widget.h"
#include "../../../utility/completer_utils.h"
#include "../../tab_supervisor.h"
#include "api_response/archidekt_deck_listing_api_response.h"
#include "display/archidekt_api_response_deck_display_widget.h"
#include "display/archidekt_api_response_deck_listings_display_widget.h"
#include <QCompleter>
#include <QDebug>
#include <QFormLayout>
#include <QGridLayout>
@ -19,7 +19,6 @@
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QPushButton>
#include <QRegularExpression>
#include <QResizeEvent>
#include <QScrollArea>
#include <QScrollBar>
@ -279,41 +278,14 @@ void TabArchidekt::setupFilterWidgets()
auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
auto displayModel = new CardDatabaseDisplayModel(this);
displayModel->setSourceModel(cardDatabaseModel);
auto *searchModel = new CardSearchModel(displayModel, this);
auto *proxyModel = new CardCompleterProxyModel(this);
proxyModel->setSourceModel(searchModel);
proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
proxyModel->setFilterRole(Qt::DisplayRole);
auto *completer = new QCompleter(proxyModel, this);
completer->setCompletionRole(Qt::DisplayRole);
completer->setCompletionMode(QCompleter::PopupCompletion);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setFilterMode(Qt::MatchContains);
completer->setMaxVisibleItems(10);
cardsField->setCompleter(completer);
commandersField->setCompleter(completer);
const CardCompleterSetup cardSetup = createCardCompleter(displayModel, this);
cardsField->setCompleter(cardSetup.completer);
commandersField->setCompleter(cardSetup.completer);
// Keep autocomplete working for both fields
connect(cardsField, &QLineEdit::textChanged, this, [=](const QString &text) {
searchModel->updateSearchResults(text);
QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
completer->complete();
}
});
connect(commandersField, &QLineEdit::textChanged, this, [=](const QString &text) {
searchModel->updateSearchResults(text);
QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
completer->complete();
}
});
connectCardCompleterSearch(cardsField, cardSetup);
connectCardCompleterSearch(commandersField, cardSetup);
// Assemble secondary toolbar
secondaryToolbarLayout->addWidget(bracketLabel);

View file

@ -1,6 +1,7 @@
#include "tab_edhrec_main.h"
#include "../../../../../client/settings/cache_settings.h"
#include "../../../utility/completer_utils.h"
#include "../../tab_supervisor.h"
#include "api_response/average_deck/edhrec_average_deck_api_response.h"
#include "api_response/commander/edhrec_commander_api_response.h"
@ -12,7 +13,6 @@
#include "display/top_commander/edhrec_top_commanders_api_response_display_widget.h"
#include "display/top_tags/edhrec_top_tags_api_response_display_widget.h"
#include <QCompleter>
#include <QDebug>
#include <QHBoxLayout>
#include <QJsonArray>
@ -63,32 +63,12 @@ TabEdhRecMain::TabEdhRecMain(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor
auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
auto displayModel = new CardDatabaseDisplayModel(this);
displayModel->setSourceModel(cardDatabaseModel);
auto *searchModel = new CardSearchModel(displayModel, this);
auto *proxyModel = new CardCompleterProxyModel(this);
proxyModel->setSourceModel(searchModel);
proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
proxyModel->setFilterRole(Qt::DisplayRole);
auto *completer = new QCompleter(proxyModel, this);
completer->setCompletionRole(Qt::DisplayRole);
completer->setCompletionMode(QCompleter::PopupCompletion);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setFilterMode(Qt::MatchContains);
completer->setMaxVisibleItems(10);
searchBar->setCompleter(completer);
const CardCompleterSetup cardSetup = createCardCompleter(displayModel, this);
searchBar->setCompleter(cardSetup.completer);
// Update suggestions dynamically
connect(searchBar, &QLineEdit::textChanged, searchModel, &CardSearchModel::updateSearchResults);
connect(searchBar, &QLineEdit::textChanged, this, [=](const QString &text) {
// Ensure substring matching
QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
completer->complete(); // Force the dropdown to appear
}
});
connectCardCompleterSearch(searchBar, cardSetup);
searchPushButton = new QPushButton(navigationContainer);
connect(searchPushButton, &QPushButton::clicked, this, [=, this]() { doSearch(); });

View file

@ -1,5 +1,6 @@
#include "tab_card_art_rules.h"
#include "../utility/completer_utils.h"
#include "libcockatrice/card/database/card_database_manager.h"
#include <QCompleter>
@ -194,29 +195,14 @@ void TabCardArtRules::initSearchBar()
cardDbModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
cardDbDisplayModel = new CardDatabaseDisplayModel(this);
cardDbDisplayModel->setSourceModel(cardDbModel);
cardSearchModel = new CardSearchModel(cardDbDisplayModel, this);
cardProxyModel = new CardCompleterProxyModel(this);
cardProxyModel->setSourceModel(cardSearchModel);
cardProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
searchCompleter = new QCompleter(cardProxyModel, this);
searchCompleter->setCompletionRole(Qt::DisplayRole);
searchCompleter->setCompletionMode(QCompleter::PopupCompletion);
searchCompleter->setCaseSensitivity(Qt::CaseInsensitive);
searchCompleter->setFilterMode(Qt::MatchContains);
searchCompleter->setMaxVisibleItems(15);
const CardCompleterSetup cardSetup = createCardCompleter(cardDbDisplayModel, this, 15);
cardSearchModel = cardSetup.searchModel;
cardProxyModel = cardSetup.proxyModel;
searchCompleter = cardSetup.completer;
searchEdit->setCompleter(searchCompleter);
connect(searchEdit, &QLineEdit::textEdited, cardSearchModel, &CardSearchModel::updateSearchResults);
connect(searchEdit, &QLineEdit::textEdited, this, [this](const QString &text) {
const QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
cardProxyModel->setFilterRegularExpression(
QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
searchCompleter->complete();
}
});
connectCardCompleterSearch(searchEdit, cardSetup);
connect(searchCompleter, static_cast<void (QCompleter::*)(const QString &)>(&QCompleter::activated), this,
[this](const QString &name) { searchEdit->setText(name); });
connect(searchEdit, &QLineEdit::editingFinished, this,

View file

@ -21,10 +21,15 @@
#include "../interface/widgets/cards/card_info_frame_widget.h"
#include "../interface/widgets/dialogs/dlg_create_game.h"
#include "../interface/widgets/server/user/user_list_manager.h"
#include "../interface/widgets/utility/completer_utils.h"
#include "../interface/widgets/utility/line_edit_completer.h"
#include "../interface/window_main.h"
#include "../main.h"
#include "../utility/visibility_change_listener.h"
#include "card/card_completer_proxy_model.h"
#include "card/card_search_model.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "tab_supervisor.h"
#include <QAction>
@ -35,7 +40,9 @@
#include <QLabel>
#include <QMenu>
#include <QMessageBox>
#include <QRegularExpression>
#include <QStackedWidget>
#include <QStringListModel>
#include <QTimer>
#include <QWidget>
#include <libcockatrice/card/database/card_database.h>
@ -538,7 +545,8 @@ bool TabGame::leaveGame()
void TabGame::actSay()
{
if (completer->popup()->isVisible()) {
if (sayEdit->hasVisibleCompleterPopup()) {
sayEdit->hideCompleterPopups();
return;
}
@ -558,14 +566,14 @@ void TabGame::addPlayerToAutoCompleteList(QString playerName)
{
if (sayEdit && !autocompleteUserList.contains(playerName)) {
autocompleteUserList << playerName;
sayEdit->setCompletionList(autocompleteUserList);
mentionModel->setStringList(autocompleteUserList);
}
}
void TabGame::removePlayerFromAutoCompleteList(QString playerName)
{
if (sayEdit && autocompleteUserList.removeOne(playerName)) {
sayEdit->setCompletionList(autocompleteUserList);
mentionModel->setStringList(autocompleteUserList);
}
}
@ -628,8 +636,8 @@ void TabGame::actRotateViewCCW()
void TabGame::actCompleterChanged()
{
SettingsCache::instance().chat().getChatMentionCompleter() ? completer->setCompletionRole(2)
: completer->setCompletionRole(1);
SettingsCache::instance().chat().getChatMentionCompleter() ? mentionCompleter->setCompletionRole(2)
: mentionCompleter->setCompletionRole(1);
}
void TabGame::notifyPlayerJoin(QString playerName)
@ -1281,12 +1289,25 @@ void TabGame::createMessageDock(bool bReplay)
sayEdit->setMaxLength(MAX_TEXT_LENGTH);
sayLabel->setBuddy(sayEdit);
connect(this, &TabGame::chatMessageSent, game->getGameEventHandler(), &GameEventHandler::handleChatMessageSent);
completer = new QCompleter(autocompleteUserList, sayEdit);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setMaxVisibleItems(5);
completer->setFilterMode(Qt::MatchStartsWith);
mentionModel = new QStringListModel(autocompleteUserList, sayEdit);
mentionCompleter = createMentionCompleter(mentionModel, sayEdit);
sayEdit->addCompleter(mentionCompleter, CompleterTrigger::Mention);
auto *cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, sayEdit);
auto *displayModel = new CardDatabaseDisplayModel(sayEdit);
displayModel->setSourceModel(cardDatabaseModel);
const CardCompleterSetup cardSetup = createCardCompleter(displayModel, sayEdit);
sayEdit->addCompleter(cardSetup.completer, CompleterTrigger::Card);
connect(sayEdit, &LineEditCompleter::cardPartialChanged, this, [this, cardSetup](const QString &text) {
cardSetup.searchModel->updateSearchResults(text);
cardSetup.proxyModel->setFilterRegularExpression(
QRegularExpression(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption));
if (sayEdit->hasFocus()) {
cardSetup.completer->complete();
}
});
sayEdit->setCompleter(completer);
actCompleterChanged();
if (game->getPlayerManager()->isSpectator()) {

View file

@ -19,6 +19,7 @@
#include <QCompleter>
#include <QLoggingCategory>
#include <QMap>
#include <QStringListModel>
class CardMenu;
class ServerInfo_PlayerProperties;
@ -61,7 +62,8 @@ private:
const UserListProxy *userListProxy;
ReplayWidget *replayWidget = nullptr;
QStringList gameTypes;
QCompleter *completer;
QCompleter *mentionCompleter;
QStringListModel *mentionModel;
QStringList autocompleteUserList;
QStackedWidget *mainWidget;

View file

@ -8,6 +8,11 @@
#include "../interface/widgets/server/user/user_list_manager.h"
#include "../interface/widgets/server/user/user_list_widget.h"
#include "../main.h"
#include "../utility/completer_utils.h"
#include "card/card_completer_proxy_model.h"
#include "card/card_search_model.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "tab_account.h"
#include "tab_supervisor.h"
@ -17,11 +22,14 @@
#include <QMenu>
#include <QMessageBox>
#include <QPushButton>
#include <QRegularExpression>
#include <QSplitter>
#include <QStringListModel>
#include <QSystemTrayIcon>
#include <QToolButton>
#include <QVBoxLayout>
#include <QtCore/qdatetime.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/get_pb_extension.h>
#include <libcockatrice/protocol/pb/event_join_room.pb.h>
@ -137,13 +145,27 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
gameSelector->processGameInfo(info.game_list(i));
}
completer = new QCompleter(autocompleteUserList, sayEdit);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setMaxVisibleItems(5);
completer->setFilterMode(Qt::MatchStartsWith);
mentionModel = new QStringListModel(autocompleteUserList, sayEdit);
mentionCompleter = createMentionCompleter(mentionModel, sayEdit);
sayEdit->addCompleter(mentionCompleter, CompleterTrigger::Mention);
auto *cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, sayEdit);
auto *displayModel = new CardDatabaseDisplayModel(sayEdit);
displayModel->setSourceModel(cardDatabaseModel);
const CardCompleterSetup cardSetup = createCardCompleter(displayModel, sayEdit);
sayEdit->addCompleter(cardSetup.completer, CompleterTrigger::Card);
connect(sayEdit, &LineEditCompleter::cardPartialChanged, this, [this, cardSetup](const QString &text) {
cardSetup.searchModel->updateSearchResults(text);
cardSetup.proxyModel->setFilterRegularExpression(
QRegularExpression(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption));
if (sayEdit->hasFocus()) {
cardSetup.completer->complete();
}
});
sayEdit->setCompleter(completer);
actCompleterChanged();
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
&TabRoom::refreshShortcuts);
refreshShortcuts();
@ -213,8 +235,8 @@ void TabRoom::sendMessage()
{
if (sayEdit->text().isEmpty()) {
return;
} else if (completer->popup()->isVisible()) {
completer->popup()->hide();
} else if (sayEdit->hasVisibleCompleterPopup()) {
sayEdit->hideCompleterPopups();
return;
} else {
Command_RoomSay cmd;
@ -248,8 +270,8 @@ void TabRoom::actOpenChatSettings()
void TabRoom::actCompleterChanged()
{
SettingsCache::instance().chat().getChatMentionCompleter() ? completer->setCompletionRole(2)
: completer->setCompletionRole(1);
SettingsCache::instance().chat().getChatMentionCompleter() ? mentionCompleter->setCompletionRole(2)
: mentionCompleter->setCompletionRole(1);
}
void TabRoom::processRoomEvent(const RoomEvent &event)
@ -285,16 +307,18 @@ void TabRoom::processListGamesEvent(const Event_ListGames &event)
void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event)
{
if (!autocompleteUserList.contains("@" + QString::fromStdString(event.user_info().name()))) {
autocompleteUserList << "@" + QString::fromStdString(event.user_info().name());
sayEdit->setCompletionList(autocompleteUserList);
QString mention = "@" + QString::fromStdString(event.user_info().name());
if (!autocompleteUserList.contains(mention)) {
autocompleteUserList << mention;
mentionModel->setStringList(autocompleteUserList);
}
}
void TabRoom::processLeaveRoomEvent(const Event_LeaveRoom &event)
{
autocompleteUserList.removeOne("@" + QString::fromStdString(event.name()));
sayEdit->setCompletionList(autocompleteUserList);
QString mention = "@" + QString::fromStdString(event.name());
autocompleteUserList.removeOne(mention);
mentionModel->setStringList(autocompleteUserList);
}
void TabRoom::processRoomSayEvent(const Event_RoomSay &event)

View file

@ -14,6 +14,7 @@
#include <QFocusEvent>
#include <QGroupBox>
#include <QMap>
#include <QStringListModel>
class UserListProxy;
class UserListManager;
@ -63,6 +64,7 @@ private:
ChatView *chatView;
QLabel *sayLabel;
LineEditCompleter *sayEdit;
QStringListModel *mentionModel;
QGroupBox *chatGroupBox;
QMenu *roomMenu;
@ -72,7 +74,7 @@ private:
[[nodiscard]] QString sanitizeHtml(QString dirty) const;
QStringList autocompleteUserList;
QCompleter *completer;
QCompleter *mentionCompleter;
signals:
void roomClosing(TabRoom *tab);
void openMessageDialog(const QString &userName, bool focus);