Merge branch 'master' into small-fixes/0-2725-chat-scroll-bottom

This commit is contained in:
BruebachL 2026-08-12 13:24:01 +02:00 • committed by GitHub
commit f1888c6d97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
176 changed files with 7230 additions and 1728 deletions

View file

@ -143,7 +143,11 @@ set(cockatrice_SOURCES
src/interface/intents/intent_wait_for_database_load.h
src/interface/layouts/flow_layout.cpp
src/interface/layouts/overlap_layout.cpp
src/interface/widgets/utility/card_completer_delegate.cpp
src/interface/widgets/utility/card_completer_styler.cpp
src/interface/widgets/utility/completer_utils.cpp
src/interface/widgets/utility/line_edit_completer.cpp
src/interface/widgets/utility/reversed_completer_model.cpp
src/interface/pixel_map_generator.cpp
src/interface/theme_config.cpp
src/interface/theme_manager.cpp
@ -257,10 +261,13 @@ set(cockatrice_SOURCES
src/interface/widgets/server/user/user_list_manager.cpp
src/interface/widgets/server/user/user_list_painter.cpp
src/interface/widgets/server/user/user_list_widget.cpp
src/interface/widgets/settings_page/abstract_settings_page.cpp
src/interface/widgets/settings_page/appearance_settings_page.cpp
src/interface/widgets/settings_page/deck_editor_settings_page.cpp
src/interface/widgets/settings_page/general_settings_page.cpp
src/interface/widgets/settings_page/messages_settings_page.cpp
src/interface/widgets/settings_page/settings_search_delegate.cpp
src/interface/widgets/settings_page/settings_search_model.cpp
src/interface/widgets/settings_page/shortcut_settings_page.cpp
src/interface/widgets/settings_page/sound_settings_page.cpp
src/interface/widgets/settings_page/storage_settings_page.cpp
@ -315,6 +322,13 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp
src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp
src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp
src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp
src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp
src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp
src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp
src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp
src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp
src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp
src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.cpp
src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_average_deck_api_response.cpp
src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.cpp
@ -361,6 +375,9 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp
src/interface/key_signals.cpp
src/interface/logger.cpp
src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp
src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp
src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp

View file

@ -14,8 +14,8 @@
#include <QtConcurrent>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <version_string.h>
#define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled"
@ -23,7 +23,7 @@
SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *apParent) : QObject(apParent), cardUpdateProcess(nullptr)
{
isSpoilerDownloadEnabled = SettingsCache::instance().personal().getDownloadSpoilersStatus();
isSpoilerDownloadEnabled = SettingsCache::instance().downloads().getDownloadSpoilersStatus();
if (isSpoilerDownloadEnabled) {
// Start the process of checking if we're in spoiler season
// File exists means we're in spoiler season

View file

@ -11,18 +11,22 @@
#include <QGlobalStatic>
#include <QSettings>
#include <QStandardPaths>
#include <libcockatrice/settings/appearance_settings.h>
#include <libcockatrice/settings/cache_storage_settings.h>
#include <libcockatrice/settings/card_database_settings.h>
#include <libcockatrice/settings/card_override_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/chat_settings.h>
#include <libcockatrice/settings/commander_bracket_settings.h>
#include <libcockatrice/settings/debug_settings.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/game_filters_settings.h>
#include <libcockatrice/settings/game_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/layouts_settings.h>
#include <libcockatrice/settings/message_settings.h>
#include <libcockatrice/settings/network_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/settings/recents_settings.h>
@ -135,8 +139,12 @@ SettingsCache::SettingsCache()
personalSettings = new PersonalSettings(settingsPath, this);
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
interfaceSettings = new InterfaceSettings(settingsPath, this);
deckEditorSettings = new DeckEditorSettings(settingsPath, this);
pathsSettings = new PathsSettings(settingsPath, this);
visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this);
appearanceSettings = new AppearanceSettings(settingsPath, this);
networkSettings = new NetworkSettings(settingsPath, this);
commanderBracketSettings = new CommanderBracketSettings(settingsPath, this);
// Forward ICardDatabasePathProvider signal from PathsSettings
connect(pathsSettings, &PathsSettings::cardDatabasePathChanged, this,
@ -147,7 +155,13 @@ SettingsCache::SettingsCache()
releaseChannels << new StableReleaseChannel();
releaseChannels << new BetaReleaseChannel();
themeName = personalSettings->getThemeName();
themeName = appearanceSettings->getThemeName();
auto definitions = commanderBracketSettings->loadDefinitions();
if (definitions.isEmpty()) {
definitions = CommanderBracketSettings::defaultDefinitions();
}
commanderBracketSettings->reloadDefinitions(definitions);
loadPaths();
}
@ -155,7 +169,7 @@ SettingsCache::SettingsCache()
void SettingsCache::setThemeName(const QString &_themeName)
{
themeName = _themeName;
personalSettings->setThemeName(themeName);
appearanceSettings->setThemeName(themeName);
emit themeChanged();
}
@ -216,15 +230,15 @@ void SettingsCache::loadPaths()
// customPicsPath derived from picsPath
QString picsPath = pathsIni.value("paths/pics").toString();
if (picsPath.endsWith("/")) {
computePath("custompics", picsPath + "CUSTOM/");
computePath("customPics", picsPath + "CUSTOM/");
} else {
computePath("custompics", picsPath + "/CUSTOM/");
computePath("customPics", picsPath + "/CUSTOM/");
}
computePath("customsets", dataPath + "/customsets/");
computeFilePath("carddatabase", dataPath + "/cards.xml");
computeFilePath("tokendatabase", dataPath + "/tokens.xml");
computeFilePath("spoilerdatabase", dataPath + "/spoiler.xml");
computePath("customSets", dataPath + "/customsets/");
computeFilePath("cardDatabase", dataPath + "/cards.xml");
computeFilePath("tokenDatabase", dataPath + "/tokens.xml");
computeFilePath("spoilerDatabase", dataPath + "/spoiler.xml");
}
void SettingsCache::resetPaths()
@ -272,12 +286,12 @@ QString SettingsCache::getTokenDatabasePath() const
// INetworkSettingsProvider - delegate to sub-objects
int SettingsCache::getKeepAlive() const
{
return personalSettings->getKeepAlive();
return networkSettings->getKeepAlive();
}
int SettingsCache::getTimeOut() const
{
return personalSettings->getTimeOut();
return networkSettings->getTimeOut();
}
bool SettingsCache::getNotifyAboutUpdates() const
@ -287,17 +301,17 @@ bool SettingsCache::getNotifyAboutUpdates() const
void SettingsCache::setKnownMissingFeatures(const QString &_knownMissingFeatures)
{
interfaceSettings->setKnownMissingFeatures(_knownMissingFeatures);
networkSettings->setKnownMissingFeatures(_knownMissingFeatures);
}
QString SettingsCache::getKnownMissingFeatures()
{
return interfaceSettings->getKnownMissingFeatures();
return networkSettings->getKnownMissingFeatures();
}
QString SettingsCache::getClientID()
{
return personalSettings->getClientID();
return networkSettings->getClientID();
}
// Release channels
@ -412,7 +426,7 @@ CardsDisplaySettings &SettingsCache::cardsDisplay() const
return *cardsDisplaySettings;
}
InterfaceSettings &SettingsCache::interface() const
InterfaceSettings &SettingsCache::userInterface() const
{
return *interfaceSettings;
}
@ -422,7 +436,22 @@ PathsSettings &SettingsCache::paths() const
return *pathsSettings;
}
DeckEditorSettings &SettingsCache::deckEditor() const
{
return *deckEditorSettings;
}
VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const
{
return *visualDeckStorageSettings;
}
AppearanceSettings &SettingsCache::appearance() const
{
return *appearanceSettings;
}
NetworkSettings &SettingsCache::network() const
{
return *networkSettings;
}

View file

@ -28,7 +28,9 @@ class CardDatabaseSettings;
class CardOverrideSettings;
class CardsDisplaySettings;
class ChatSettings;
class CommanderBracketSettings;
class DebugSettings;
class DeckEditorSettings;
class DownloadSettings;
class GameFiltersSettings;
class GameSettings;
@ -44,6 +46,8 @@ class SoundSettings;
class TabsSettings;
class UpdatesSettings;
class VisualDeckStorageSettings;
class AppearanceSettings;
class NetworkSettings;
class QSettings;
class SettingsCache : public ICardDatabasePathProvider, public INetworkSettingsProvider
@ -75,8 +79,12 @@ private:
PersonalSettings *personalSettings;
CardsDisplaySettings *cardsDisplaySettings;
InterfaceSettings *interfaceSettings;
DeckEditorSettings *deckEditorSettings;
PathsSettings *pathsSettings;
VisualDeckStorageSettings *visualDeckStorageSettings;
AppearanceSettings *appearanceSettings;
NetworkSettings *networkSettings;
CommanderBracketSettings *commanderBracketSettings;
QString themeName;
@ -138,9 +146,16 @@ public:
[[nodiscard]] UpdatesSettings &updates() const;
[[nodiscard]] PersonalSettings &personal() const;
[[nodiscard]] CardsDisplaySettings &cardsDisplay() const;
[[nodiscard]] InterfaceSettings &interface() const;
[[nodiscard]] InterfaceSettings &userInterface() const;
[[nodiscard]] DeckEditorSettings &deckEditor() const;
[[nodiscard]] PathsSettings &paths() const;
[[nodiscard]] VisualDeckStorageSettings &visualDeckStorage() const;
[[nodiscard]] AppearanceSettings &appearance() const;
[[nodiscard]] NetworkSettings &network() const;
[[nodiscard]] CommanderBracketSettings &commanderBrackets() const
{
return *commanderBracketSettings;
}
[[nodiscard]] bool getIsPortableBuild() const
{

View file

@ -5,7 +5,7 @@
#include <QtMath>
CardCounterSettings::CardCounterSettings(const QString &settingsPath, QObject *parent)
: SettingsManager(settingsPath + "global.ini", "cards", "counters", parent)
: SettingsManager(settingsPath + "card_counters.ini", "cards", "counters", parent)
{
}

View file

@ -287,6 +287,9 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event
if (!game->getGameMetaInfo()->proto().share_decklists_on_load()) {
continue;
}
if (!playerInfo.has_deck_list()) {
continue;
}
opponentDecksToDisplay.append(
qMakePair(playerId, qMakePair(playerName, QString::fromStdString(playerInfo.deck_list()))));

View file

@ -69,7 +69,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown)
const CardInfo &info = exactCard.getInfo();
int tableRow = info.getUiAttributes().tableRow;
bool playToStack = SettingsCache::instance().interface().getPlayToStack();
bool playToStack = SettingsCache::instance().userInterface().getPlayToStack();
QString currentZone = card->getZone()->getName();
if (!faceDown && currentZone == ZoneNames::STACK && tableRow == 3) {
cmd.set_target_zone(ZoneNames::GRAVE);
@ -312,7 +312,7 @@ void PlayerActions::actDrawCard()
void PlayerActions::actRequestMulliganDialog()
{
int startSize = SettingsCache::instance().interface().getStartingHandSize();
int startSize = SettingsCache::instance().userInterface().getStartingHandSize();
int handSize = player->getHandZone()->getCards().size();
int deckSize = player->getDeckZone()->getCards().size() + handSize;
@ -328,7 +328,7 @@ void PlayerActions::actMulligan(int number)
}
doMulligan(number);
SettingsCache::instance().interface().setStartingHandSize(number);
SettingsCache::instance().userInterface().setStartingHandSize(number);
}
void PlayerActions::actMulliganSameSize()
@ -932,13 +932,13 @@ void PlayerActions::setLastTokenInfo(CardInfoPtr cardInfo)
return;
}
lastTokenInfo = {.name = cardInfo->getName(),
.color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(),
.pt = cardInfo->getPowTough(),
.annotation = SettingsCache::instance().interface().getAnnotateTokens() ? cardInfo->getText() : "",
.destroy = true,
.providerId =
SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())};
lastTokenInfo = {
.name = cardInfo->getName(),
.color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(),
.pt = cardInfo->getPowTough(),
.annotation = SettingsCache::instance().userInterface().getAnnotateTokens() ? cardInfo->getText() : "",
.destroy = true,
.providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())};
lastTokenTableRow = TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow);
@ -1171,7 +1171,7 @@ void PlayerActions::createCard(const CardItem *sourceCard,
}
cmd.set_pt(cardInfo->getPowTough().toStdString());
if (SettingsCache::instance().interface().getAnnotateTokens()) {
if (SettingsCache::instance().userInterface().getAnnotateTokens()) {
cmd.set_annotation(cardInfo->getText().toStdString());
} else {
cmd.set_annotation("");

View file

@ -58,7 +58,7 @@ bool ZoneViewZoneLogic::prepareAddCard(int x)
// autoclose check is done both here and in removeCard
if (cards.isEmpty() && !doInsert && SettingsCache::instance().interface().getCloseEmptyCardView()) {
if (cards.isEmpty() && !doInsert && SettingsCache::instance().userInterface().getCloseEmptyCardView()) {
emit closeView();
}
@ -145,7 +145,7 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone)
// card gets dragged within the view.
// Another autoclose check is done in prepareAddCard so that the view autocloses if the last card was moved to an
// unrevealed portion of the same zone.
if (cards.isEmpty() && SettingsCache::instance().interface().getCloseEmptyCardView() && toNewZone) {
if (cards.isEmpty() && SettingsCache::instance().userInterface().getCloseEmptyCardView() && toNewZone) {
emit closeView();
return;
}

View file

@ -12,9 +12,9 @@
#include <algorithm>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/appearance_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/debug_settings.h>
#include <libcockatrice/settings/personal_settings.h>
AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id)
: ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0),
@ -107,7 +107,7 @@ QSizeF AbstractCardItem::getTranslatedSize(QPainter *painter) const
void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle)
{
const int MAX_FONT_SIZE = SettingsCache::instance().personal().getMaxFontSize();
const int MAX_FONT_SIZE = SettingsCache::instance().appearance().getMaxFontSize();
const int fontSize = std::max(9, MAX_FONT_SIZE);
QRectF totalBoundingRect = painter->combinedTransform().mapRect(boundingRect());

View file

@ -262,7 +262,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
if (startZone->getName() == ZoneNames::HAND) {
startCard->playCard(false);
CardInfoPtr ci = startCard->getCard().getCardPtr();
bool playToStack = SettingsCache::instance().interface().getPlayToStack();
bool playToStack = SettingsCache::instance().userInterface().getPlayToStack();
if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) ||
(playToStack && ci->getUiAttributes().tableRow != 0 &&
startCard->getZone()->getName() != ZoneNames::STACK))) {

View file

@ -281,7 +281,7 @@ void CardItem::drawArrow(const QColor &arrowColor)
auto *game = owner->getGame();
PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
int phase = 0; // 0 means to not set the phase
if (SettingsCache::instance().interface().getDoNotDeleteArrowsInSubPhases()) {
if (SettingsCache::instance().userInterface().getDoNotDeleteArrowsInSubPhases()) {
int currentPhase = game->getGameState()->getCurrentPhase();
phase = Phases::getLastSubphase(currentPhase) + 1;
}
@ -400,7 +400,7 @@ void CardItem::playCard(bool faceDown)
if (tz) {
emit tz->toggleTapped();
} else {
if (SettingsCache::instance().interface().getClickPlaysAllSelected()) {
if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) {
if (faceDown) {
emit playSelectedFaceDown(this);
} else {
@ -464,7 +464,7 @@ static bool isUnwritableRevealZone(CardZoneLogic *zone)
void CardItem::handleClickedToPlay(bool shiftHeld)
{
if (isUnwritableRevealZone(state->getZone())) {
if (SettingsCache::instance().interface().getClickPlaysAllSelected()) {
if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) {
emit hideSelected(this);
} else {
state->getZone()->removeCard(this);
@ -481,7 +481,7 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
return;
}
if ((event->modifiers() != Qt::AltModifier) && (event->button() == Qt::LeftButton) &&
(!SettingsCache::instance().interface().getDoubleClickToPlay())) {
(!SettingsCache::instance().userInterface().getDoubleClickToPlay())) {
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
}
if (owner != nullptr) {
@ -493,7 +493,7 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
{
if ((event->modifiers() != Qt::AltModifier) && (event->buttons() == Qt::LeftButton) &&
(SettingsCache::instance().interface().getDoubleClickToPlay())) {
(SettingsCache::instance().userInterface().getDoubleClickToPlay())) {
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
}
event->accept();

View file

@ -189,7 +189,7 @@ void DlgCreateToken::tokenSelectionChanged(const QModelIndex &current, const QMo
const QChar cardColor = cardInfo->getColorChar();
colorEdit->setCurrentIndex(colorEdit->findData(cardColor, Qt::UserRole, Qt::MatchFixedString));
ptEdit->setText(cardInfo->getPowTough());
if (SettingsCache::instance().interface().getAnnotateTokens()) {
if (SettingsCache::instance().userInterface().getAnnotateTokens()) {
annotationEdit->setText(cardInfo->getText());
}
} else {

View file

@ -37,7 +37,7 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
{
animationTimer = new QBasicTimer;
addItem(phasesToolbar);
connect(&SettingsCache::instance().interface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this,
&GameScene::rearrange);
rearrange();
@ -336,7 +336,7 @@ QList<PlayerLogic *> GameScene::rotatePlayers(const QList<PlayerLogic *> &active
int GameScene::determineColumnCount(int playerCount)
{
return playerCount < SettingsCache::instance().interface().getMinPlayersForMultiColumnLayout() ? 1 : 2;
return playerCount < SettingsCache::instance().userInterface().getMinPlayersForMultiColumnLayout() ? 1 : 2;
}
/**

View file

@ -47,11 +47,11 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
connect(scene, &GameScene::sigResizeRubberBand, this, &GameView::resizeRubberBand);
connect(scene, &GameScene::sigStopRubberBand, this, &GameView::stopRubberBand);
connect(scene, &QGraphicsScene::selectionChanged, this, [this]() { updateTotalSelectionCount(); });
connect(&SettingsCache::instance().interface(), &InterfaceSettings::tallyTypeChanged, this,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::tallyTypeChanged, this,
[this] { updateTotalSelectionCount(); });
setFocusDisabled(SettingsCache::instance().interface().getKeepGameChatFocus());
connect(&SettingsCache::instance().interface(), &InterfaceSettings::keepGameChatFocusChanged, this,
setFocusDisabled(SettingsCache::instance().userInterface().getKeepGameChatFocus());
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::keepGameChatFocusChanged, this,
&GameView::setFocusDisabled);
aCloseMostRecentZoneView = new QAction(this);
@ -130,7 +130,7 @@ void GameView::resizeRubberBand(const QPointF &cursorPoint, int selectedCount)
QRect rect = QRect(mapFromScene(selectionOrigin), cursor).normalized();
rubberBand->setGeometry(rect);
if (!SettingsCache::instance().interface().getShowDragSelectionCount()) {
if (!SettingsCache::instance().userInterface().getShowDragSelectionCount()) {
dragCountLabel->hide();
return;
}
@ -239,7 +239,7 @@ void GameView::updateTotalSelectionCount(const QSize &viewSize)
int count = scene()->selectedItems().count();
if (!SettingsCache::instance().interface().getShowTotalSelectionCount() || count <= 1) {
if (!SettingsCache::instance().userInterface().getShowTotalSelectionCount() || count <= 1) {
totalCountLabel->hide();
} else {
totalCountLabel->setText(QString::number(count));
@ -251,7 +251,7 @@ void GameView::updateTotalSelectionCount(const QSize &viewSize)
totalCountLabel->show();
}
TallyType tallyType = Tally::intToType(SettingsCache::instance().interface().getTallyType());
TallyType tallyType = Tally::intToType(SettingsCache::instance().userInterface().getTallyType());
GameScene *gameScene = static_cast<GameScene *>(scene());
QList<TallyRow> entries = Tally::compute(gameScene->selectedCards(), tallyType);

View file

@ -23,14 +23,14 @@ TallyMenu::TallyMenu()
QAction *TallyMenu::createTallyAction(TallyType tallyType)
{
TallyType currentType = Tally::intToType(SettingsCache::instance().interface().getTallyType());
TallyType currentType = Tally::intToType(SettingsCache::instance().userInterface().getTallyType());
QAction *action = new QAction(this);
action->setCheckable(true);
action->setChecked(tallyType == currentType);
connect(action, &QAction::triggered, &SettingsCache::instance().interface(),
[tallyType] { SettingsCache::instance().interface().setTallyType(static_cast<int>(tallyType)); });
connect(action, &QAction::triggered, &SettingsCache::instance().userInterface(),
[tallyType] { SettingsCache::instance().userInterface().setTallyType(static_cast<int>(tallyType)); });
actionGroup->addAction(action);

View file

@ -17,9 +17,9 @@
PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
{
connect(&SettingsCache::instance().interface(), &InterfaceSettings::horizontalHandChanged, this,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::horizontalHandChanged, this,
&PlayerGraphicsItem::rearrangeZones);
connect(&SettingsCache::instance().interface(), &InterfaceSettings::handJustificationChanged, this,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::handJustificationChanged, this,
&PlayerGraphicsItem::rearrangeZones);
connect(player, &PlayerLogic::rearrangeCounters, this, &PlayerGraphicsItem::rearrangeCounters);
connect(player, &PlayerLogic::activeChanged, this, &PlayerGraphicsItem::onPlayerActiveChanged);
@ -149,7 +149,7 @@ qreal PlayerGraphicsItem::getMinimumWidth() const
{
qreal result = tableZoneGraphicsItem->getMinimumWidth() + CardDimensions::HEIGHT_F + 15 + counterAreaWidth +
stackZoneGraphicsItem->boundingRect().width();
if (!SettingsCache::instance().interface().getHorizontalHand()) {
if (!SettingsCache::instance().userInterface().getHorizontalHand()) {
result += handZoneGraphicsItem->boundingRect().width();
}
return result;
@ -166,7 +166,7 @@ void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth)
// Extend table (and hand, if horizontal) to accommodate the new player width.
qreal tableWidth = newPlayerWidth - CardDimensions::HEIGHT_F - 15 - counterAreaWidth -
stackZoneGraphicsItem->boundingRect().width();
if (!SettingsCache::instance().interface().getHorizontalHand()) {
if (!SettingsCache::instance().userInterface().getHorizontalHand()) {
tableWidth -= handZoneGraphicsItem->boundingRect().width();
}
@ -234,7 +234,7 @@ void PlayerGraphicsItem::rearrangeCounters()
void PlayerGraphicsItem::rearrangeZones()
{
auto base = QPointF(CardDimensions::HEIGHT_F + counterAreaWidth + 15, 0);
if (SettingsCache::instance().interface().getHorizontalHand()) {
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
if (mirrored) {
if (player->getHandZone()->contentsKnown()) {
handVisible = true;
@ -285,7 +285,7 @@ void PlayerGraphicsItem::updateBoundingRect()
{
prepareGeometryChange();
qreal width = CardDimensions::HEIGHT_F + 15 + counterAreaWidth + stackZoneGraphicsItem->boundingRect().width();
if (SettingsCache::instance().interface().getHorizontalHand()) {
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
qreal handHeight = handVisible ? handZoneGraphicsItem->boundingRect().height() : 0;
bRect = QRectF(0, 0, width + tableZoneGraphicsItem->boundingRect().width(),
tableZoneGraphicsItem->boundingRect().height() + handHeight);

View file

@ -34,7 +34,7 @@ void HandZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
QPoint point = dropPoint + scenePos().toPoint();
int x = -1;
if (SettingsCache::instance().interface().getHorizontalHand()) {
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
for (x = 0; x < getLogic()->getCards().size(); x++) {
if (point.x() < static_cast<CardItem *>(getLogic()->getCards().at(x))->scenePos().x()) {
break;
@ -61,7 +61,7 @@ void HandZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
QRectF HandZone::boundingRect() const
{
if (SettingsCache::instance().interface().getHorizontalHand()) {
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
return QRectF(0, 0, width, CardDimensions::HEIGHT_F + 10);
} else {
return QRectF(0, 0, CardDimensions::WIDTH_F * 1.5, zoneHeight);
@ -78,8 +78,8 @@ void HandZone::reorganizeCards()
{
if (!getLogic()->getCards().isEmpty()) {
const int cardCount = getLogic()->getCards().size();
if (SettingsCache::instance().interface().getHorizontalHand()) {
bool leftJustified = SettingsCache::instance().interface().getLeftJustified();
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
bool leftJustified = SettingsCache::instance().userInterface().getLeftJustified();
qreal cardWidth = getLogic()->getCards().at(0)->boundingRect().width();
const int xPadding = leftJustified ? cardWidth * 1.4 : 5;
qreal totalWidth =
@ -127,7 +127,7 @@ void HandZone::sortHand(const QList<CardList::SortOption> &options)
void HandZone::setWidth(qreal _width)
{
if (SettingsCache::instance().interface().getHorizontalHand()) {
if (SettingsCache::instance().userInterface().getHorizontalHand()) {
prepareGeometryChange();
width = _width;
reorganizeCards();

View file

@ -29,7 +29,7 @@ TableZone::TableZone(TableZoneLogic *_logic, bool _mirrored, QGraphicsItem *pare
connect(_logic, &TableZoneLogic::contentSizeChanged, this, &TableZone::resizeToContents);
connect(_logic, &TableZoneLogic::toggleTapped, this, &TableZone::toggleTapped);
connect(themeManager, &ThemeManager::themeChanged, this, &TableZone::updateBg);
connect(&SettingsCache::instance().interface(), &InterfaceSettings::invertVerticalCoordinateChanged, this,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::invertVerticalCoordinateChanged, this,
&TableZone::reorganizeCards);
updateBg();
@ -60,8 +60,8 @@ void TableZone::setMirrored(bool isMirrored)
bool TableZone::isInverted() const
{
return ((mirrored && !SettingsCache::instance().interface().getInvertVerticalCoordinate()) ||
(!mirrored && SettingsCache::instance().interface().getInvertVerticalCoordinate()));
return ((mirrored && !SettingsCache::instance().userInterface().getInvertVerticalCoordinate()) ||
(!mirrored && SettingsCache::instance().userInterface().getInvertVerticalCoordinate()));
}
void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)

View file

@ -66,7 +66,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); });
if (SettingsCache::instance().interface().getFocusCardViewSearchBar()) {
if (SettingsCache::instance().userInterface().getFocusCardViewSearchBar()) {
this->setActive(true);
searchEdit.setFocus();
}
@ -77,9 +77,9 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
vbox->addItem(searchEditProxy);
// hide search bar if chat autofocus setting is enabled, since typing into it will no longer work anyway
searchEditProxy->setVisible(!SettingsCache::instance().interface().getKeepGameChatFocus());
connect(&SettingsCache::instance().interface(), &InterfaceSettings::keepGameChatFocusChanged, searchEditProxy,
[searchEditProxy](bool keepFocus) { searchEditProxy->setVisible(!keepFocus); });
searchEditProxy->setVisible(!SettingsCache::instance().userInterface().getKeepGameChatFocus());
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::keepGameChatFocusChanged,
searchEditProxy, [searchEditProxy](bool keepFocus) { searchEditProxy->setVisible(!keepFocus); });
// top row
QGraphicsLinearLayout *hTopRow = new QGraphicsLinearLayout(Qt::Horizontal);
@ -159,9 +159,9 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
connect(&sortBySelector, static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), this,
&ZoneViewWidget::processSortBy);
connect(&pileViewCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &ZoneViewWidget::processSetPileView);
groupBySelector.setCurrentIndex(SettingsCache::instance().interface().getZoneViewGroupByIndex());
sortBySelector.setCurrentIndex(SettingsCache::instance().interface().getZoneViewSortByIndex());
pileViewCheckBox.setChecked(SettingsCache::instance().interface().getZoneViewPileView());
groupBySelector.setCurrentIndex(SettingsCache::instance().userInterface().getZoneViewGroupByIndex());
sortBySelector.setCurrentIndex(SettingsCache::instance().userInterface().getZoneViewSortByIndex());
pileViewCheckBox.setChecked(SettingsCache::instance().userInterface().getZoneViewPileView());
if (CardList::NoSort == static_cast<CardList::SortOption>(groupBySelector.currentData().toInt())) {
pileViewCheckBox.setEnabled(false);
@ -191,7 +191,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
void ZoneViewWidget::processGroupBy(int index)
{
auto option = static_cast<CardList::SortOption>(groupBySelector.itemData(index).toInt());
SettingsCache::instance().interface().setZoneViewGroupByIndex(index);
SettingsCache::instance().userInterface().setZoneViewGroupByIndex(index);
zone->setGroupBy(option);
// disable pile view checkbox if we're not grouping by anything
@ -215,13 +215,13 @@ void ZoneViewWidget::processSortBy(int index)
return;
}
SettingsCache::instance().interface().setZoneViewSortByIndex(index);
SettingsCache::instance().userInterface().setZoneViewSortByIndex(index);
zone->setSortBy(option);
}
void ZoneViewWidget::processSetPileView(QT_STATE_CHANGED_T value)
{
SettingsCache::instance().interface().setZoneViewPileView(value);
SettingsCache::instance().userInterface().setZoneViewPileView(value);
zone->setPileView(value);
}
@ -478,7 +478,7 @@ static qreal rowsToHeight(int rows)
**/
static qreal calcMaxInitialHeight()
{
return rowsToHeight(SettingsCache::instance().interface().getCardViewInitialRowsMax());
return rowsToHeight(SettingsCache::instance().userInterface().getCardViewInitialRowsMax());
}
/**
@ -560,7 +560,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const
void ZoneViewWidget::expandWindow()
{
qreal maxInitialHeight = calcMaxInitialHeight();
qreal maxExpandedHeight = rowsToHeight(SettingsCache::instance().interface().getCardViewExpandedRowsMax());
qreal maxExpandedHeight = rowsToHeight(SettingsCache::instance().userInterface().getCardViewExpandedRowsMax());
qreal height = rect().height() - extraHeight - 10;
qreal maxHeight = maximumHeight() - extraHeight - 10;

View file

@ -19,19 +19,22 @@
#include <QThread>
#include <algorithm>
#include <libcockatrice/settings/cache_storage_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <utility>
// never cache more than 300 cards at once for a single deck
#define CACHED_CARD_PER_DECK_MAX 300
// wait at least this long before retrying a card whose picture failed to load
static constexpr int RETRY_FAILED_CARDS_SECS = 300;
CardPictureLoader::CardPictureLoader() : QObject(nullptr)
{
worker = new CardPictureLoaderWorker;
connect(&SettingsCache::instance().paths(), &PathsSettings::picsPathChanged, this,
&CardPictureLoader::picsPathChanged);
connect(&SettingsCache::instance().personal(), &PersonalSettings::picDownloadChanged, this,
connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this,
&CardPictureLoader::picDownloadChanged);
qRegisterMetaType<ExactCard>();
@ -135,7 +138,14 @@ void CardPictureLoader::getPixmap(QPixmap &pixmap, const ExactCard &card, QSize
QPixmap bigPixmap;
if (QPixmapCache::find(key, &bigPixmap)) {
if (bigPixmap.isNull()) {
qCDebug(CardPictureLoaderLog) << "Cached pixmap for key" << key << "is NULL!";
getCardBackLoadingFailedPixmap(pixmap, size);
QDateTime failedAtTime = getInstance().failedAt.value(key);
if (!failedAtTime.isValid() ||
failedAtTime.addSecs(RETRY_FAILED_CARDS_SECS) < QDateTime::currentDateTime()) {
getInstance().failedAt.remove(key);
QPixmapCache::remove(key);
getInstance().worker->enqueueImageLoad(card);
}
return;
}
@ -159,8 +169,10 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
QPixmap finalPixmap;
if (image.isNull()) {
getInstance().failedAt.insert(card.getPixmapCacheKey(), QDateTime::currentDateTime());
qCDebug(CardPictureLoaderLog) << "Caching NULL pixmap for" << card.getName();
} else {
getInstance().failedAt.remove(card.getPixmapCacheKey());
if (card.getInfo().getUiAttributes().upsideDownArt) {
#if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0))
QImage mirrorImage = image.flipped(Qt::Horizontal | Qt::Vertical);
@ -184,8 +196,10 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
// imageLoaded should only be reached if the exactCard isn't already in cache.
// (plus there's a deduplication mechanism in CardPictureLoaderWorker)
// It should be safe to connect the CardInfo here without worrying about redundant connections.
connect(card.getCardPtr().data(), &QObject::destroyed, this,
[cacheKey = card.getPixmapCacheKey()] { QPixmapCache::remove(cacheKey); });
connect(card.getCardPtr().data(), &QObject::destroyed, this, [cacheKey = card.getPixmapCacheKey()] {
QPixmapCache::remove(cacheKey);
getInstance().failedAt.remove(cacheKey);
});
card.emitPixmapUpdated();
}

View file

@ -4,6 +4,8 @@
#include "card_picture_loader_status_bar.h"
#include "card_picture_loader_worker.h"
#include <QDateTime>
#include <QHash>
#include <QLoggingCategory>
inline Q_LOGGING_CATEGORY(CardPictureLoaderLog, "card_picture_loader");
@ -56,6 +58,7 @@ private:
CardPictureLoaderWorker *worker; ///< Worker thread for async image loading
CardPictureLoaderStatusBar *statusBar; ///< Status bar widget showing load progress
QHash<QString, QDateTime> failedAt; ///< Timestamp of the last failed load attempt per pixmap cache key
public:
/**

View file

@ -11,15 +11,17 @@
#include <QNetworkReply>
#include <QThread>
#include <libcockatrice/settings/cache_storage_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <utility>
#include <version_string.h>
static constexpr int MAX_REQUESTS_PER_SEC = 10;
static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance
static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering
CardPictureLoaderWorker::CardPictureLoaderWorker()
: QObject(nullptr), picDownload(SettingsCache::instance().personal().getPicDownload()),
: QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()),
requestQuota(MAX_REQUESTS_PER_SEC)
{
networkManager = new QNetworkAccessManager(this);
@ -124,6 +126,19 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
void CardPictureLoaderWorker::resetRequestQuota()
{
requestQuota = MAX_REQUESTS_PER_SEC;
QDateTime now = QDateTime::currentDateTime();
for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) {
if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) {
it.value() = qMin(MAX_REQUESTS_PER_SEC, it.value() + 1);
}
}
for (const auto &request : requestLoadQueue) {
const QString host = request.first.host();
hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC));
}
processQueuedRequests();
}
@ -136,14 +151,26 @@ void CardPictureLoaderWorker::processQueuedRequests()
bool CardPictureLoaderWorker::processSingleRequest()
{
if (!requestLoadQueue.isEmpty()) {
auto request = requestLoadQueue.takeFirst();
makeRequest(request.first, request.second);
return true;
for (int i = 0; i < requestLoadQueue.size(); ++i) {
const auto &request = requestLoadQueue.at(i);
QString host = request.first.host();
int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC);
if (allowance > 0) {
hostQuotaRemaining.insert(host, allowance - 1);
makeRequest(request.first, request.second);
requestLoadQueue.removeAt(i);
return true;
}
}
return false;
}
void CardPictureLoaderWorker::onHostRateLimited(const QString &host)
{
hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2));
hostLast429.insert(host, QDateTime::currentDateTime());
}
void CardPictureLoaderWorker::enqueueImageLoad(const ExactCard &card)
{
// Send call through a connection to ensure the handling is run on the pictureLoader thread

View file

@ -5,6 +5,8 @@
#include "card_picture_loader_worker_work.h"
#include "card_picture_to_load.h"
#include <QDateTime>
#include <QHash>
#include <QLoggingCategory>
#include <QMutex>
#include <QNetworkAccessManager>
@ -66,6 +68,12 @@ public:
*/
void queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker);
/**
* @brief Handles a server returning HTTP 429 by reducing that host's request quota.
* @param host The host that returned 429
*/
void onHostRateLimited(const QString &host);
/** @brief Clears the network cache and redirect cache. */
void clearNetworkCache();
@ -110,8 +118,11 @@ private:
bool picDownload; ///< Whether downloading images from network is enabled
QQueue<QPair<QUrl, CardPictureLoaderWorkerWork *>> requestLoadQueue; ///< Queue of pending network requests
int requestQuota; ///< Remaining requests allowed per second
QTimer requestTimer; ///< Timer to reset the request quota
int requestQuota; ///< Remaining requests allowed per second
QTimer requestTimer; ///< Timer to reset the request quota
QHash<QString, int> hostRequestQuota; ///< Sustained per-host request allowance
QHash<QString, int> hostQuotaRemaining; ///< Per-host allowance left in the current second
QHash<QString, QDateTime> hostLast429; ///< When each host was last rate limited
CardPictureLoaderLocal *localLoader; ///< Loader for local images
QSet<QString> currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded

View file

@ -8,9 +8,13 @@
#include <QLoggingCategory>
#include <QMovie>
#include <QNetworkReply>
#include <QRandomGenerator>
#include <QThread>
#include <QThreadPool>
#include <libcockatrice/settings/personal_settings.h>
#include <QTimer>
ServerRateLimiter CardPictureLoaderWorkerWork::s_rateLimiter;
#include <libcockatrice/settings/download_settings.h>
// Card back returned by gatherer when card is not found
static const QStringList MD5_BLACKLIST = {
@ -20,7 +24,7 @@ static const QStringList MD5_BLACKLIST = {
CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad)
: QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)),
picDownload(SettingsCache::instance().personal().getPicDownload())
picDownload(SettingsCache::instance().downloads().getPicDownload())
{
// Hook up signals to the orchestrator
connect(this, &CardPictureLoaderWorkerWork::requestImageDownload, worker, &CardPictureLoaderWorker::queueRequest);
@ -30,19 +34,48 @@ CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoader
connect(this, &CardPictureLoaderWorkerWork::imageLoaded, worker, &CardPictureLoaderWorker::handleImageLoaded);
connect(this, &CardPictureLoaderWorkerWork::requestSucceeded, worker,
&CardPictureLoaderWorker::imageRequestSucceeded);
connect(this, &CardPictureLoaderWorkerWork::rateLimited, worker, &CardPictureLoaderWorker::onHostRateLimited);
// Hook up signals to settings
connect(&SettingsCache::instance().personal(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged()));
connect(&SettingsCache::instance().downloads(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged()));
startNextPicDownload();
}
void CardPictureLoaderWorkerWork::startNextPicDownload()
{
QDateTime now = QDateTime::currentDateTime();
while (!cardToDownload.getCurrentUrl().isEmpty() &&
s_rateLimiter.isRateLimited(QUrl(cardToDownload.getCurrentUrl()).host(), now)) {
QString host = QUrl(cardToDownload.getCurrentUrl()).host();
if (s_rateLimiter.rounds(host) == 1) {
// First 429 round for this server: wait out the backoff and give it
// one more chance instead of immediately falling through to a worse
// source. A second 429 makes us fall through instead.
qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
<< " set: " << cardToDownload.getSetName() << "]: Waiting out backoff for " << host << " to retry "
<< cardToDownload.getCurrentUrl();
scheduleDeferredRetry();
return;
}
// The server has already 429'd us at least twice, so further retries are
// unlikely to succeed: move on to the other configured sources.
qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
<< " set: " << cardToDownload.getSetName() << "]: Skipping rate-limited URL "
<< cardToDownload.getCurrentUrl() << " (server " << host << " still rate limiting)";
if (!cardToDownload.nextUrl() && !cardToDownload.nextSet()) {
scheduleDeferredRetry();
return;
}
}
QString picUrl = cardToDownload.getCurrentUrl();
if (picUrl.isEmpty()) {
picDownloadFailed();
scheduleDeferredRetry();
} else {
QUrl url(picUrl);
qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
@ -108,7 +141,41 @@ static bool imageIsBlackListed(const QByteArray &picData)
void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply)
{
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 429) {
qCWarning(CardPictureLoaderWorkerWorkLog) << "Too many requests.";
QString host = reply->url().host();
QDateTime now = QDateTime::currentDateTime();
qint64 retryAfterMs = 0;
const QByteArray retryAfterHeader = reply->rawHeader("Retry-After");
if (!retryAfterHeader.isEmpty()) {
bool ok = false;
int seconds = retryAfterHeader.toInt(&ok);
if (ok && seconds > 0) {
retryAfterMs = static_cast<qint64>(seconds) * 1000;
} else {
QDateTime retryAfterDate =
QDateTime::fromString(QString::fromLatin1(retryAfterHeader), Qt::RFC2822Date);
if (retryAfterDate.isValid()) {
retryAfterMs = qMax<qint64>(0, now.msecsTo(retryAfterDate));
}
}
}
QDateTime backoffUntil = s_rateLimiter.on429(host, now, retryAfterMs);
emit rateLimited(host);
if (s_rateLimiter.rounds(host) == 1) {
qCWarning(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getName()
<< " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host
<< ", backing off until " << backoffUntil.toString(Qt::ISODate) << ", retrying the same url";
scheduleDeferredRetry();
} else {
qCWarning(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getName()
<< " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host
<< ", retry already attempted, falling through to other sources";
picDownloadFailed();
}
} else {
bool isFromCache = reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool();
@ -149,6 +216,9 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply)
return;
}
// A non-redirect successful response means the server is not rate limiting us anymore.
s_rateLimiter.onSuccess(reply->url().host());
// peek is used to keep the data in the buffer for use by QImageReader
const QByteArray &picData = reply->peek(reply->size());
@ -203,6 +273,42 @@ QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply)
return imgReader.read();
}
void CardPictureLoaderWorkerWork::scheduleDeferredRetry()
{
QDateTime now = QDateTime::currentDateTime();
// Prefer waiting on the current URL's server so we retry the same source.
QString currentHost = QUrl(cardToDownload.getCurrentUrl()).host();
QDateTime backoffUntil = s_rateLimiter.deadline(currentHost);
if (!s_rateLimiter.isRateLimited(currentHost, now)) {
backoffUntil = s_rateLimiter.earliestDeadline(now);
}
if (!backoffUntil.isValid()) {
qCWarning(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
<< " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, no servers in backoff: BAILING OUT";
concludeImageLoad(QImage());
return;
}
qint64 waitMs = qMax<qint64>(0, now.msecsTo(backoffUntil));
// Add some jitter to desynchronize concurrent retries and avoid a thundering herd.
waitMs += QRandomGenerator::global()->bounded(5000);
qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
<< " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, scheduling deferred retry in " << waitMs
<< "ms";
QTimer::singleShot(waitMs, this, [this] {
s_rateLimiter.clearExpired(QDateTime::currentDateTime());
cardToDownload.resetIndices();
startNextPicDownload();
});
}
void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image)
{
emit imageLoaded(cardToDownload.getCard(), image);
@ -211,5 +317,5 @@ void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image)
void CardPictureLoaderWorkerWork::picDownloadChanged()
{
picDownload = SettingsCache::instance().personal().getPicDownload();
picDownload = SettingsCache::instance().downloads().getPicDownload();
}

View file

@ -4,12 +4,15 @@
#include "card_picture_loader_worker.h"
#include "card_picture_to_load.h"
#include <QDateTime>
#include <QLoggingCategory>
#include <QMutex>
#include <QNetworkAccessManager>
#include <QObject>
#include <QRandomGenerator>
#include <QThread>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/utility/server_rate_limiter.h>
inline Q_LOGGING_CATEGORY(CardPictureLoaderWorkerWorkLog, "card_picture_loader.worker");
@ -50,6 +53,8 @@ public slots:
private:
bool picDownload; ///< Whether network downloading is enabled
static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state
/** @brief Starts downloading the next URL for this card. */
void startNextPicDownload();
@ -77,6 +82,16 @@ private:
*/
void concludeImageLoad(const QImage &image);
/**
* @brief Schedules a deferred retry after the relevant server backoff expires.
*
* Waits on the current URL's server when it is the reason we are blocked,
* otherwise on the earliest active backoff. If no servers are in backoff,
* concludes with failure. Otherwise resets the CardPictureToLoad indices and
* retries after the backoff period.
*/
void scheduleDeferredRetry();
private slots:
/** @brief Updates the picDownload setting when it changes. */
void picDownloadChanged();
@ -100,6 +115,9 @@ signals:
/** @brief Emitted when a URL has been redirected. */
void urlRedirected(const QUrl &originalUrl, const QUrl &redirectUrl);
/** @brief Emitted when a server returned HTTP 429. */
void rateLimited(const QString &host);
/** @brief Emitted when a cached URL is invalid and must be removed. */
void cachedUrlInvalidated(const QUrl &url);
};

View file

@ -17,8 +17,9 @@ CardPictureToLoad::CardPictureToLoad(const ExactCard &_card)
{
if (card) {
sortedSets = extractSetsSorted(card);
// The first time called, nextSet will also populate the Urls for the first set.
nextSet();
currentSetIndex = 0;
currentSet = sortedSets.first();
populateSetUrls();
}
}
@ -101,15 +102,19 @@ void CardPictureToLoad::populateSetUrls()
}
}
/* Call nextUrl to make sure currentUrl is up-to-date
but we don't need the result here. */
(void)nextUrl();
currentUrlIndex = 0;
if (!currentSetUrls.isEmpty()) {
currentUrl = currentSetUrls.first();
} else {
currentUrl = QString();
}
}
bool CardPictureToLoad::nextSet()
{
if (!sortedSets.isEmpty()) {
currentSet = sortedSets.takeFirst();
currentSetIndex++;
if (currentSetIndex < sortedSets.size()) {
currentSet = sortedSets.at(currentSetIndex);
populateSetUrls();
return true;
}
@ -119,8 +124,9 @@ bool CardPictureToLoad::nextSet()
bool CardPictureToLoad::nextUrl()
{
if (!currentSetUrls.isEmpty()) {
currentUrl = currentSetUrls.takeFirst();
currentUrlIndex++;
if (currentUrlIndex < currentSetUrls.size()) {
currentUrl = currentSetUrls.at(currentUrlIndex);
return true;
}
currentUrl = QString();
@ -136,6 +142,28 @@ QString CardPictureToLoad::getSetName() const
}
}
QString CardPictureToLoad::peekNextUrl() const
{
int nextIndex = currentUrlIndex + 1;
if (nextIndex < currentSetUrls.size()) {
return currentSetUrls.at(nextIndex);
}
return QString();
}
void CardPictureToLoad::resetIndices()
{
currentSetIndex = 0;
if (!sortedSets.isEmpty()) {
currentSet = sortedSets.first();
populateSetUrls();
} else {
currentSet = {};
currentSetUrls.clear();
currentUrl = QString();
}
}
static int parse(const QString &urlTemplate,
const QString &propType,
const QString &cardName,

View file

@ -25,6 +25,8 @@ private:
QList<QString> currentSetUrls; ///< URLs for the current set being attempted
QString currentUrl; ///< Currently active URL to download
CardSetPtr currentSet; ///< Currently active set
int currentSetIndex = 0; ///< Current position in sortedSets
int currentUrlIndex = 0; ///< Current position in currentSetUrls
public:
/**
@ -56,6 +58,9 @@ public:
/** @return The short name of the current set, or empty string if no set. */
[[nodiscard]] QString getSetName() const;
/** @return The next URL in the current set's list without advancing, or empty if at end. */
[[nodiscard]] QString peekNextUrl() const;
/**
* @brief Transforms a URL template into a concrete URL for this card/set.
* @param urlTemplate The URL template to transform
@ -88,6 +93,14 @@ public:
*/
void populateSetUrls();
/**
* @brief Resets iteration indices to the beginning.
*
* Restarts URL/set iteration from the first set and first URL.
* Used for deferred retry after server backoff expires.
*/
void resetIndices();
/**
* @brief Extract all sets from the card and sort them by priority.
* @param card The card to extract sets from

View file

@ -17,7 +17,7 @@ class ManaCostWidget : public QWidget
public:
explicit ManaCostWidget(QWidget *parent, CardInfoPtr card);
QStringList parseManaCost(const QString &manaString);
static QStringList parseManaCost(const QString &manaString);
public slots:
void resizeEvent(QResizeEvent *event) override;

View file

@ -52,12 +52,29 @@ void CardInfoPictureEnlargedWidget::loadPixmap(const QSize &size)
* @param size The desired size for the pixmap.
*
* Sets the widget's pixmap to the card image and resizes the widget to match the specified size. Triggers a repaint.
*
* When the image is not yet cached, the pixmap is cleared (instead of showing a stale previous card) and the widget
* refreshes automatically once the card image finishes loading.
*/
void CardInfoPictureEnlargedWidget::setCardPixmap(const ExactCard &_card, const QSize size)
{
if (card.getCardPtr()) {
disconnect(card.getCardPtr().data(), nullptr, this, nullptr);
}
card = _card;
// Clear any previous card's art so we never paint a stale pixmap while the new image loads
enlargedPixmap = QPixmap();
loadPixmap(size);
if (card.getCardPtr()) {
connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this]() {
loadPixmap(this->size());
update();
});
}
setFixedSize(size); // Set the widget size to the enlarged size
update(); // Trigger a repaint

View file

@ -2,6 +2,8 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../settings_page/user_interface_settings_page.h"
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
#include "deck_list_style_proxy.h"
#include "deck_state_manager.h"
@ -12,7 +14,7 @@
#include <QSplitter>
#include <QTextEdit>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/utility/macros.h>
#include <libcockatrice/utility/string_limits.h>
@ -111,20 +113,18 @@ void DeckEditorDeckDockWidget::createDeckDock()
showBannerCardCheckBox = new QCheckBox();
showBannerCardCheckBox->setObjectName("showBannerCardCheckBox");
showBannerCardCheckBox->setChecked(
SettingsCache::instance().cardsDisplay().getDeckEditorBannerCardComboBoxVisible());
connect(showBannerCardCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setDeckEditorBannerCardComboBoxVisible);
connect(&SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::deckEditorBannerCardComboBoxVisibleChanged, this,
showBannerCardCheckBox->setChecked(SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible());
connect(showBannerCardCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(),
&DeckEditorSettings::setBannerCardComboBoxVisible);
connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::bannerCardComboBoxVisibleChanged, this,
&DeckEditorDeckDockWidget::updateShowBannerCardComboBox);
showTagsWidgetCheckBox = new QCheckBox();
showTagsWidgetCheckBox->setObjectName("showTagsWidgetCheckBox");
showTagsWidgetCheckBox->setChecked(SettingsCache::instance().cardsDisplay().getDeckEditorTagsWidgetVisible());
connect(showTagsWidgetCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setDeckEditorTagsWidgetVisible);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::deckEditorTagsWidgetVisibleChanged, this,
showTagsWidgetCheckBox->setChecked(SettingsCache::instance().deckEditor().getTagsWidgetVisible());
connect(showTagsWidgetCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(),
&DeckEditorSettings::setTagsWidgetVisible);
connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::tagsWidgetVisibleChanged, this,
&DeckEditorDeckDockWidget::updateShowTagsWidget);
quickSettingsWidget->addSettingsWidget(showBannerCardCheckBox);
@ -136,6 +136,8 @@ void DeckEditorDeckDockWidget::createDeckDock()
formatComboBox->addItem(tr("Loading Database..."));
formatComboBox->setEnabled(false); // Disable until loaded
commanderBracketWidget = new CommanderBracketWidget(this);
commentsLabel = new QLabel();
commentsLabel->setObjectName("commentsLabel");
commentsEdit = new QTextEdit;
@ -156,7 +158,7 @@ void DeckEditorDeckDockWidget::createDeckDock()
bannerCardLabel = new QLabel();
bannerCardLabel->setObjectName("bannerCardLabel");
bannerCardLabel->setText(tr("Banner Card"));
bannerCardLabel->setHidden(!SettingsCache::instance().cardsDisplay().getDeckEditorBannerCardComboBoxVisible());
bannerCardLabel->setHidden(!SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible());
bannerCardComboBox = new QComboBox(this);
connect(getModel(), &DeckListModel::cardNodesChanged, this, [this]() {
// Delay the update to avoid race conditions
@ -167,10 +169,10 @@ void DeckEditorDeckDockWidget::createDeckDock()
connect(bannerCardComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
&DeckEditorDeckDockWidget::writeBannerCard);
bannerCardComboBox->setHidden(!SettingsCache::instance().cardsDisplay().getDeckEditorBannerCardComboBoxVisible());
bannerCardComboBox->setHidden(!SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible());
deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this, {});
deckTagsDisplayWidget->setHidden(!SettingsCache::instance().cardsDisplay().getDeckEditorTagsWidgetVisible());
deckTagsDisplayWidget->setHidden(!SettingsCache::instance().deckEditor().getTagsWidgetVisible());
connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, deckStateManager,
&DeckStateManager::setTags);
@ -221,13 +223,15 @@ void DeckEditorDeckDockWidget::createDeckDock()
upperLayout->addWidget(formatLabel, 2, 0);
upperLayout->addWidget(formatComboBox, 2, 1);
upperLayout->addWidget(bannerCardLabel, 3, 0);
upperLayout->addWidget(bannerCardComboBox, 3, 1);
upperLayout->addWidget(commanderBracketWidget, 3, 0, 1, 2);
upperLayout->addWidget(deckTagsDisplayWidget, 4, 1);
upperLayout->addWidget(bannerCardLabel, 4, 0);
upperLayout->addWidget(bannerCardComboBox, 4, 1);
upperLayout->addWidget(activeGroupCriteriaLabel, 5, 0);
upperLayout->addWidget(activeGroupCriteriaComboBox, 5, 1);
upperLayout->addWidget(deckTagsDisplayWidget, 5, 1);
upperLayout->addWidget(activeGroupCriteriaLabel, 6, 0);
upperLayout->addWidget(activeGroupCriteriaComboBox, 6, 1);
hashLabel1 = new QLabel();
hashLabel1->setObjectName("hashLabel1");
@ -305,15 +309,19 @@ void DeckEditorDeckDockWidget::initializeFormats()
// Ensure no selection is visible initially
formatComboBox->setCurrentIndex(-1);
}
connect(formatComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index) {
QString formatKey;
if (index >= 0) {
QString formatKey = formatComboBox->itemData(index).toString();
formatKey = formatComboBox->itemData(index).toString();
deckStateManager->setFormat(formatKey);
} else {
deckStateManager->setFormat(""); // clear format if deselected
}
commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
});
commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
}
ExactCard DeckEditorDeckDockWidget::getCurrentCard()
@ -495,6 +503,8 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel()
formatComboBox->setCurrentIndex(formatComboBox->findData(deckStateManager->getMetadata().gameFormat));
formatComboBox->blockSignals(false);
commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
deckTagsDisplayWidget->blockSignals(true);
deckTagsDisplayWidget->setTags(deckStateManager->getMetadata().tags);
deckTagsDisplayWidget->blockSignals(false);
@ -748,6 +758,7 @@ void DeckEditorDeckDockWidget::retranslateUi()
commentsLabel->setText(tr("&Comments:"));
activeGroupCriteriaLabel->setText(tr("Group by:"));
formatLabel->setText(tr("Format:"));
commanderBracketWidget->retranslateUi();
hashLabel1->setText(tr("Hash:"));
@ -755,4 +766,4 @@ void DeckEditorDeckDockWidget::retranslateUi()
aDecrement->setText(tr("&Decrement number"));
aRemoveCard->setText(tr("&Remove row"));
aSwapCard->setText(tr("Swap card to/from sideboard"));
}
}

View file

@ -21,6 +21,7 @@
#include <QTreeView>
#include <libcockatrice/card/card_info.h>
class CommanderBracketWidget;
class DeckListModel;
class AbstractTabDeckEditor;
class DeckEditorDeckDockWidget : public QDockWidget
@ -89,6 +90,8 @@ private:
QAction *aRemoveCard, *aIncrement, *aDecrement, *aSwapCard;
CommanderBracketWidget *commanderBracketWidget;
DeckListModel *getModel() const;
[[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const;
void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement);

View file

@ -57,6 +57,14 @@ public:
*/
const DeckList &getDeckList() const;
/**
* @brief Gets the underlying DeckList.
*/
QSharedPointer<DeckList> getDeckListShared() const
{
return deckList;
}
/**
* @brief Creates a LoadedDeck containing the contents of the current deck and the current LoadInfo.
*

View file

@ -214,6 +214,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
spectatorsNeedPasswordCheckBox->setChecked(gameInfo.spectators_need_password());
spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat());
spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient());
shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load());
QSet<int> types;
for (int i = 0; i < gameInfo.game_types_size(); ++i) {

View file

@ -30,7 +30,7 @@ DlgFilterGames::DlgFilterGames(const QMap<int, QString> &_allGameTypes,
hideFullGames = new QCheckBox(tr("Hide full games"));
hideFullGames->setChecked(filters.hideFullGames);
hideGamesThatStarted = new QCheckBox(tr("Hide games that have started"));
hideGamesThatStarted = new QCheckBox(tr("Hide started games"));
hideGamesThatStarted->setChecked(filters.hideGamesThatStarted);
hidePasswordProtectedGames = new QCheckBox(tr("Hide password protected games"));

View file

@ -4,7 +4,6 @@
#include "../../../client/settings/shortcuts_settings.h"
#include "../../deck_loader/card_node_function.h"
#include "../../deck_loader/deck_loader.h"
#include "dlg_settings.h"
#include <QApplication>
#include <QCheckBox>

View file

@ -1,3 +1,8 @@
/**
* @file dlg_settings.cpp
* @brief Implementation of the main settings dialog
* @ingroup Dialogs
*/
#include "dlg_settings.h"
#include "../../../client/settings/cache_settings.h"
@ -6,6 +11,8 @@
#include "../settings_page/deck_editor_settings_page.h"
#include "../settings_page/general_settings_page.h"
#include "../settings_page/messages_settings_page.h"
#include "../settings_page/settings_search_delegate.h"
#include "../settings_page/settings_search_model.h"
#include "../settings_page/shortcut_settings_page.h"
#include "../settings_page/sound_settings_page.h"
#include "../settings_page/storage_settings_page.h"
@ -13,20 +20,36 @@
#include "libcockatrice/card/database/card_database_loader.h"
#include "libcockatrice/card/database/card_database_manager.h"
#include <QAbstractItemView>
#include <QCloseEvent>
#include <QDialogButtonBox>
#include <QDir>
#include <QFrame>
#include <QGraphicsOpacityEffect>
#include <QGuiApplication>
#include <QListWidget>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLineEdit>
#include <QListView>
#include <QMessageBox>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QScreen>
#include <QScrollArea>
#include <QScrollBar>
#include <QSequentialAnimationGroup>
#include <QShortcut>
#include <QStackedLayout>
#include <QStackedWidget>
#include <QToolButton>
#include <QVBoxLayout>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
/**
* @brief Wraps a widget in a scroll area for long settings pages
* @param widget The widget to wrap
* @return The scroll area containing the widget
*/
static QScrollArea *makeScrollable(QWidget *widget)
{
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
@ -40,112 +63,355 @@ static QScrollArea *makeScrollable(QWidget *widget)
return scrollArea;
}
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
/**
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
*/
static QStringList pageIconResources()
{
return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
}
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false)
{
auto rec = QGuiApplication::primaryScreen()->availableGeometry();
this->setMinimumSize(qMin(700, rec.width()), qMin(700, rec.height()));
setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height()));
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
contentsWidget = new QListWidget;
contentsWidget->setViewMode(QListView::IconMode);
contentsWidget->setIconSize(QSize(58, 50));
contentsWidget->setMovement(QListView::Static);
contentsWidget->setMinimumHeight(85);
contentsWidget->setMaximumHeight(85);
contentsWidget->setSpacing(5);
pagesWidget = new QStackedWidget;
pagesWidget->addWidget(makeScrollable(new GeneralSettingsPage));
pagesWidget->addWidget(makeScrollable(new AppearanceSettingsPage));
pagesWidget->addWidget(makeScrollable(new UserInterfaceSettingsPage));
pagesWidget->addWidget(new DeckEditorSettingsPage);
pagesWidget->addWidget(makeScrollable(new StorageSettingsPage));
pagesWidget->addWidget(new MessagesSettingsPage);
pagesWidget->addWidget(new SoundSettingsPage);
pagesWidget->addWidget(new ShortcutSettingsPage);
createIcons();
contentsWidget->setCurrentRow(0);
auto *vboxLayout = new QVBoxLayout;
vboxLayout->addWidget(contentsWidget);
vboxLayout->addWidget(pagesWidget);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok);
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgSettings::close);
auto *mainLayout = new QVBoxLayout;
mainLayout->addLayout(vboxLayout);
mainLayout->addSpacing(2);
mainLayout->addWidget(buttonBox);
setLayout(mainLayout);
setupUi();
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
retranslateUi();
searchEdit->setFocus();
adjustSize();
}
void DlgSettings::createIcons()
void DlgSettings::setupUi()
{
generalButton = new QListWidgetItem(contentsWidget);
generalButton->setTextAlignment(Qt::AlignHCenter);
generalButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
generalButton->setIcon(QPixmap("theme:config/general"));
// Search bar
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
searchEdit->addAction(QPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
searchEdit->installEventFilter(this);
connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged);
appearanceButton = new QListWidgetItem(contentsWidget);
appearanceButton->setTextAlignment(Qt::AlignHCenter);
appearanceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
appearanceButton->setIcon(QPixmap("theme:config/appearance"));
auto *searchLayout = new QHBoxLayout;
searchLayout->addWidget(searchEdit);
userInterfaceButton = new QListWidgetItem(contentsWidget);
userInterfaceButton->setTextAlignment(Qt::AlignHCenter);
userInterfaceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
userInterfaceButton->setIcon(QPixmap("theme:config/interface"));
// Tab bar (built in setupTabBar)
setupTabBar();
deckEditorButton = new QListWidgetItem(contentsWidget);
deckEditorButton->setTextAlignment(Qt::AlignHCenter);
deckEditorButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
deckEditorButton->setIcon(QPixmap("theme:config/deckeditor"));
// Pages stacked widget
pagesWidget = new QStackedWidget;
storageButton = new QListWidgetItem(contentsWidget);
storageButton->setTextAlignment(Qt::AlignHCenter);
storageButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
storageButton->setIcon(QPixmap("theme:config/storage"));
auto *generalPage = new GeneralSettingsPage;
auto *appearancePage = new AppearanceSettingsPage;
auto *userInterfacePage = new UserInterfaceSettingsPage;
auto *deckEditorPage = new DeckEditorSettingsPage;
auto *storagePage = new StorageSettingsPage;
auto *messagesPage = new MessagesSettingsPage;
auto *soundPage = new SoundSettingsPage;
auto *shortcutsPage = new ShortcutSettingsPage;
messagesButton = new QListWidgetItem(contentsWidget);
messagesButton->setTextAlignment(Qt::AlignHCenter);
messagesButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
messagesButton->setIcon(QPixmap("theme:config/messages"));
pages.append(generalPage);
pages.append(appearancePage);
pages.append(userInterfacePage);
pages.append(deckEditorPage);
pages.append(storagePage);
pages.append(messagesPage);
pages.append(soundPage);
pages.append(shortcutsPage);
soundButton = new QListWidgetItem(contentsWidget);
soundButton->setTextAlignment(Qt::AlignHCenter);
soundButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
soundButton->setIcon(QPixmap("theme:config/sound"));
pagesWidget->addWidget(makeScrollable(generalPage));
pagesWidget->addWidget(makeScrollable(appearancePage));
pagesWidget->addWidget(makeScrollable(userInterfacePage));
pagesWidget->addWidget(makeScrollable(deckEditorPage));
pagesWidget->addWidget(makeScrollable(storagePage));
pagesWidget->addWidget(messagesPage);
pagesWidget->addWidget(soundPage);
pagesWidget->addWidget(shortcutsPage);
shortcutsButton = new QListWidgetItem(contentsWidget);
shortcutsButton->setTextAlignment(Qt::AlignHCenter);
shortcutsButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
shortcutsButton->setIcon(QPixmap("theme:config/shorcuts"));
Q_ASSERT(pages.size() == NumPages);
connect(contentsWidget, &QListWidget::currentItemChanged, this, &DlgSettings::changePage);
// Search results view (hidden by default)
searchResultsView = new QListView;
searchResultsView->setUniformItemSizes(false);
searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
searchResultsView->setVisible(false);
searchResultsView->setStyleSheet(
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
searchModel = new SettingsSearchModel(this);
searchDelegate = new SettingsSearchDelegate(this);
searchResultsView->setModel(searchModel);
searchResultsView->setItemDelegate(searchDelegate);
connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked);
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
const QStringList icons = pageIconResources();
for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) {
tabButtons[i]->setIcon(QPixmap(icons[i]));
}
searchDelegate->setPageIcons(icons);
searchResultsView->viewport()->update();
});
// Build search index after pages are created
buildSearchIndex();
// Pages container (stacked widget + search results overlay)
pagesContainer = new QWidget;
auto *containerLayout = new QStackedLayout;
containerLayout->setStackingMode(QStackedLayout::StackAll);
containerLayout->addWidget(pagesWidget);
containerLayout->addWidget(searchResultsView);
pagesContainer->setLayout(containerLayout);
// Bottom buttons
auto *buttonBox = new QHBoxLayout;
buttonBox->addStretch();
okButton = new QPushButton;
okButton->setDefault(true);
connect(okButton, &QPushButton::clicked, this, &DlgSettings::close);
buttonBox->addWidget(okButton);
// Main layout
auto *mainLayout = new QVBoxLayout;
mainLayout->addLayout(searchLayout);
mainLayout->addWidget(tabBarWidget);
auto *separator = new QFrame;
separator->setFrameShape(QFrame::HLine);
separator->setFrameShadow(QFrame::Sunken);
mainLayout->addWidget(separator);
mainLayout->addWidget(pagesContainer);
mainLayout->addSpacing(4);
mainLayout->addLayout(buttonBox);
setLayout(mainLayout);
// Keyboard shortcuts
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
connect(nextTabShortcut, &QShortcut::activated, this, [this] {
int next = (currentTabIndex + 1) % tabButtons.size();
setActiveTab(next);
});
auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
connect(prevTabShortcut, &QShortcut::activated, this, [this] {
int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size();
setActiveTab(prev);
});
// Initialize to first tab
setActiveTab(0);
}
void DlgSettings::changePage(QListWidgetItem *current, QListWidgetItem *previous)
void DlgSettings::setupTabBar()
{
if (!current) {
current = previous;
tabBarWidget = new QWidget;
auto *tabLayout = new QHBoxLayout;
tabLayout->setContentsMargins(0, 0, 0, 0);
tabLayout->setSpacing(2);
const QStringList iconResources = pageIconResources();
for (int i = 0; i < iconResources.size(); ++i) {
auto *tabButton = new QToolButton;
tabButton->setCheckable(true);
tabButton->setIcon(QPixmap(iconResources[i]));
tabButton->setIconSize(QSize(48, 48));
tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
tabButton->setAutoExclusive(true);
tabButton->setMinimumHeight(85);
tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); });
tabButtons.append(tabButton);
tabLayout->addWidget(tabButton);
}
pagesWidget->setCurrentIndex(contentsWidget->row(current));
tabBarWidget->setLayout(tabLayout);
}
void DlgSettings::buildSearchIndex()
{
QList<SettingsSearchEntry> allEntries;
const QStringList pageNames = translatedPageNames();
searchDelegate->setPageNames(pageNames);
searchDelegate->setPageIcons(pageIconResources());
for (int i = 0; i < pages.size(); ++i) {
QList<SettingsSearchEntry> pageEntries = pages[i]->getSearchEntries();
for (auto &entry : pageEntries) {
if (entry.pageIndex == -1) {
entry.pageIndex = i;
}
}
allEntries.append(pageEntries);
}
searchModel->setSourceEntries(allEntries);
}
void DlgSettings::onTabClicked(int index)
{
if (searchActive) {
switchToTabMode();
}
setActiveTab(index);
}
void DlgSettings::setActiveTab(int index)
{
if (index < 0 || index >= tabButtons.size()) {
return;
}
currentTabIndex = index;
pagesWidget->setCurrentIndex(index);
for (int i = 0; i < tabButtons.size(); ++i) {
tabButtons[i]->setChecked(i == index);
}
// Style active tab with a thick accent border + subtle background tint
for (int i = 0; i < tabButtons.size(); ++i) {
if (i == index) {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: palette(window); padding-bottom: 1px; }");
} else {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: transparent; }");
}
}
}
void DlgSettings::flashWidget(QWidget *widget)
{
auto *overlay = new QWidget(widget);
overlay->setGeometry(widget->rect());
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
QPalette pal = overlay->palette();
QColor flashColor = pal.color(QPalette::Highlight);
flashColor.setAlpha(100);
pal.setBrush(QPalette::Window, flashColor);
overlay->setPalette(pal);
overlay->setAutoFillBackground(true);
auto *effect = new QGraphicsOpacityEffect(overlay);
effect->setOpacity(0.0);
overlay->setGraphicsEffect(effect);
overlay->show();
overlay->raise();
auto *flashIn = new QPropertyAnimation(effect, "opacity");
flashIn->setDuration(120);
flashIn->setStartValue(0.0);
flashIn->setEndValue(0.6);
flashIn->setEasingCurve(QEasingCurve::OutCubic);
auto *fadeOut = new QPropertyAnimation(effect, "opacity");
fadeOut->setDuration(900);
fadeOut->setStartValue(0.6);
fadeOut->setEndValue(0.0);
fadeOut->setEasingCurve(QEasingCurve::InCubic);
auto *group = new QSequentialAnimationGroup(overlay);
group->addAnimation(flashIn);
group->addAnimation(fadeOut);
connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
group->start(QAbstractAnimation::DeleteWhenStopped);
}
void DlgSettings::onSearchTextChanged(const QString &text)
{
searchModel->setFilterString(text);
if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
if (!searchActive) {
switchToSearchMode();
}
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
} else if (searchActive) {
switchToTabMode();
}
}
void DlgSettings::switchToSearchMode()
{
searchActive = true;
tabBarWidget->setVisible(false);
pagesWidget->setVisible(false);
searchResultsView->setVisible(true);
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
}
void DlgSettings::switchToTabMode()
{
searchActive = false;
tabBarWidget->setVisible(true);
pagesWidget->setVisible(true);
searchResultsView->setVisible(false);
searchEdit->blockSignals(true);
searchEdit->clear();
searchEdit->blockSignals(false);
setActiveTab(currentTabIndex);
}
void DlgSettings::onSearchResultClicked(const QModelIndex &index)
{
navigateToSearchResult(index);
}
void DlgSettings::navigateToSearchResult(const QModelIndex &index)
{
SettingsSearchEntry entry = searchModel->entryForIndex(index);
if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
return;
}
// Switch to the page
switchToTabMode();
setActiveTab(entry.pageIndex);
// Scroll to the widget, focus it, and flash to highlight it
if (entry.widget) {
QWidget *widget = entry.widget;
while (widget) {
if (auto *scrollArea = qobject_cast<QScrollArea *>(widget)) {
scrollArea->ensureWidgetVisible(entry.widget);
break;
}
widget = widget->parentWidget();
}
entry.widget->setFocus();
flashWidget(entry.widget);
}
}
void DlgSettings::setTab(int index)
{
if (index <= contentsWidget->count() - 1 && index >= 0) {
changePage(contentsWidget->item(index), contentsWidget->currentItem());
contentsWidget->setCurrentRow(index);
if (index >= 0 && index < tabButtons.size()) {
setActiveTab(index);
}
}
@ -155,6 +421,49 @@ void DlgSettings::updateLanguage()
installNewTranslator();
}
bool DlgSettings::eventFilter(QObject *watched, QEvent *event)
{
if (watched == searchEdit && event->type() == QEvent::KeyPress) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Escape) {
if (searchActive) {
switchToTabMode();
return true;
}
} else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
if (searchActive) {
if (searchResultsView->currentIndex().isValid()) {
navigateToSearchResult(searchResultsView->currentIndex());
}
return true;
}
} else if (keyEvent->key() == Qt::Key_Down) {
if (searchActive) {
int nextRow = searchResultsView->currentIndex().row() + 1;
if (nextRow >= searchModel->rowCount()) {
nextRow = 0;
}
searchResultsView->setCurrentIndex(searchModel->index(nextRow));
searchResultsView->scrollTo(searchModel->index(nextRow));
return true;
}
} else if (keyEvent->key() == Qt::Key_Up) {
if (searchActive) {
int prevRow = searchResultsView->currentIndex().row() - 1;
if (prevRow < 0) {
prevRow = searchModel->rowCount() - 1;
}
if (prevRow >= 0) {
searchResultsView->setCurrentIndex(searchModel->index(prevRow));
searchResultsView->scrollTo(searchModel->index(prevRow));
}
return true;
}
}
}
return QDialog::eventFilter(watched, event);
}
void DlgSettings::closeEvent(QCloseEvent *event)
{
bool showLoadError = true;
@ -209,7 +518,6 @@ void DlgSettings::closeEvent(QCloseEvent *event)
if (!QDir(SettingsCache::instance().paths().getDeckPath()).exists() ||
SettingsCache::instance().paths().getDeckPath().isEmpty()) {
//! \todo Prompt to create the deck directory.
if (QMessageBox::critical(
this, tr("Error"),
tr("The path to your deck directory is invalid. Would you like to go back and set the correct path?"),
@ -221,7 +529,6 @@ void DlgSettings::closeEvent(QCloseEvent *event)
if (!QDir(SettingsCache::instance().paths().getPicsPath()).exists() ||
SettingsCache::instance().paths().getPicsPath().isEmpty()) {
//! \todo Prompt to create the pictures directory.
if (QMessageBox::critical(this, tr("Error"),
tr("The path to your card pictures directory is invalid. Would you like to go back "
"and set the correct path?"),
@ -236,15 +543,26 @@ void DlgSettings::closeEvent(QCloseEvent *event)
void DlgSettings::retranslateUi()
{
setWindowTitle(tr("Settings"));
retranslateTabNames();
generalButton->setText(tr("General"));
appearanceButton->setText(tr("Appearance"));
userInterfaceButton->setText(tr("User Interface"));
storageButton->setText(tr("Storage"));
deckEditorButton->setText(tr("Card Sources"));
messagesButton->setText(tr("Chat"));
soundButton->setText(tr("Sound"));
shortcutsButton->setText(tr("Shortcuts"));
searchEdit->setPlaceholderText(tr("Search settings..."));
okButton->setText(tr("OK"));
contentsWidget->reset();
// Rebuild search index for translated text
buildSearchIndex();
}
QStringList DlgSettings::translatedPageNames()
{
return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"),
tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")};
}
void DlgSettings::retranslateTabNames()
{
const QStringList tabLabels = translatedPageNames();
for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
tabButtons[i]->setText(tabLabels[i]);
}
}

View file

@ -1,43 +1,99 @@
/**
* @file dlg_settings.h
* @brief Main settings dialog for the Cockatrice client
* @ingroup Dialogs
*/
//! \todo Document this file.
#ifndef DLG_SETTINGS_H
#define DLG_SETTINGS_H
#include <QComboBox>
#include <QDialog>
#include <QLoggingCategory>
class QPushButton;
inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings");
class QListWidget;
class QStackedWidget;
class QListWidgetItem;
class QToolButton;
class QListView;
class QLineEdit;
class AbstractSettingsPage;
class SettingsSearchModel;
class SettingsSearchDelegate;
/**
* @brief Main application settings dialog with tabbed navigation and search
*
* Provides a modern settings interface organized into tabbed pages. Users can
* either navigate by clicking tabs or search for specific settings using the
* built-in search bar. Search results are filtered and ranked by relevance.
*/
class DlgSettings : public QDialog
{
Q_OBJECT
public:
/**
* @brief Page order in the tab bar, matching the order pages are added in setupUi()
*
* Use these values instead of raw indices so reordering pages never silently
* breaks external callers like tab_room.cpp.
*/
enum SettingsPage
{
GeneralPage = 0,
AppearancePage,
UserInterfacePage,
DeckEditorPage,
StoragePage,
MessagesPage,
SoundPage,
ShortcutsPage,
NumPages
};
explicit DlgSettings(QWidget *parent = nullptr);
void setTab(int index);
private slots:
void changePage(QListWidgetItem *current, QListWidgetItem *previous);
void onTabClicked(int index);
void onSearchTextChanged(const QString &text);
void onSearchResultClicked(const QModelIndex &index);
void updateLanguage();
private:
QListWidget *contentsWidget;
QStackedWidget *pagesWidget;
QListWidgetItem *generalButton, *appearanceButton, *userInterfaceButton, *deckEditorButton, *storageButton,
*messagesButton, *soundButton, *shortcutsButton;
void createIcons();
// UI elements
QLineEdit *searchEdit; ///< Search bar for filtering settings
QWidget *tabBarWidget; ///< Container widget for the tab buttons
QList<QToolButton *> tabButtons; ///< Navigation tab buttons
QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
QListView *searchResultsView; ///< Search results list view
QWidget *pagesContainer; ///< Container stacking pages and search results
QPushButton *okButton; ///< Button to close the dialog
// Data
QList<AbstractSettingsPage *> pages; ///< All settings page instances
SettingsSearchModel *searchModel; ///< Model for search results
SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering
int currentTabIndex; ///< Currently active tab index
bool searchActive; ///< Whether search mode is active
void setupUi();
void setupTabBar();
void buildSearchIndex();
void switchToTabMode();
void switchToSearchMode();
void navigateToSearchResult(const QModelIndex &index);
void setActiveTab(int index);
static void flashWidget(QWidget *widget);
static QStringList translatedPageNames();
void retranslateUi();
void retranslateTabNames();
protected:
void closeEvent(QCloseEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
};
#endif
#endif // DLG_SETTINGS_H

View file

@ -14,8 +14,8 @@
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/remote/remote_client.h>
#include <libcockatrice/settings/appearance_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
: QWidget(parent), tabSupervisor(_tabSupervisor), background("theme:backgrounds/home"), overlay("theme:cockatrice")
@ -43,12 +43,12 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
updateConnectButton(tabSupervisor->getClient()->getStatus());
connect(tabSupervisor->getClient(), &RemoteClient::statusChanged, this, &HomeWidget::updateConnectButton);
connect(&SettingsCache::instance().personal(), &PersonalSettings::homeTabBackgroundSourceChanged, this,
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundSourceChanged, this,
&HomeWidget::initializeBackgroundFromSource);
connect(&SettingsCache::instance().personal(), &PersonalSettings::homeTabBackgroundShuffleFrequencyChanged, this,
&HomeWidget::onBackgroundShuffleFrequencyChanged);
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundShuffleFrequencyChanged,
this, &HomeWidget::onBackgroundShuffleFrequencyChanged);
// Lambda is cleaner to read than overloading this
connect(&SettingsCache::instance().personal(), &PersonalSettings::homeTabDisplayCardNameChanged, this,
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabDisplayCardNameChanged, this,
[this] { repaint(); });
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this,
&HomeWidget::initializeBackgroundFromSource);
@ -65,7 +65,7 @@ void HomeWidget::initializeBackgroundFromSource()
}
auto backgroundSourceType =
BackgroundSources::fromId(SettingsCache::instance().personal().getHomeTabBackgroundSource());
BackgroundSources::fromId(SettingsCache::instance().appearance().getHomeTabBackgroundSource());
switch (backgroundSourceType) {
case BackgroundSources::Theme:
@ -113,7 +113,7 @@ void HomeWidget::setRandomCard(ExactCard &newCard)
void HomeWidget::updateRandomCard()
{
auto backgroundSourceType =
BackgroundSources::fromId(SettingsCache::instance().personal().getHomeTabBackgroundSource());
BackgroundSources::fromId(SettingsCache::instance().appearance().getHomeTabBackgroundSource());
ExactCard newCard;
@ -156,8 +156,8 @@ void HomeWidget::updateRandomCard()
void HomeWidget::onBackgroundShuffleFrequencyChanged()
{
cardChangeTimer->stop();
if (SettingsCache::instance().personal().getHomeTabBackgroundShuffleFrequency() > 0) {
cardChangeTimer->start(SettingsCache::instance().personal().getHomeTabBackgroundShuffleFrequency() * 1000);
if (SettingsCache::instance().appearance().getHomeTabBackgroundShuffleFrequency() > 0) {
cardChangeTimer->start(SettingsCache::instance().appearance().getHomeTabBackgroundShuffleFrequency() * 1000);
}
}
@ -265,7 +265,7 @@ void HomeWidget::updateConnectButton(const ClientStatus status)
QPair<QColor, QColor> HomeWidget::extractDominantColors(const QPixmap &pixmap)
{
if (themeManager->isBuiltInTheme() && SettingsCache::instance().personal().getHomeTabBackgroundSource() ==
if (themeManager->isBuiltInTheme() && SettingsCache::instance().appearance().getHomeTabBackgroundSource() ==
BackgroundSources::toId(BackgroundSources::Theme)) {
return QPair<QColor, QColor>(QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80));
}
@ -352,7 +352,7 @@ void HomeWidget::paintEvent(QPaintEvent *event)
}
}
if (!cardName.isEmpty() && SettingsCache::instance().personal().getHomeTabDisplayCardName()) {
if (!cardName.isEmpty() && SettingsCache::instance().appearance().getHomeTabDisplayCardName()) {
QFont font = painter.font();
font.setPointSize(14);
font.setBold(true);

View file

@ -16,16 +16,16 @@ class TearOffMenu : public QMenu
public:
explicit TearOffMenu(const QString &title, QWidget *parent = nullptr) : QMenu(title, parent)
{
connect(&SettingsCache::instance().interface(), &InterfaceSettings::useTearOffMenusChanged, this,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::useTearOffMenusChanged, this,
[this](const bool state) { setTearOffEnabled(state); });
setTearOffEnabled(SettingsCache::instance().interface().getUseTearOffMenus());
setTearOffEnabled(SettingsCache::instance().userInterface().getUseTearOffMenus());
}
explicit TearOffMenu(QWidget *parent = nullptr) : QMenu(parent)
{
connect(&SettingsCache::instance().interface(), &InterfaceSettings::useTearOffMenusChanged, this,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::useTearOffMenusChanged, this,
[this](const bool state) { setTearOffEnabled(state); });
setTearOffEnabled(SettingsCache::instance().interface().getUseTearOffMenus());
setTearOffEnabled(SettingsCache::instance().userInterface().getUseTearOffMenus());
}
TearOffMenu *addTearOffMenu(const QString &title)

View file

@ -3,9 +3,11 @@
#include "../../../client/settings/cache_settings.h"
#include <QTimer>
#include <libcockatrice/protocol/get_pb_extension.h>
#include <libcockatrice/settings/interface_settings.h>
static constexpr int TIMER_INTERVAL_MS = 200;
static constexpr int EMPTY_SECTION_MARGIN_MS = 500;
static QList<int> createReplayTimeline(const GameReplay *replay)
{
@ -94,7 +96,7 @@ void ReplayManager::handleBackwardsSkip(bool doRewindBuffering)
// The rewind only happens once the timer runs out.
// If another backwards skip happens, the timer will just get reset instead of rewinding.
rewindBufferingTimer->stop();
rewindBufferingTimer->start(SettingsCache::instance().interface().getRewindBufferingMs());
rewindBufferingTimer->start(SettingsCache::instance().userInterface().getRewindBufferingMs());
} else {
// otherwise, process the rewind immediately
processRewind();
@ -119,6 +121,10 @@ void ReplayManager::replayTimerTimeout()
processNewEvents(NORMAL_PLAYBACK);
timeChanged(currentVisualTime);
if (skipEmptySections) {
handleSkipEmptySection();
}
}
/** @brief Processes all unprocessed events up to the current time. */
@ -149,6 +155,51 @@ void ReplayManager::processNewEvents(PlaybackMode playbackMode)
}
}
static bool hasMeaningfulEvent(const GameEventContainer &cont)
{
const int eventListSize = cont.event_list_size();
for (int i = 0; i < eventListSize; ++i) {
const GameEvent &event = cont.event_list(i);
const auto eventType = static_cast<GameEvent::GameEventType>(getPbExtension(event));
if (eventType != GameEvent::PLAYER_PROPERTIES_CHANGED) {
return true;
}
}
return false;
}
void ReplayManager::handleSkipEmptySection()
{
if (currentEvent == replayTimeline.size()) {
return;
}
// find most recent meaningful event
int prevEvent = std::max(0, currentEvent - 1);
for (; prevEvent > 0 && !hasMeaningfulEvent(replay->event_list(prevEvent)); --prevEvent) {
}
int prevEventTime = replayTimeline.value(prevEvent);
if (currentVisualTime - prevEventTime <= EMPTY_SECTION_MARGIN_MS) {
return;
}
// find next earliest meaningful event
int nextEvent = currentEvent;
for (; nextEvent < replayTimeline.size() - 1 && !hasMeaningfulEvent(replay->event_list(nextEvent)); ++nextEvent) {
}
int nextEventTime = replayTimeline.value(nextEvent);
if (nextEventTime - currentVisualTime <= EMPTY_SECTION_MARGIN_MS) {
return;
}
// skip forward if we're not within margin of either event
skipToTime(nextEventTime - EMPTY_SECTION_MARGIN_MS, false);
}
void ReplayManager::setTimeScaleFactor(qreal _timeScaleFactor)
{
timeScaleFactor = _timeScaleFactor;
@ -156,6 +207,11 @@ void ReplayManager::setTimeScaleFactor(qreal _timeScaleFactor)
replayTimer->setInterval(interval);
}
void ReplayManager::setSkipEmptySections(bool value)
{
skipEmptySections = value;
}
void ReplayManager::startReplay()
{
replayTimer->start();

View file

@ -31,6 +31,7 @@ class ReplayManager : public QObject
QTimer *rewindBufferingTimer;
qreal timeScaleFactor = 1.0;
bool skipEmptySections = false;
int currentVisualTime = 0; ///< time currently displayed by the timeline
int currentProcessedTime = 0; ///< time that events are currently processed up to. Could differ from visual time due
@ -41,6 +42,7 @@ class ReplayManager : public QObject
void handleBackwardsSkip(bool doRewindBuffering);
void processRewind();
void processNewEvents(PlaybackMode playbackMode);
void handleSkipEmptySection();
private slots:
void replayTimerTimeout();
@ -63,6 +65,7 @@ public:
}
void setTimeScaleFactor(qreal _timeScaleFactor);
void setSkipEmptySections(bool value);
public slots:
void startReplay();

View file

@ -14,16 +14,21 @@ ReplayQuickSettingsWidget::ReplayQuickSettingsWidget(QWidget *parent) : Settings
fastForwardSpeedBox.setMinimum(1);
fastForwardSpeedBox.setMaximum(99.9);
fastForwardSpeedBox.setDecimals(1);
fastForwardSpeedBox.setValue(SettingsCache::instance().interface().getFastForwardSpeed());
fastForwardSpeedBox.setValue(SettingsCache::instance().userInterface().getFastForwardSpeed());
connect(&fastForwardSpeedBox, qOverload<double>(&QDoubleSpinBox::valueChanged), this,
&ReplayQuickSettingsWidget::actUpdateFastForwardSpeed);
skipEmptyCheckBox.setChecked(SettingsCache::instance().userInterface().getSkipEmptySections());
connect(&skipEmptyCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
&ReplayQuickSettingsWidget::actUpdateSkipEmptySections);
// putting it all together
auto *widget = new QWidget;
auto *grid = new QGridLayout(widget);
grid->setContentsMargins(0, 0, 0, 0);
grid->addWidget(&fastForwardSpeedLabel, 0, 0, 1, 1);
grid->addWidget(&fastForwardSpeedBox, 0, 1, 1, 1);
grid->addWidget(&skipEmptyCheckBox, 1, 0, 1, 2);
this->addSettingsWidget(widget);
@ -36,10 +41,18 @@ void ReplayQuickSettingsWidget::retranslateUi()
{
fastForwardSpeedLabel.setText(tr("Fast forward speed:"));
fastForwardSpeedBox.setSuffix("x");
skipEmptyCheckBox.setText(tr("Skip empty sections"));
}
void ReplayQuickSettingsWidget::actUpdateFastForwardSpeed(qreal value)
{
SettingsCache::instance().interface().setFastForwardSpeed(value);
SettingsCache::instance().userInterface().setFastForwardSpeed(value);
emit fastForwardSpeedChanged(value);
}
void ReplayQuickSettingsWidget::actUpdateSkipEmptySections(QT_STATE_CHANGED_T value)
{
SettingsCache::instance().userInterface().setSkipEmptySections(value);
emit skipEmptySectionsChanged(value);
}

View file

@ -3,7 +3,9 @@
#include "../../interface/widgets/quick_settings/settings_button_widget.h"
#include <QCheckBox>
#include <QDoubleSpinBox>
#include <libcockatrice/utility/macros.h>
class ReplayQuickSettingsWidget : public SettingsButtonWidget
{
@ -16,13 +18,17 @@ public:
signals:
void fastForwardSpeedChanged(qreal speed);
void skipEmptySectionsChanged(bool skip);
private:
QLabel fastForwardSpeedLabel;
QDoubleSpinBox fastForwardSpeedBox;
QCheckBox skipEmptyCheckBox;
private slots:
void actUpdateFastForwardSpeed(qreal value);
void actUpdateSkipEmptySections(QT_STATE_CHANGED_T value);
};
#endif // COCKATRICE_REPLAY_QUICK_SETTINGS_WIDGET_H

View file

@ -66,6 +66,10 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay)
settingsWidget->setFixedSize(QSize(32, 32));
connect(settingsWidget, &ReplayQuickSettingsWidget::fastForwardSpeedChanged, this,
[this] { updateTimeScaleFactor(replayFastForwardButton->isChecked()); });
connect(settingsWidget, &ReplayQuickSettingsWidget::skipEmptySectionsChanged, replayManager,
&ReplayManager::setSkipEmptySections);
replayManager->setSkipEmptySections(SettingsCache::instance().userInterface().getSkipEmptySections());
// putting everything together
auto replayControlLayout = new QHBoxLayout;
@ -98,7 +102,7 @@ void ReplayWidget::replayPlayButtonToggled(bool checked)
void ReplayWidget::updateTimeScaleFactor(bool isFastForward)
{
qreal factor = isFastForward ? SettingsCache::instance().interface().getFastForwardSpeed() : 1.0;
qreal factor = isFastForward ? SettingsCache::instance().userInterface().getFastForwardSpeed() : 1.0;
replayManager->setTimeScaleFactor(factor);
}

View file

@ -14,6 +14,7 @@
#include <QMouseEvent>
#include <QScrollBar>
#include <QTimer>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/settings/chat_settings.h>
@ -66,12 +67,14 @@ void ChatView::adjustColorsToPalette()
serverMessageColor = QColor(0xFF, 0x73, 0x83);
otherUserColor = otherUserColor.lighter(150);
linkColor = QColor(71, 158, 252);
unresolvedCardTagColor = QColor(0xFF, 0xA5, 0x00);
} else {
document()->setDefaultStyleSheet(R"(
a { text-decoration: none; color: blue; }
.blue { color: blue }
)");
linkColor = palette().link().color();
unresolvedCardTagColor = QColor(0xA0, 0x52, 0x2D);
}
QTimer::singleShot(0, this, &ChatView::refreshBlockColors);
@ -177,13 +180,22 @@ void ChatView::appendHtmlServerMessage(const QString &html, bool optionalIsBold,
void ChatView::appendCardTag(QTextCursor &cursor, const QString &cardName)
{
QTextCharFormat oldFormat = cursor.charFormat();
QTextCharFormat anchorFormat = oldFormat;
anchorFormat.setForeground(linkColor);
anchorFormat.setAnchor(true);
anchorFormat.setAnchorHref("card://" + cardName);
anchorFormat.setFontItalic(true);
QTextCharFormat cardFormat = oldFormat;
cardFormat.setFontItalic(true);
cursor.setCharFormat(anchorFormat);
if (!CardDatabaseManager::query()->lookupCardByName(cardName)) {
cardFormat.setForeground(unresolvedCardTagColor);
cursor.setCharFormat(cardFormat);
cursor.insertText(cardName);
cursor.setCharFormat(oldFormat);
return;
}
cardFormat.setForeground(linkColor);
cardFormat.setAnchor(true);
cardFormat.setAnchorHref("card://" + cardName);
cursor.setCharFormat(cardFormat);
cursor.insertText(cardName);
cursor.setCharFormat(oldFormat);
}

View file

@ -83,6 +83,7 @@ private:
QColor otherUserColor = QColor(0, 65, 255); // dark blue
QColor serverMessageColor = QColor(0x85, 0x15, 0x15);
QColor linkColor;
QColor unresolvedCardTagColor;
private slots:
void openLink(const QUrl &link);

View file

@ -26,6 +26,7 @@
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/interface_settings.h>
GameSelector::GameSelector(AbstractClient *_client,
TabSupervisor *_tabSupervisor,
@ -83,12 +84,12 @@ GameSelector::GameSelector(AbstractClient *_client,
if (showFilters && restoresettings) {
quickFilterToolBar = new GameSelectorQuickFilterToolBar(this, tabSupervisor, gameListProxyModel, gameTypeMap);
quickFilterToolBar->setVisible(showFilters && restoresettings &&
SettingsCache::instance().cardsDisplay().getShowGameSelectorFilterToolbar());
SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar());
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::showGameSelectorFilterToolbarChanged,
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::showGameSelectorFilterToolbarChanged,
this, [this] {
quickFilterToolBar->setVisible(
SettingsCache::instance().cardsDisplay().getShowGameSelectorFilterToolbar());
SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar());
});
} else {
quickFilterToolBar = nullptr;

View file

@ -1,8 +1,7 @@
#include "user_card_settings_dialog.h"
#include "../../../card_picture_loader/card_picture_loader.h"
#include "card/card_completer_proxy_model.h"
#include "card/card_search_model.h"
#include "../../utility/completer_utils.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "user_card_art_provider.h"
@ -19,7 +18,6 @@
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QRegularExpression>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
@ -133,29 +131,14 @@ void UserCardArtSettingsDialog::initializeSearchBar()
cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this);
cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel);
searchModel = new CardSearchModel(cardDatabaseDisplayModel, this);
proxyModel = new CardCompleterProxyModel(this);
proxyModel->setSourceModel(searchModel);
proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
proxyModel->setFilterRole(Qt::DisplayRole);
completer = new QCompleter(proxyModel, this);
completer->setCompletionRole(Qt::DisplayRole);
completer->setCompletionMode(QCompleter::PopupCompletion);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setFilterMode(Qt::MatchContains);
completer->setMaxVisibleItems(15);
const CardCompleterSetup cardSetup = createCardCompleter(cardDatabaseDisplayModel, this, 15);
searchModel = cardSetup.searchModel;
proxyModel = cardSetup.proxyModel;
completer = cardSetup.completer;
searchBar->setCompleter(completer);
connect(searchBar, &QLineEdit::textEdited, searchModel, &CardSearchModel::updateSearchResults);
connect(searchBar, &QLineEdit::textEdited, this, [this](const QString &text) {
const QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
completer->complete();
}
});
connectCardCompleterSearch(searchBar, cardSetup);
connect(completer, static_cast<void (QCompleter::*)(const QString &)>(&QCompleter::activated), this,
[this](const QString &completion) {

View file

@ -527,6 +527,21 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
// ── showForUser ───────────────────────────────────────────────────────────────
void UserInfoPopup::refreshHeader()
{
if (m_currentUser.isEmpty()) {
return;
}
const QPixmap avatar = m_avatarCache ? m_avatarCache->value(m_currentUser) : QPixmap{};
const CardArtParams params = (m_cardArtParamsMap && m_cardArtParamsMap->contains(m_currentUser))
? m_cardArtParamsMap->value(m_currentUser)
: CardArtParams{};
const QString artKey = m_currentUser + u'|' + params.cardName + u'|' + params.cardProviderId;
const QPixmap cardArt = (m_cardArtCache && !params.cardName.isEmpty()) ? m_cardArtCache->value(artKey) : QPixmap{};
m_header->setUserData(m_currentUserInfo, m_currentOnline, avatar, cardArt, params);
}
void UserInfoPopup::showForUser(const QString &userName,
const ServerInfo_User &userInfo,
bool online,
@ -538,13 +553,7 @@ void UserInfoPopup::showForUser(const QString &userName,
m_currentOnline = online;
// Header
const QPixmap avatar = m_avatarCache ? m_avatarCache->value(userName) : QPixmap{};
const CardArtParams params = (m_cardArtParamsMap && m_cardArtParamsMap->contains(userName))
? m_cardArtParamsMap->value(userName)
: CardArtParams{};
const QString artKey = userName + u'|' + params.cardName + u'|' + params.cardProviderId;
const QPixmap cardArt = (m_cardArtCache && !params.cardName.isEmpty()) ? m_cardArtCache->value(artKey) : QPixmap{};
m_header->setUserData(userInfo, online, avatar, cardArt, params);
refreshHeader();
// Actions
rebuildActionButtons(userInfo, online, isBuddy, isIgnored);

View file

@ -116,6 +116,9 @@ public:
/** Called when buddy/ignore status changes externally while popup is open. */
void updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
/** Re-pulls the avatar/card art for the currently shown user (e.g. after it loads). */
void refreshHeader();
signals:
void mouseEnteredPopup();
void mouseLeftPopup();

View file

@ -23,6 +23,7 @@
#include <QPlainTextEdit>
#include <QPushButton>
#include <QRadioButton>
#include <QScreen>
#include <QSpinBox>
#include <QWidget>
#include <libcockatrice/card/database/card_database_manager.h>
@ -30,7 +31,7 @@
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pb/response_get_user_info.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/appearance_settings.h>
#include <libcockatrice/utility/string_limits.h>
BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent)
@ -349,7 +350,7 @@ bool UserListItemDelegate::editorEvent(QEvent *event,
QSize UserListItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
if (!SettingsCache::instance().interface().getStyleUserList()) {
if (!SettingsCache::instance().appearance().getStyleUserList()) {
return QStyledItemDelegate::sizeHint(option, index);
}
return UserListPainter::sizeHint();
@ -357,7 +358,7 @@ QSize UserListItemDelegate::sizeHint(const QStyleOptionViewItem &option, const Q
void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
if (!SettingsCache::instance().interface().getStyleUserList()) {
if (!SettingsCache::instance().appearance().getStyleUserList()) {
QStyledItemDelegate::paint(painter, option, index);
return;
}
@ -495,8 +496,6 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
m_userInfoPopup->setWindowOpacity(0.0);
m_userInfoPopup->installEventFilter(this);
connectPopupSignals();
m_showPopupTimer = new QTimer(this);
m_showPopupTimer->setSingleShot(true);
m_showPopupTimer->setInterval(280);
@ -515,13 +514,15 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
}
});
connectPopupSignals();
userTree->setMouseTracking(true);
userTree->viewport()->setMouseTracking(true);
userTree->viewport()->installEventFilter(this);
// Pin on item click
connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) {
if (!SettingsCache::instance().interface().getStyleUserList()) {
if (!SettingsCache::instance().appearance().getStyleUserList()) {
return;
}
const QString name = static_cast<UserListTWI *>(item)->getUserInfo().name().c_str();
@ -543,17 +544,16 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] {
m_showPopupTimer->stop();
hidePopup(true);
requestAvatarsForVisibleItems();
});
// Forward join requests from popup upward
connect(m_userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested);
connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this,
[this](const QString &) { userTree->viewport()->update(); });
connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this,
[this](const QString &) { userTree->viewport()->update(); });
connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, &UserListWidget::refreshVisibleUserHeader);
connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, &UserListWidget::refreshVisibleUserHeader);
connect(&SettingsCache::instance().interface(), &InterfaceSettings::styleUserListChanged, this,
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::styleUserListChanged, this,
&UserListWidget::applyDisplayMode);
applyDisplayMode();
@ -633,6 +633,14 @@ void UserListWidget::bind(UserListManager *mgr)
rebuild();
}
void UserListWidget::refreshVisibleUserHeader(const QString &name)
{
userTree->viewport()->update();
if (m_userInfoPopup->isVisible() && m_userInfoPopup->currentUser() == name) {
m_userInfoPopup->refreshHeader();
}
}
void UserListWidget::refreshPopupButtons(const QString &userName)
{
UserListTWI *item = users.value(userName);
@ -657,9 +665,15 @@ void UserListWidget::hideEvent(QHideEvent *e)
hidePopup(true);
}
void UserListWidget::showEvent(QShowEvent *e)
{
QGroupBox::showEvent(e);
requestAvatarsForVisibleItems();
}
void UserListWidget::applyDisplayMode()
{
const bool styled = SettingsCache::instance().interface().getStyleUserList();
const bool styled = SettingsCache::instance().appearance().getStyleUserList();
if (styled) {
userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch);
@ -718,7 +732,7 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event)
{
if (obj == userTree->viewport()) {
if (event->type() == QEvent::MouseMove) {
if (!SettingsCache::instance().interface().getStyleUserList()) {
if (!SettingsCache::instance().appearance().getStyleUserList()) {
return QGroupBox::eventFilter(obj, event);
}
auto *me = static_cast<QMouseEvent *>(event);
@ -758,6 +772,8 @@ void UserListWidget::showPopupForUser(const QString &userName)
return;
}
avatarProvider->requestAvatar(userName); // ensure the hovered user's avatar is fetched promptly
const ServerInfo_User &info = item->getUserInfo();
const bool online = item->data(0, UserListRoles::Online).toBool();
const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(userName);
@ -801,7 +817,12 @@ void UserListWidget::positionPopup(const QString &userName)
const int popH = m_userInfoPopup->height();
const int margin = 12;
const QRect screen = QGuiApplication::primaryScreen()->availableGeometry();
QScreen *activeScreen = QGuiApplication::screenAt(itemTL);
if (!activeScreen) {
activeScreen = window()->screen();
}
const QRect screen =
activeScreen ? activeScreen->availableGeometry() : QGuiApplication::primaryScreen()->availableGeometry();
// ── X: prefer the side with more space ───────────────────────────────────
const int spaceLeft = vpTL.x() - screen.left() - margin;
@ -875,6 +896,38 @@ void UserListWidget::retranslateUi()
updateCount();
}
void UserListWidget::beginBulkLoad()
{
m_bulkLoading = true;
}
void UserListWidget::endBulkLoad()
{
m_bulkLoading = false;
sortItems();
requestAvatarsForVisibleItems();
userTree->viewport()->update();
}
bool UserListWidget::isItemNearViewport(const UserListTWI *item) const
{
// Prefetch a full viewport of rows above and below so scrolling never shows
// an unloaded row.
const QRect nearView =
userTree->viewport()->rect().adjusted(0, -userTree->viewport()->height(), 0, userTree->viewport()->height());
return userTree->visualItemRect(item).intersects(nearView);
}
void UserListWidget::requestAvatarsForVisibleItems()
{
for (int i = 0; i < userTree->topLevelItemCount(); ++i) {
auto *twi = static_cast<UserListTWI *>(userTree->topLevelItem(i));
if (isItemNearViewport(twi)) {
avatarProvider->requestAvatar(QString::fromStdString(twi->getUserInfo().name()));
}
}
}
void UserListWidget::rebuild()
{
userTree->clear();
@ -901,11 +954,11 @@ void UserListWidget::rebuild()
break;
}
beginBulkLoad();
for (auto it = source->cbegin(); it != source->cend(); ++it) {
processUserInfo(it.value(), manager->getOnlineUser(it.key()) != nullptr);
}
sortItems();
endBulkLoad();
}
void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online)
@ -940,11 +993,15 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online)
++onlineCount;
}
updateCount();
avatarProvider->requestAvatar(userName);
if (!m_bulkLoading && isItemNearViewport(item)) {
avatarProvider->requestAvatar(userName);
}
}
item->setOnline(online);
sortItems();
userTree->viewport()->update();
if (!m_bulkLoading) {
sortItems();
userTree->viewport()->update();
}
}
bool UserListWidget::deleteUser(const QString &userName)

View file

@ -36,6 +36,7 @@ class QPlainTextEdit;
class Response;
class CommandContainer;
class UserContextMenu;
class QShowEvent;
class BanDialog : public QDialog
{
@ -158,11 +159,14 @@ private:
QTimer *m_hidePopupTimer = nullptr;
QString m_hoveredUser;
bool m_popupPinned = false;
bool m_bulkLoading = false;
void showPopupForUser(const QString &userName);
void hidePopup(bool immediate = false);
void positionPopup(const QString &userName);
void connectPopupSignals();
bool isItemNearViewport(const UserListTWI *item) const;
void requestAvatarsForVisibleItems();
QMap<QString, UserListTWI *> users;
TabSupervisor *tabSupervisor;
@ -177,6 +181,7 @@ private:
void refreshPopupButtons(const QString &userName);
private slots:
void userClicked(QTreeWidgetItem *item, int column);
void refreshVisibleUserHeader(const QString &name);
signals:
void openMessageDialog(const QString &userName, bool focus);
void addBuddy(const QString &userName);
@ -192,6 +197,8 @@ public:
QWidget *parent = nullptr);
void bind(UserListManager *mgr);
void applyDisplayMode();
void beginBulkLoad();
void endBulkLoad();
bool eventFilter(QObject *obj, QEvent *event) override;
void retranslateUi();
void rebuild();
@ -207,6 +214,7 @@ public:
protected:
void hideEvent(QHideEvent *e) override;
void showEvent(QShowEvent *e) override;
};
#endif

View file

@ -0,0 +1,196 @@
#include "abstract_settings_page.h"
#include "settings_search_model.h"
#include <QCheckBox>
#include <QGridLayout>
#include <QGroupBox>
#include <QLabel>
#include <QLayout>
#include <QLineEdit>
#include <QPair>
#include <QSpinBox>
/**
* @brief Recursively collects all widgets within a layout
* @param layout The layout to walk
* @param widgets Output list of (widget, containing layout) pairs
*/
static void collectWidgets(QLayout *layout, QList<QPair<QWidget *, QLayout *>> &widgets)
{
for (int i = 0; i < layout->count(); ++i) {
QLayoutItem *item = layout->itemAt(i);
if (!item) {
continue;
}
if (QWidget *widget = item->widget()) {
widgets.append({widget, layout});
} else if (QLayout *subLayout = item->layout()) {
collectWidgets(subLayout, widgets);
}
}
}
/**
* @brief Rejects QLabels that are not setting names
*
* HTML link labels, path values, and excessively long labels are filtered out.
*/
static bool isValidSettingLabel(const QLabel *label)
{
const QString &text = label->text();
if (Qt::mightBeRichText(text)) {
return false;
}
if (text.contains(QLatin1Char('/')) || text.contains(QLatin1Char('\\'))) {
return false;
}
if (text.size() > 60) {
return false;
}
return true;
}
/**
* @brief Finds the control associated with a setting label
*
* Uses the explicit buddy if set, otherwise the widget in the cell (or slot)
* immediately following the label within the same layout. Returns nullptr when
* no obvious control is found.
*/
static QWidget *controlForLabel(QLabel *label, QLayout *containingLayout)
{
if (QWidget *buddy = label->buddy()) {
return buddy;
}
if (auto *grid = qobject_cast<QGridLayout *>(containingLayout)) {
int index = grid->indexOf(label);
if (index != -1) {
int row = 0;
int column = 0;
int rowSpan = 1;
int columnSpan = 1;
grid->getItemPosition(index, &row, &column, &rowSpan, &columnSpan);
if (QLayoutItem *next = grid->itemAtPosition(row, column + columnSpan)) {
if (QWidget *nextWidget = next->widget()) {
return nextWidget;
}
}
}
} else {
int index = containingLayout->indexOf(label);
if (index != -1) {
for (int i = index + 1; i < containingLayout->count(); ++i) {
if (QLayoutItem *next = containingLayout->itemAt(i)) {
if (QWidget *nextWidget = next->widget()) {
return nextWidget;
}
}
}
}
}
return nullptr;
}
/**
* @brief Builds the extended search text for an entry
*
* Combines the group title, label, and any extra searchable text derived from
* the associated control (placeholder, prefix/suffix, tooltip). Combo values
* and numeric tooltips are excluded since they change at runtime and would
* make the search index stale.
*/
static QString buildFullSearchText(const QString &groupTitle, const QString &cleanLabel, QWidget *control)
{
QStringList parts = {groupTitle, cleanLabel};
if (control) {
if (auto *lineEdit = qobject_cast<QLineEdit *>(control)) {
parts.append(lineEdit->placeholderText());
} else if (auto *spinBox = qobject_cast<QSpinBox *>(control)) {
parts.append(spinBox->prefix());
parts.append(spinBox->suffix());
}
if (!control->toolTip().isEmpty()) {
bool isNumeric = false;
control->toolTip().toInt(&isNumeric);
if (!isNumeric) {
parts.append(control->toolTip());
}
}
}
parts.removeAll(QString());
return parts.join(QLatin1Char(' '));
}
QList<SettingsSearchEntry> AbstractSettingsPage::getSearchEntries()
{
return autoDetectSearchEntries(this, -1);
}
QList<SettingsSearchEntry> AbstractSettingsPage::autoDetectSearchEntries(QWidget *page, int pageIndex)
{
QList<SettingsSearchEntry> entries;
const auto children = page->children();
for (QObject *child : children) {
auto *groupBox = qobject_cast<QGroupBox *>(child);
if (!groupBox) {
continue;
}
QString groupTitle = groupBox->title();
if (groupTitle.isEmpty()) {
continue;
}
QLayout *groupLayout = groupBox->layout();
if (!groupLayout) {
continue;
}
QList<QPair<QWidget *, QLayout *>> widgets;
collectWidgets(groupLayout, widgets);
for (const auto &pair : widgets) {
QWidget *widget = pair.first;
QString label;
auto *checkBox = qobject_cast<QCheckBox *>(widget);
if (checkBox) {
label = checkBox->text();
} else {
auto *labelWidget = qobject_cast<QLabel *>(widget);
if (!labelWidget || labelWidget->text().isEmpty() || !isValidSettingLabel(labelWidget)) {
continue;
}
label = labelWidget->text();
}
if (label.isEmpty()) {
continue;
}
// Strip accelerator markers (&) for search
QString cleanLabel = label;
cleanLabel.remove(QLatin1Char('&'));
QWidget *control = widget;
if (auto *labelWidget = qobject_cast<QLabel *>(widget)) {
control = controlForLabel(labelWidget, pair.second);
if (!control) {
continue;
}
}
entries.append(SettingsSearchEntry{.pageIndex = pageIndex,
.groupTitle = groupTitle,
.widgetLabel = cleanLabel,
.fullSearchText = buildFullSearchText(groupTitle, cleanLabel, control),
.widget = control});
}
}
return entries;
}

View file

@ -1,16 +1,24 @@
#ifndef COCKATRICE_ABSTRACT_SETTINGS_PAGE_H
#define COCKATRICE_ABSTRACT_SETTINGS_PAGE_H
#include <QList>
#include <QWidget>
#define WIKI_CUSTOM_PIC_URL "https://github.com/Cockatrice/Cockatrice/wiki/Custom-Picture-Download-URLs"
#define WIKI_CUSTOM_SHORTCUTS "https://github.com/Cockatrice/Cockatrice/wiki/Custom-Keyboard-Shortcuts"
#define WIKI_TRANSLATION_FAQ "https://github.com/Cockatrice/Cockatrice/wiki/Translation-FAQ"
struct SettingsSearchEntry;
class AbstractSettingsPage : public QWidget
{
Q_OBJECT
public:
virtual void retranslateUi() = 0;
virtual QList<SettingsSearchEntry> getSearchEntries();
protected:
static QList<SettingsSearchEntry> autoDetectSearchEntries(QWidget *page, int pageIndex);
};
#endif // COCKATRICE_ABSTRACT_SETTINGS_PAGE_H

View file

@ -15,6 +15,7 @@
#include <QMessageBox>
#include <QStyleFactory>
#include <QTimer>
#include <libcockatrice/settings/appearance_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/paths_settings.h>
@ -104,7 +105,7 @@ AppearanceSettingsPage::AppearanceSettingsPage()
homeTabBackgroundSourceBox.addItem(QObject::tr(entry.trKey), QVariant::fromValue(entry.type));
}
QString homeTabBackgroundSource = SettingsCache::instance().personal().getHomeTabBackgroundSource();
QString homeTabBackgroundSource = settings.appearance().getHomeTabBackgroundSource();
int homeTabBackgroundSourceId =
homeTabBackgroundSourceBox.findData(BackgroundSources::fromId(homeTabBackgroundSource));
if (homeTabBackgroundSourceId != -1) {
@ -113,20 +114,19 @@ AppearanceSettingsPage::AppearanceSettingsPage()
connect(&homeTabBackgroundSourceBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this]() {
auto type = homeTabBackgroundSourceBox.currentData().value<BackgroundSources::Type>();
SettingsCache::instance().personal().setHomeTabBackgroundSource(BackgroundSources::toId(type));
SettingsCache::instance().appearance().setHomeTabBackgroundSource(BackgroundSources::toId(type));
updateHomeTabSettingsVisibility();
});
homeTabBackgroundShuffleFrequencySpinBox.setRange(0, 3600);
homeTabBackgroundShuffleFrequencySpinBox.setSuffix(tr(" seconds"));
homeTabBackgroundShuffleFrequencySpinBox.setValue(
SettingsCache::instance().personal().getHomeTabBackgroundShuffleFrequency());
connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload<int>(&QSpinBox::valueChanged), &settings.personal(),
&PersonalSettings::setHomeTabBackgroundShuffleFrequency);
homeTabBackgroundShuffleFrequencySpinBox.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency());
connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload<int>(&QSpinBox::valueChanged), &settings.appearance(),
&AppearanceSettings::setHomeTabBackgroundShuffleFrequency);
homeTabDisplayCardNameCheckBox.setChecked(settings.personal().getHomeTabDisplayCardName());
connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.personal(),
&PersonalSettings::setHomeTabDisplayCardName);
homeTabDisplayCardNameCheckBox.setChecked(settings.appearance().getHomeTabDisplayCardName());
connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(),
&AppearanceSettings::setHomeTabDisplayCardName);
updateHomeTabSettingsVisibility();
@ -140,9 +140,9 @@ AppearanceSettingsPage::AppearanceSettingsPage()
homeTabGroupBox = new QGroupBox;
homeTabGroupBox->setLayout(homeTabGrid);
styleUserListCheckBox.setChecked(settings.interface().getStyleUserList());
connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.interface(),
&InterfaceSettings::setStyleUserList);
styleUserListCheckBox.setChecked(settings.appearance().getStyleUserList());
connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(),
&AppearanceSettings::setStyleUserList);
auto stylingTabGrid = new QGridLayout;
stylingTabGrid->addWidget(&styleUserListCheckBox, 0, 0, 1, 2);
@ -151,12 +151,12 @@ AppearanceSettingsPage::AppearanceSettingsPage()
stylingGroupBox->setLayout(stylingTabGrid);
// Menu settings
showShortcutsCheckBox.setChecked(settings.cardsDisplay().getShowShortcuts());
showShortcutsCheckBox.setChecked(settings.userInterface().getShowShortcuts());
connect(&showShortcutsCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &AppearanceSettingsPage::showShortcutsChanged);
showGameSelectorFilterToolbarCheckBox.setChecked(settings.cardsDisplay().getShowGameSelectorFilterToolbar());
connect(&showGameSelectorFilterToolbarCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.cardsDisplay(),
&CardsDisplaySettings::setShowGameSelectorFilterToolbar);
showGameSelectorFilterToolbarCheckBox.setChecked(settings.userInterface().getShowGameSelectorFilterToolbar());
connect(&showGameSelectorFilterToolbarCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(),
&InterfaceSettings::setShowGameSelectorFilterToolbar);
auto *menuGrid = new QGridLayout;
menuGrid->addWidget(&showShortcutsCheckBox, 0, 0);
@ -199,9 +199,9 @@ AppearanceSettingsPage::AppearanceSettingsPage()
connect(&roundCardCornersCheckBox, &QAbstractButton::toggled, &settings.cardsDisplay(),
&CardsDisplaySettings::setRoundCardCorners);
connect(&maxFontSizeForCardsEdit, qOverload<int>(&QSpinBox::valueChanged), &settings.personal(),
&PersonalSettings::setMaxFontSize);
maxFontSizeForCardsEdit.setValue(settings.personal().getMaxFontSize());
connect(&maxFontSizeForCardsEdit, qOverload<int>(&QSpinBox::valueChanged), &settings.appearance(),
&AppearanceSettings::setMaxFontSize);
maxFontSizeForCardsEdit.setValue(settings.appearance().getMaxFontSize());
maxFontSizeForCardsLabel.setBuddy(&maxFontSizeForCardsEdit);
maxFontSizeForCardsEdit.setMinimum(9);
maxFontSizeForCardsEdit.setMaximum(100);
@ -224,12 +224,12 @@ AppearanceSettingsPage::AppearanceSettingsPage()
&CardsDisplaySettings::setStackCardOverlapPercent);
cardViewInitialRowsMaxBox.setRange(1, 999);
cardViewInitialRowsMaxBox.setValue(SettingsCache::instance().interface().getCardViewInitialRowsMax());
cardViewInitialRowsMaxBox.setValue(SettingsCache::instance().userInterface().getCardViewInitialRowsMax());
connect(&cardViewInitialRowsMaxBox, qOverload<int>(&QSpinBox::valueChanged), this,
&AppearanceSettingsPage::cardViewInitialRowsMaxChanged);
cardViewExpandedRowsMaxBox.setRange(1, 999);
cardViewExpandedRowsMaxBox.setValue(SettingsCache::instance().interface().getCardViewExpandedRowsMax());
cardViewExpandedRowsMaxBox.setValue(SettingsCache::instance().userInterface().getCardViewExpandedRowsMax());
connect(&cardViewExpandedRowsMaxBox, qOverload<int>(&QSpinBox::valueChanged), this,
&AppearanceSettingsPage::cardViewExpandedRowsMaxChanged);
@ -291,12 +291,12 @@ AppearanceSettingsPage::AppearanceSettingsPage()
cardCountersGroupBox->setLayout(cardCountersLayout);
// Hand layout
horizontalHandCheckBox.setChecked(settings.interface().getHorizontalHand());
connect(&horizontalHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.interface(),
horizontalHandCheckBox.setChecked(settings.userInterface().getHorizontalHand());
connect(&horizontalHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(),
&InterfaceSettings::setHorizontalHand);
leftJustifiedHandCheckBox.setChecked(settings.interface().getLeftJustified());
connect(&leftJustifiedHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.interface(),
leftJustifiedHandCheckBox.setChecked(settings.userInterface().getLeftJustified());
connect(&leftJustifiedHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(),
&InterfaceSettings::setLeftJustified);
auto *handGrid = new QGridLayout;
@ -307,13 +307,13 @@ AppearanceSettingsPage::AppearanceSettingsPage()
handGroupBox->setLayout(handGrid);
// table grid layout
invertVerticalCoordinateCheckBox.setChecked(settings.interface().getInvertVerticalCoordinate());
connect(&invertVerticalCoordinateCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.interface(),
invertVerticalCoordinateCheckBox.setChecked(settings.userInterface().getInvertVerticalCoordinate());
connect(&invertVerticalCoordinateCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(),
&InterfaceSettings::setInvertVerticalCoordinate);
minPlayersForMultiColumnLayoutEdit.setMinimum(2);
minPlayersForMultiColumnLayoutEdit.setValue(settings.interface().getMinPlayersForMultiColumnLayout());
connect(&minPlayersForMultiColumnLayoutEdit, qOverload<int>(&QSpinBox::valueChanged), &settings.interface(),
minPlayersForMultiColumnLayoutEdit.setValue(settings.userInterface().getMinPlayersForMultiColumnLayout());
connect(&minPlayersForMultiColumnLayoutEdit, qOverload<int>(&QSpinBox::valueChanged), &settings.userInterface(),
&InterfaceSettings::setMinPlayersForMultiColumnLayout);
minPlayersForMultiColumnLayoutLabel.setBuddy(&minPlayersForMultiColumnLayoutEdit);
@ -375,7 +375,7 @@ void AppearanceSettingsPage::editPalette()
void AppearanceSettingsPage::updateHomeTabSettingsVisibility()
{
bool visible = SettingsCache::instance().personal().getHomeTabBackgroundSource() !=
bool visible = SettingsCache::instance().appearance().getHomeTabBackgroundSource() !=
BackgroundSources::toId(BackgroundSources::Theme);
homeTabBackgroundShuffleFrequencyLabel.setVisible(visible);
@ -385,7 +385,7 @@ void AppearanceSettingsPage::updateHomeTabSettingsVisibility()
void AppearanceSettingsPage::showShortcutsChanged(QT_STATE_CHANGED_T value)
{
SettingsCache::instance().cardsDisplay().setShowShortcuts(value);
SettingsCache::instance().userInterface().setShowShortcuts(value);
qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus, value == 0); // 0 = unchecked
}
@ -412,7 +412,7 @@ void AppearanceSettingsPage::overrideAllCardArtWithPersonalPreferenceToggled(QT_
*/
void AppearanceSettingsPage::cardViewInitialRowsMaxChanged(int value)
{
SettingsCache::instance().interface().setCardViewInitialRowsMax(value);
SettingsCache::instance().userInterface().setCardViewInitialRowsMax(value);
if (cardViewExpandedRowsMaxBox.value() < value) {
cardViewExpandedRowsMaxBox.setValue(value);
}
@ -425,7 +425,7 @@ void AppearanceSettingsPage::cardViewInitialRowsMaxChanged(int value)
*/
void AppearanceSettingsPage::cardViewExpandedRowsMaxChanged(int value)
{
SettingsCache::instance().interface().setCardViewExpandedRowsMax(value);
SettingsCache::instance().userInterface().setCardViewExpandedRowsMax(value);
if (cardViewInitialRowsMaxBox.value() > value) {
cardViewInitialRowsMaxBox.setValue(value);
}

View file

@ -40,10 +40,10 @@ private:
QSpinBox homeTabBackgroundShuffleFrequencySpinBox;
QCheckBox homeTabDisplayCardNameCheckBox;
QCheckBox styleUserListCheckBox;
QLabel minPlayersForMultiColumnLayoutLabel;
QLabel maxFontSizeForCardsLabel;
QCheckBox showShortcutsCheckBox;
QCheckBox showGameSelectorFilterToolbarCheckBox;
QLabel minPlayersForMultiColumnLayoutLabel;
QLabel maxFontSizeForCardsLabel;
QCheckBox overrideAllCardArtWithPersonalPreferenceCheckBox;
QCheckBox bumpSetsWithCardsInDeckToTopCheckBox;
QCheckBox displayCardNamesCheckBox;

View file

@ -17,9 +17,9 @@
DeckEditorSettingsPage::DeckEditorSettingsPage()
{
picDownloadCheckBox.setChecked(SettingsCache::instance().personal().getPicDownload());
connect(&picDownloadCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().personal(),
&PersonalSettings::setPicDownload);
picDownloadCheckBox.setChecked(SettingsCache::instance().downloads().getPicDownload());
connect(&picDownloadCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().downloads(),
&DownloadSettings::setPicDownload);
urlLinkLabel.setTextInteractionFlags(Qt::LinksAccessibleByMouse);
urlLinkLabel.setOpenExternalLinks(true);
@ -29,7 +29,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
auto *lpGeneralGrid = new QGridLayout;
auto *lpSpoilerGrid = new QGridLayout;
mcDownloadSpoilersCheckBox.setChecked(SettingsCache::instance().personal().getDownloadSpoilersStatus());
mcDownloadSpoilersCheckBox.setChecked(SettingsCache::instance().downloads().getDownloadSpoilersStatus());
mpSpoilerSavePathLineEdit = new QLineEdit(SettingsCache::instance().getSpoilerCardDatabasePath());
mpSpoilerSavePathLineEdit->setReadOnly(true);
@ -91,8 +91,8 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
lpSpoilerGrid->addWidget(&infoOnSpoilersLabel, 3, 0, 1, 3, Qt::AlignTop);
// On a change to the checkbox, hide/un-hide the other fields
connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, &SettingsCache::instance().personal(),
&PersonalSettings::setDownloadSpoilerStatus);
connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, &SettingsCache::instance().downloads(),
&DownloadSettings::setDownloadSpoilerStatus);
connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, this, &DeckEditorSettingsPage::setSpoilersEnabled);
mpGeneralGroupBox = new QGroupBox;

View file

@ -90,6 +90,7 @@ MessagesSettingsPage::MessagesSettingsPage()
highlightNotice->addWidget(&hexHighlightLabel, 1, 2);
highlightNotice->addWidget(customAlertString, 0, 0);
highlightNotice->addWidget(&customAlertStringLabel, 1, 0);
customAlertStringLabel.setBuddy(customAlertString);
highlightGroupBox = new QGroupBox;
highlightGroupBox->setLayout(highlightNotice);

View file

@ -0,0 +1,125 @@
/**
* @file settings_search_delegate.cpp
* @brief Implementation of the custom settings search result delegate
* @ingroup Dialogs
*/
#include "settings_search_delegate.h"
#include "settings_search_model.h"
#include <QPainter>
SettingsSearchDelegate::SettingsSearchDelegate(QObject *parent) : QStyledItemDelegate(parent)
{
}
void SettingsSearchDelegate::setPageNames(const QStringList &names)
{
pageNames = names;
}
void SettingsSearchDelegate::setPageIcons(const QStringList &iconResources)
{
pageIcons.clear();
for (const QString &resource : iconResources) {
pageIcons.append(QPixmap(resource));
}
}
void SettingsSearchDelegate::paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
painter->save();
SettingsSearchEntry entry = index.data(SettingsSearchModel::EntryRole).value<SettingsSearchEntry>();
bool isSelected = option.state & QStyle::State_Selected;
bool isHovered = option.state & QStyle::State_MouseOver;
// Background
QColor bgColor = isSelected ? option.palette.color(QPalette::Highlight)
: isHovered ? option.palette.color(QPalette::Midlight)
: option.palette.color(QPalette::Base);
painter->fillRect(option.rect, bgColor);
if (isSelected) {
// Accent bar on the left to make the selection unmistakable
painter->fillRect(QRect(option.rect.left(), option.rect.top(), 4, option.rect.height()),
option.palette.color(QPalette::Highlight).darker(150));
}
int leftMargin = 12;
int topMargin = 8;
int rightMargin = 12;
int bottomMargin = 4;
QRect contentRect = option.rect.adjusted(leftMargin, topMargin, -rightMargin, -bottomMargin);
int yPos = contentRect.top();
// Icon of the related settings page
const int iconSize = 24;
QPixmap pageIcon =
(entry.pageIndex >= 0 && entry.pageIndex < pageIcons.size()) ? pageIcons.at(entry.pageIndex) : QPixmap();
int iconOffset = pageIcon.isNull() ? 0 : iconSize + 8;
if (!pageIcon.isNull()) {
QRect iconRect(contentRect.left(), contentRect.top() + (contentRect.height() - iconSize) / 2, iconSize,
iconSize);
painter->drawPixmap(iconRect, pageIcon);
}
QRect textRect = contentRect.adjusted(iconOffset, 0, 0, 0);
// Breadcrumb: "Page > Group"
QFont breadcrumbFont = option.font;
breadcrumbFont.setPointSize(breadcrumbFont.pointSize() - 1);
breadcrumbFont.setBold(true);
QColor breadcrumbColor =
isSelected ? option.palette.color(QPalette::HighlightedText) : option.palette.color(QPalette::Text);
if (!isSelected) {
breadcrumbColor.setAlpha(180);
}
QString pageName;
if (entry.pageIndex >= 0 && entry.pageIndex < pageNames.size()) {
pageName = pageNames[entry.pageIndex];
} else {
pageName = QString::number(entry.pageIndex);
}
QString breadcrumbText = QStringLiteral("%1 > %2").arg(pageName, entry.groupTitle);
painter->setFont(breadcrumbFont);
painter->setPen(breadcrumbColor);
painter->drawText(QRect(textRect.left(), yPos, textRect.width(), 20), Qt::AlignLeft | Qt::AlignVCenter,
breadcrumbText);
yPos += 20;
// Setting label
QFont labelFont = option.font;
labelFont.setPointSize(labelFont.pointSize() + 1);
labelFont.setBold(isSelected);
QColor labelColor =
isSelected ? option.palette.color(QPalette::HighlightedText) : option.palette.color(QPalette::Text);
painter->setFont(labelFont);
painter->setPen(labelColor);
painter->drawText(QRect(textRect.left(), yPos, textRect.width(), 24), Qt::AlignLeft | Qt::AlignVCenter,
entry.widgetLabel);
yPos += 24;
// Bottom separator
QPen separatorPen(option.palette.color(QPalette::Mid), 1);
painter->setPen(separatorPen);
painter->drawLine(option.rect.left() + leftMargin, option.rect.bottom(), option.rect.right() - rightMargin,
option.rect.bottom());
painter->restore();
}
QSize SettingsSearchDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
Q_UNUSED(index);
return QSize(option.rect.width(), 56);
}

View file

@ -0,0 +1,38 @@
/**
* @file settings_search_delegate.h
* @brief Custom delegate for rendering settings search results
* @ingroup Dialogs
*/
#ifndef COCKATRICE_SETTINGS_SEARCH_DELEGATE_H
#define COCKATRICE_SETTINGS_SEARCH_DELEGATE_H
#include <QPixmap>
#include <QStyledItemDelegate>
/**
* @brief Custom paint delegate for settings search result items
*
* Renders each search result with a breadcrumb line ("Page > Group"),
* the setting label, and a subtle separator. Supports selected/hovered states.
*/
class SettingsSearchDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
explicit SettingsSearchDelegate(QObject *parent = nullptr);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
/** @brief Sets the translated page names for breadcrumb display */
void setPageNames(const QStringList &names);
/** @brief Sets the icons shown in front of results, indexed by page position */
void setPageIcons(const QStringList &iconResources);
private:
QStringList pageNames; ///< Translated page names indexed by page position
QList<QPixmap> pageIcons; ///< Icons of the related settings pages
};
#endif // COCKATRICE_SETTINGS_SEARCH_DELEGATE_H

View file

@ -0,0 +1,145 @@
/**
* @file settings_search_model.cpp
* @brief Implementation of the settings search list model
* @ingroup Dialogs
*/
#include "settings_search_model.h"
#include <QPair>
#include <algorithm>
SettingsSearchModel::SettingsSearchModel(QObject *parent) : QAbstractListModel(parent)
{
}
void SettingsSearchModel::setSourceEntries(const QList<SettingsSearchEntry> &entries)
{
beginResetModel();
sourceEntries = entries;
endResetModel();
rebuildFilter();
}
void SettingsSearchModel::setFilterString(const QString &text)
{
filterActive = !text.trimmed().isEmpty();
if (filterActive) {
filterQuery = text.trimmed();
filterRegex =
QRegularExpression(QRegularExpression::escape(filterQuery), QRegularExpression::CaseInsensitiveOption);
}
rebuildFilter();
}
bool SettingsSearchModel::isFilterActive() const
{
return filterActive;
}
/**
* @brief Calculates a relevance score for a single entry against the query
*
* Scoring priorities (highest to lowest):
* 1. Label starts with query -> 100
* 2. Label contains query -> 80
* 3. Group title starts with query -> 60
* 4. Group title contains query -> 40
* 5. Full text regex match -> 20
* 6. No match -> 0 (excluded from results)
*/
static int relevanceScore(const SettingsSearchEntry &entry, const QString &query, const QRegularExpression &regex)
{
QString lowerQuery = query.toLower();
// Label matches are most relevant
QString label = entry.widgetLabel.toLower();
if (label.startsWith(lowerQuery)) {
return 100;
}
if (label.contains(lowerQuery)) {
return 80;
}
// Group title matches are next
QString group = entry.groupTitle.toLower();
if (group.startsWith(lowerQuery)) {
return 60;
}
if (group.contains(lowerQuery)) {
return 40;
}
// Full text match is least relevant
if (entry.fullSearchText.contains(regex)) {
return 20;
}
return 0;
}
void SettingsSearchModel::rebuildFilter()
{
beginResetModel();
filteredIndices.clear();
if (!filterActive) {
for (int i = 0; i < sourceEntries.size(); ++i) {
filteredIndices.append(i);
}
} else {
QList<QPair<int, int>> scored; // <score, index>
for (int i = 0; i < sourceEntries.size(); ++i) {
const SettingsSearchEntry &entry = sourceEntries[i];
// Skip conditional settings that are currently disabled or hidden
if (entry.widget && (!entry.widget->isEnabled() || entry.widget->isHidden())) {
continue;
}
int score = relevanceScore(entry, filterQuery, filterRegex);
if (score > 0) {
scored.append({-score, i}); // negative for descending sort
}
}
std::sort(scored.begin(), scored.end());
for (const auto &pair : scored) {
filteredIndices.append(pair.second);
}
}
endResetModel();
}
int SettingsSearchModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return filteredIndices.size();
}
QVariant SettingsSearchModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= filteredIndices.size()) {
return {};
}
const SettingsSearchEntry &entry = sourceEntries[filteredIndices[index.row()]];
switch (role) {
case EntryRole:
return QVariant::fromValue(entry);
case Qt::DisplayRole:
return entry.widgetLabel;
case Qt::ToolTipRole:
return QStringLiteral("%1 > %2 > %3")
.arg(QString::number(entry.pageIndex), entry.groupTitle, entry.widgetLabel);
default:
return {};
}
}
SettingsSearchEntry SettingsSearchModel::entryForIndex(const QModelIndex &index) const
{
if (!index.isValid() || index.row() >= filteredIndices.size()) {
return {};
}
return sourceEntries[filteredIndices[index.row()]];
}

View file

@ -0,0 +1,76 @@
/**
* @file settings_search_model.h
* @brief Data model for the settings search feature
* @ingroup Dialogs
*/
#ifndef COCKATRICE_SETTINGS_SEARCH_MODEL_H
#define COCKATRICE_SETTINGS_SEARCH_MODEL_H
#include <QAbstractListModel>
#include <QList>
#include <QRegularExpression>
#include <QWidget>
/**
* @brief Represents a single searchable setting entry
*
* Each settings page provides a list of these entries via getSearchEntries().
* The model uses them for filtering, relevance scoring, and display.
*/
struct SettingsSearchEntry
{
int pageIndex; ///< Index of the settings page this entry belongs to
QString groupTitle; ///< Title of the group/section within the page
QString widgetLabel; ///< Display label for the setting widget
QString fullSearchText; ///< Extended search text (label, control text, tooltip) for full-text matching
QWidget *widget; ///< Pointer to the setting widget for focus/scrolling
};
/**
* @brief List model providing filtered, ranked search results
*
* Manages a list of SettingsSearchEntry items. When a filter string is set,
* entries are scored by relevance and sorted so the best matches appear first.
* Supports custom roles for accessing entry fields from views and delegates.
*/
class SettingsSearchModel : public QAbstractListModel
{
Q_OBJECT
public:
/**
* @brief Custom data role for accessing the full entry
*/
enum Roles
{
EntryRole = Qt::UserRole + 1, ///< Full SettingsSearchEntry object
};
explicit SettingsSearchModel(QObject *parent = nullptr);
/** @brief Replaces the source entries and rebuilds the filter */
void setSourceEntries(const QList<SettingsSearchEntry> &entries);
/** @brief Sets the filter string and recalculates the results */
void setFilterString(const QString &text);
/** @brief Whether a non-empty filter is currently active */
bool isFilterActive() const;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/** @brief Returns the full entry for a given model index */
SettingsSearchEntry entryForIndex(const QModelIndex &index) const;
private:
QList<SettingsSearchEntry> sourceEntries; ///< Complete unfiltered entry list
QList<int> filteredIndices; ///< Indices into sourceEntries matching the filter
QRegularExpression filterRegex; ///< Compiled regex for the current filter
QString filterQuery; ///< Current filter query string
bool filterActive = false; ///< Whether filtering is active
/** @brief Recalculates the filtered index list and ranking */
void rebuildFilter();
};
Q_DECLARE_METATYPE(SettingsSearchEntry)
#endif // COCKATRICE_SETTINGS_SEARCH_MODEL_H

View file

@ -123,6 +123,7 @@ void ShortcutSettingsPage::retranslateUi()
currentActionGroupLabel->setText(tr("Section:"));
currentActionLabel->setText(tr("Action:"));
currentShortcutLabel->setText(tr("Shortcut:"));
editShortcutGroupBox->setTitle(tr("Shortcut editor"));
editTextBox->retranslateUi();
faqLabel->setText(QString("<a href='%1'>%2</a>").arg(WIKI_CUSTOM_SHORTCUTS).arg(tr("How to set custom shortcuts")));
btnResetAll->setText(tr("Restore all default shortcuts"));

View file

@ -2,9 +2,11 @@
#include "../../../client/settings/cache_settings.h"
#include "../interface/widgets/tabs/tab_supervisor.h"
#include "../tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h"
#include <QGridLayout>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
@ -19,70 +21,70 @@ enum visualDeckStoragePromptForConversionIndex
UserInterfaceSettingsPage::UserInterfaceSettingsPage()
{
// general settings and notification settings
notificationsEnabledCheckBox.setChecked(SettingsCache::instance().interface().getNotificationsEnabled());
connect(&notificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
notificationsEnabledCheckBox.setChecked(SettingsCache::instance().userInterface().getNotificationsEnabled());
connect(&notificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setNotificationsEnabled);
connect(&notificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
&UserInterfaceSettingsPage::setNotificationEnabled);
specNotificationsEnabledCheckBox.setChecked(
SettingsCache::instance().interface().getSpectatorNotificationsEnabled());
specNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().interface().getNotificationsEnabled());
connect(&specNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
SettingsCache::instance().userInterface().getSpectatorNotificationsEnabled());
specNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().userInterface().getNotificationsEnabled());
connect(&specNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setSpectatorNotificationsEnabled);
buddyConnectNotificationsEnabledCheckBox.setChecked(
SettingsCache::instance().interface().getBuddyConnectNotificationsEnabled());
SettingsCache::instance().userInterface().getBuddyConnectNotificationsEnabled());
buddyConnectNotificationsEnabledCheckBox.setEnabled(
SettingsCache::instance().interface().getNotificationsEnabled());
SettingsCache::instance().userInterface().getNotificationsEnabled());
connect(&buddyConnectNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED,
&SettingsCache::instance().interface(), &InterfaceSettings::setBuddyConnectNotificationsEnabled);
&SettingsCache::instance().userInterface(), &InterfaceSettings::setBuddyConnectNotificationsEnabled);
doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().interface().getDoubleClickToPlay());
connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().userInterface().getDoubleClickToPlay());
connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setDoubleClickToPlay);
clickPlaysAllSelectedCheckBox.setChecked(SettingsCache::instance().interface().getClickPlaysAllSelected());
connect(&clickPlaysAllSelectedCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
clickPlaysAllSelectedCheckBox.setChecked(SettingsCache::instance().userInterface().getClickPlaysAllSelected());
connect(&clickPlaysAllSelectedCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setClickPlaysAllSelected);
playToStackCheckBox.setChecked(SettingsCache::instance().interface().getPlayToStack());
connect(&playToStackCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
playToStackCheckBox.setChecked(SettingsCache::instance().userInterface().getPlayToStack());
connect(&playToStackCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setPlayToStack);
doNotDeleteArrowsInSubPhasesCheckBox.setChecked(
SettingsCache::instance().interface().getDoNotDeleteArrowsInSubPhases());
connect(&doNotDeleteArrowsInSubPhasesCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
&InterfaceSettings::setDoNotDeleteArrowsInSubPhases);
SettingsCache::instance().userInterface().getDoNotDeleteArrowsInSubPhases());
connect(&doNotDeleteArrowsInSubPhasesCheckBox, &QCheckBox::QT_STATE_CHANGED,
&SettingsCache::instance().userInterface(), &InterfaceSettings::setDoNotDeleteArrowsInSubPhases);
closeEmptyCardViewCheckBox.setChecked(SettingsCache::instance().interface().getCloseEmptyCardView());
connect(&closeEmptyCardViewCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
closeEmptyCardViewCheckBox.setChecked(SettingsCache::instance().userInterface().getCloseEmptyCardView());
connect(&closeEmptyCardViewCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setCloseEmptyCardView);
focusCardViewSearchBarCheckBox.setChecked(SettingsCache::instance().interface().getFocusCardViewSearchBar());
connect(&focusCardViewSearchBarCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
focusCardViewSearchBarCheckBox.setChecked(SettingsCache::instance().userInterface().getFocusCardViewSearchBar());
connect(&focusCardViewSearchBarCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setFocusCardViewSearchBar);
annotateTokensCheckBox.setChecked(SettingsCache::instance().interface().getAnnotateTokens());
connect(&annotateTokensCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
annotateTokensCheckBox.setChecked(SettingsCache::instance().userInterface().getAnnotateTokens());
connect(&annotateTokensCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setAnnotateTokens);
showDragSelectionCountCheckBox.setChecked(SettingsCache::instance().interface().getShowDragSelectionCount());
connect(&showDragSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
showDragSelectionCountCheckBox.setChecked(SettingsCache::instance().userInterface().getShowDragSelectionCount());
connect(&showDragSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setShowDragSelectionCount);
showTotalSelectionCountCheckBox.setChecked(SettingsCache::instance().interface().getShowTotalSelectionCount());
connect(&showTotalSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
showTotalSelectionCountCheckBox.setChecked(SettingsCache::instance().userInterface().getShowTotalSelectionCount());
connect(&showTotalSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setShowTotalSelectionCount);
useTearOffMenusCheckBox.setChecked(SettingsCache::instance().interface().getUseTearOffMenus());
connect(&useTearOffMenusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
useTearOffMenusCheckBox.setChecked(SettingsCache::instance().userInterface().getUseTearOffMenus());
connect(&useTearOffMenusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
[](const QT_STATE_CHANGED_T state) {
SettingsCache::instance().interface().setUseTearOffMenus(state == Qt::Checked);
SettingsCache::instance().userInterface().setUseTearOffMenus(state == Qt::Checked);
});
keepGameChatFocusCheckBox.setChecked(SettingsCache::instance().interface().getKeepGameChatFocus());
connect(&keepGameChatFocusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
keepGameChatFocusCheckBox.setChecked(SettingsCache::instance().userInterface().getKeepGameChatFocus());
connect(&keepGameChatFocusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setKeepGameChatFocus);
auto *generalGrid = new QGridLayout;
@ -121,9 +123,9 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
animationGroupBox->setLayout(animationGrid);
// deck editor settings
openDeckInNewTabCheckBox.setChecked(SettingsCache::instance().interface().getOpenDeckInNewTab());
connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().interface(),
&InterfaceSettings::setOpenDeckInNewTab);
openDeckInNewTabCheckBox.setChecked(SettingsCache::instance().deckEditor().getOpenDeckInNewTab());
connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(),
&DeckEditorSettings::setOpenDeckInNewTab);
visualDeckStorageInGameCheckBox.setChecked(
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageInGame());
@ -156,10 +158,60 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
defaultDeckEditorTypeSelector.addItem(""); // these will be set in retranslateUI
defaultDeckEditorTypeSelector.addItem("");
defaultDeckEditorTypeSelector.setCurrentIndex(
SettingsCache::instance().visualDeckStorage().getDefaultDeckEditorType());
defaultDeckEditorTypeSelector.setCurrentIndex(SettingsCache::instance().deckEditor().getDefaultDeckEditorType());
connect(&defaultDeckEditorTypeSelector, QOverload<int>::of(&QComboBox::currentIndexChanged),
&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::setDefaultDeckEditorType);
&SettingsCache::instance().deckEditor(), &DeckEditorSettings::setDefaultDeckEditorType);
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setText("?");
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setAutoRaise(true);
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setEnabled(false);
// Add items with userData = internal enum
commanderSpellbookIntegrationEnabledSelector.addItem(tr("Disabled"),
commanderSpellbookIntegrationEnabledIndexDisabled);
commanderSpellbookIntegrationEnabledSelector.addItem(tr("Enabled"),
commanderSpellbookIntegrationEnabledIndexEnabled);
commanderSpellbookIntegrationEnabledSelector.addItem(tr("Automatic"),
commanderSpellbookIntegrationEnabledIndexAutomatic);
int storedMode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled();
for (int i = 0; i < commanderSpellbookIntegrationEnabledSelector.count(); ++i) {
if (commanderSpellbookIntegrationEnabledSelector.itemData(i).toInt() == storedMode) {
commanderSpellbookIntegrationEnabledSelector.setCurrentIndex(i);
break;
}
}
connect(&commanderSpellbookIntegrationEnabledSelector, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
[this](int index) {
int mode = commanderSpellbookIntegrationEnabledSelector.itemData(index).toInt();
SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled(mode);
updateCommanderSpellbookUiState();
});
commanderSpellbookIntegrationBracketNamingSelector.addItem(
tr("CommanderSpellbook bracket names")); // index 0 = false
commanderSpellbookIntegrationBracketNamingSelector.addItem(
tr("Official Commander bracket names (approximate)")); // index 1 = true
commanderSpellbookIntegrationBracketNamingSelector.setCurrentIndex(
SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames() ? 1 : 0);
connect(&commanderSpellbookIntegrationBracketNamingSelector, QOverload<int>::of(&QComboBox::currentIndexChanged),
&SettingsCache::instance(), [](int index) {
SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationUseOfficialBracketNames(index ==
1);
});
updateCommanderSpellbookUiState();
auto *labelLayout = new QHBoxLayout;
labelLayout->setContentsMargins(0, 0, 0, 0);
labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesLabel);
labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesExplainer);
auto *labelWidget = new QWidget;
labelWidget->setLayout(labelLayout);
auto *deckEditorGrid = new QGridLayout;
deckEditorGrid->addWidget(&openDeckInNewTabCheckBox, 0, 0);
@ -169,14 +221,18 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 3, 1);
deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 4, 0);
deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 4, 1);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 5, 0);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 5, 1);
deckEditorGrid->addWidget(labelWidget, 6, 0);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 6, 1);
deckEditorGroupBox = new QGroupBox;
deckEditorGroupBox->setLayout(deckEditorGrid);
// replay settings
rewindBufferingMsBox.setRange(0, 9999);
rewindBufferingMsBox.setValue(SettingsCache::instance().interface().getRewindBufferingMs());
connect(&rewindBufferingMsBox, qOverload<int>(&QSpinBox::valueChanged), &SettingsCache::instance().interface(),
rewindBufferingMsBox.setValue(SettingsCache::instance().userInterface().getRewindBufferingMs());
connect(&rewindBufferingMsBox, qOverload<int>(&QSpinBox::valueChanged), &SettingsCache::instance().userInterface(),
&InterfaceSettings::setRewindBufferingMs);
auto *replayGrid = new QGridLayout;
@ -212,6 +268,27 @@ void UserInterfaceSettingsPage::setNotificationEnabled(QT_STATE_CHANGED_T i)
}
}
void UserInterfaceSettingsPage::updateCommanderSpellbookUiState()
{
const int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled();
const bool enabled = mode != commanderSpellbookIntegrationEnabledIndexDisabled &&
mode != commanderSpellbookIntegrationEnabledIndexUnprompted;
commanderSpellbookIntegrationBracketNamingSelector.setEnabled(enabled);
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setEnabled(enabled);
commanderSpellbookIntegrationUseOfficialBracketNamesLabel.setVisible(enabled);
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setVisible(enabled);
commanderSpellbookIntegrationBracketNamingSelector.setVisible(enabled);
if (enabled) {
// Sync selector with the current stored bool
const bool useOfficial =
SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames();
commanderSpellbookIntegrationBracketNamingSelector.setCurrentIndex(useOfficial ? 1 : 0);
}
}
void UserInterfaceSettingsPage::retranslateUi()
{
generalGroupBox->setTitle(tr("General interface settings"));
@ -249,6 +326,22 @@ void UserInterfaceSettingsPage::retranslateUi()
defaultDeckEditorTypeLabel.setText(tr("Default deck editor type"));
defaultDeckEditorTypeSelector.setItemText(TabSupervisor::ClassicDeckEditor, tr("Classic Deck Editor"));
defaultDeckEditorTypeSelector.setItemText(TabSupervisor::VisualDeckEditor, tr("Visual Deck Editor"));
commanderSpellbookIntegrationEnabledLabel.setText(
tr("CommanderSpellbook integration to estimate commander bracket"));
commanderSpellbookIntegrationEnabledSelector.setItemText(commanderSpellbookIntegrationEnabledIndexDisabled,
tr("Disabled"));
commanderSpellbookIntegrationEnabledSelector.setItemText(commanderSpellbookIntegrationEnabledIndexEnabled,
tr("Enabled"));
commanderSpellbookIntegrationEnabledSelector.setItemText(commanderSpellbookIntegrationEnabledIndexAutomatic,
tr("Automatic"));
commanderSpellbookIntegrationUseOfficialBracketNamesLabel.setText(tr("Bracket naming"));
commanderSpellbookIntegrationBracketNamingSelector.setItemText(
0, CommanderBracketNames::CommanderSpellbookBracketNames);
commanderSpellbookIntegrationBracketNamingSelector.setItemText(
1, CommanderBracketNames::OfficialCommanderBracketNames);
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setToolTip(CommanderBracketNames::Explainer);
replayGroupBox->setTitle(tr("Replay settings"));
rewindBufferingMsLabel.setText(tr("Buffer time for backwards skip via shortcut:"));
rewindBufferingMsBox.setSuffix(" ms");

View file

@ -8,6 +8,8 @@
#include <QGroupBox>
#include <QLabel>
#include <QSpinBox>
#include <QToolButton>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/utility/macros.h>
class UserInterfaceSettingsPage : public AbstractSettingsPage
@ -15,6 +17,7 @@ class UserInterfaceSettingsPage : public AbstractSettingsPage
Q_OBJECT
private slots:
void setNotificationEnabled(QT_STATE_CHANGED_T);
void updateCommanderSpellbookUiState();
private:
QCheckBox notificationsEnabledCheckBox;
@ -39,6 +42,11 @@ private:
QCheckBox visualDeckStorageSelectionAnimationCheckBox;
QLabel defaultDeckEditorTypeLabel;
QComboBox defaultDeckEditorTypeSelector;
QLabel commanderSpellbookIntegrationEnabledLabel;
QComboBox commanderSpellbookIntegrationEnabledSelector;
QLabel commanderSpellbookIntegrationUseOfficialBracketNamesLabel;
QToolButton commanderSpellbookIntegrationUseOfficialBracketNamesExplainer;
QComboBox commanderSpellbookIntegrationBracketNamingSelector;
QLabel rewindBufferingMsLabel;
QSpinBox rewindBufferingMsBox;
QGroupBox *generalGroupBox;

View file

@ -44,7 +44,7 @@
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/recents_settings.h>
#include <libcockatrice/utility/string_limits.h>
@ -203,7 +203,7 @@ void AbstractTabDeckEditor::cleanDeckAndResetModified()
*/
AbstractTabDeckEditor::DeckOpenLocation AbstractTabDeckEditor::confirmOpen(const bool openInSameTabIfBlank)
{
if (SettingsCache::instance().interface().getOpenDeckInNewTab()) {
if (SettingsCache::instance().deckEditor().getOpenDeckInNewTab()) {
if (openInSameTabIfBlank && deckStateManager->isBlankNewDeck()) {
return SAME_TAB;
} else {

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>
@ -27,7 +26,7 @@
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/models/database/card/card_completer_proxy_model.h>
#include <libcockatrice/models/database/card/card_search_model.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <version_string.h>
TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor)
@ -132,8 +131,8 @@ void TabArchidekt::initializeUi()
// Settings
settingsButton = new SettingsButtonWidget(primaryToolbar);
cardSizeSlider = new CardSizeWidget(primaryToolbar, nullptr,
SettingsCache::instance().visualDeckStorage().getArchidektPreviewSize());
cardSizeSlider =
new CardSizeWidget(primaryToolbar, nullptr, SettingsCache::instance().cardsDisplay().getArchidektPreviewSize());
settingsButton->addSettingsWidget(cardSizeSlider);
// Assemble primary toolbar
@ -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);
@ -339,8 +311,8 @@ void TabArchidekt::connectSignals()
doSearch();
});
connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setArchidektPreviewCardSize);
connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setArchidektPreviewCardSize);
// Search button triggers immediate search
connect(searchButton, &QPushButton::clicked, this, &TabArchidekt::doSearchImmediate);

View file

@ -0,0 +1,17 @@
#include "card_in_deck_request.h"
CardInDeckRequest CardInDeckRequest::fromJson(const QJsonObject &json)
{
CardInDeckRequest request;
request.card = json.value("card").toString();
request.quantity = json.value("quantity").toInt();
return request;
}
QJsonObject CardInDeckRequest::toJson() const
{
QJsonObject json;
json.insert("card", card);
json.insert("quantity", quantity);
return json;
}

View file

@ -0,0 +1,14 @@
#ifndef COCKATRICE_CARD_IN_DECK_REQUEST_H
#define COCKATRICE_CARD_IN_DECK_REQUEST_H
#include <QJsonObject>
struct CardInDeckRequest
{
static CardInDeckRequest fromJson(const QJsonObject &json);
QJsonObject toJson() const;
QString card;
int quantity;
};
#endif // COCKATRICE_CARD_IN_DECK_REQUEST_H

View file

@ -0,0 +1,26 @@
#include "commander_spellbook_card_result.h"
CommanderSpellbookCardResult CommanderSpellbookCardResult::fromJson(const QJsonObject &json)
{
CommanderSpellbookCardResult result;
result.id = json.value("id").toString();
result.name = json.value("name").toString();
result.oracleId = json.value("oracleId").toString();
result.spoiler = json.value("spoiler").toBool();
result.typeLine = json.value("typeLine").toString();
result.imageUriFrontPng = json.value("imageUriFrontPng").toString();
result.imageUriFrontLarge = json.value("imageUriFrontLarge").toString();
result.imageUriFrontNormal = json.value("imageUriFrontNormal").toString();
result.imageUriFrontSmall = json.value("imageUriFrontSmall").toString();
result.imageUriFrontArtCrop = json.value("imageUriFrontArtCrop").toString();
result.imageUriBackPng = json.value("imageUriBackPng").toString();
result.imageUriBackLarge = json.value("imageUriBackLarge").toString();
result.imageUriBackNormal = json.value("imageUriBackNormal").toString();
result.imageUriBackSmall = json.value("imageUriBackSmall").toString();
result.imageUriBackArtCrop = json.value("imageUriBackArtCrop").toString();
return result;
}

View file

@ -0,0 +1,29 @@
#ifndef COCKATRICE_COMMANDER_SPELLBOOK_CARD_RESULT_H
#define COCKATRICE_COMMANDER_SPELLBOOK_CARD_RESULT_H
#include <QJsonObject>
#include <QString>
struct CommanderSpellbookCardResult
{
static CommanderSpellbookCardResult fromJson(const QJsonObject &json);
QString id;
QString name;
QString oracleId;
bool spoiler = false;
QString typeLine;
QString imageUriFrontPng;
QString imageUriFrontLarge;
QString imageUriFrontNormal;
QString imageUriFrontSmall;
QString imageUriFrontArtCrop;
QString imageUriBackPng;
QString imageUriBackLarge;
QString imageUriBackNormal;
QString imageUriBackSmall;
QString imageUriBackArtCrop;
};
#endif // COCKATRICE_COMMANDER_SPELLBOOK_CARD_RESULT_H

View file

@ -0,0 +1,95 @@
#include "commander_spellbook_deck_request.h"
#include <QJsonArray>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
CommanderSpellbookDeckRequest CommanderSpellbookDeckRequest::fromJson(const QJsonObject &json)
{
CommanderSpellbookDeckRequest request;
// Main deck
const QJsonArray mainArray = json.value("main").toArray();
for (const QJsonValue &value : mainArray) {
if (!value.isObject()) {
continue;
}
request.mainDeck.append(CardInDeckRequest::fromJson(value.toObject()));
// Max size allowed by commanderspellbook
if (request.mainDeck.size() >= 600) {
break;
}
}
// Commanders
const QJsonArray commanderArray = json.value("commanders").toArray();
for (const QJsonValue &value : commanderArray) {
if (!value.isObject()) {
continue;
}
request.commanderDeck.append(CardInDeckRequest::fromJson(value.toObject()));
// Max size allowed by commanderspellbook
if (request.commanderDeck.size() >= 12) {
break;
}
}
return request;
}
QJsonObject CommanderSpellbookDeckRequest::toJson() const
{
QJsonObject json;
QJsonArray mainArray;
for (const CardInDeckRequest &card : mainDeck) {
mainArray.append(card.toJson());
}
QJsonArray commanderArray;
for (const CardInDeckRequest &card : commanderDeck) {
commanderArray.append(card.toJson());
}
json.insert("main", mainArray);
json.insert("commanders", commanderArray);
return json;
}
CommanderSpellbookDeckRequest CommanderSpellbookDeckRequest::fromDeckList(const DeckList &deck)
{
CommanderSpellbookDeckRequest request;
// --- Mainboard ---
const auto mainCards = deck.getCardNodes({DECK_ZONE_MAIN});
for (const DecklistCardNode *node : mainCards) {
if (!node) {
continue;
}
QJsonObject json;
json.insert("card", node->getName());
json.insert("quantity", node->getNumber());
request.mainDeck.append(CardInDeckRequest::fromJson(json));
// Max size allowed by commanderspellbook
if (request.mainDeck.size() >= 600) {
break;
}
}
// --- Commander (bannerCard) ---
const auto &metadata = deck.getMetadata();
if (!metadata.bannerCard.name.isEmpty()) {
QJsonObject json;
json.insert("card", metadata.bannerCard.name);
json.insert("quantity", 1);
request.commanderDeck.append(CardInDeckRequest::fromJson(json));
}
return request;
}

View file

@ -0,0 +1,19 @@
#ifndef COCKATRICE_COMMANDER_SPELLBOOK_DECK_REQUEST_H
#define COCKATRICE_COMMANDER_SPELLBOOK_DECK_REQUEST_H
#include "card_in_deck_request.h"
#include "libcockatrice/deck_list/deck_list.h"
#include <QJsonObject>
#include <QList>
struct CommanderSpellbookDeckRequest
{
static CommanderSpellbookDeckRequest fromJson(const QJsonObject &json);
static CommanderSpellbookDeckRequest fromDeckList(const DeckList &deck);
QJsonObject toJson() const;
QList<CardInDeckRequest> mainDeck; // maxItems: 600
QList<CardInDeckRequest> commanderDeck; // maxItems: 12
};
#endif // COCKATRICE_COMMANDER_SPELLBOOK_DECK_REQUEST_H

View file

@ -0,0 +1,93 @@
#include "commander_spellbook_estimate_bracket_result.h"
EstimateBracketResult EstimateBracketResult::fromJson(const QJsonObject &json)
{
EstimateBracketResult result;
result.bracketTag = json.value("bracketTag").toString();
//
// Cards
//
for (const auto &value : json.value("cards").toArray()) {
if (!value.isObject()) {
continue;
}
const QJsonObject obj = value.toObject();
CommanderSpellbookCardResult card = CommanderSpellbookCardResult::fromJson(obj.value("card").toObject());
if (obj.value("gameChanger").toBool()) {
result.gameChangerCards.append(card);
}
if (obj.value("massLandDenial").toBool()) {
result.massLandDenialCards.append(card);
}
if (obj.value("extraTurn").toBool()) {
result.extraTurnCards.append(card);
}
}
//
// Templates
//
for (const auto &value : json.value("templates").toArray()) {
if (!value.isObject()) {
continue;
}
const QJsonObject obj = value.toObject();
CommanderSpellbookVariantResult variant = CommanderSpellbookVariantResult::fromJson(obj);
if (obj.value("massLandDenial").toBool()) {
result.massLandDenialTemplates.append(variant);
}
if (obj.value("extraTurn").toBool()) {
result.extraTurnTemplates.append(variant);
}
}
//
// Combos
//
for (const auto &value : json.value("combos").toArray()) {
if (!value.isObject()) {
continue;
}
const QJsonObject obj = value.toObject();
CommanderSpellbookVariantResult combo = CommanderSpellbookVariantResult::fromJson(obj);
if (obj.value("massLandDenial").toBool()) {
result.massLandDenialCombos.append(combo);
}
if (obj.value("extraTurn").toBool()) {
result.extraTurnCombos.append(combo);
}
if (obj.value("lock").toBool()) {
result.lockCombos.append(combo);
}
if (obj.value("skipTurns").toBool()) {
result.skipTurnsCombos.append(combo);
}
if (obj.value("definitelyTwoCard").toBool()) {
result.definitelyTwoCardCombos.append(combo);
}
if (obj.value("arguablyTwoCard").toBool()) {
result.arguablyTwoCardCombos.append(combo);
}
}
return result;
}

View file

@ -0,0 +1,32 @@
#ifndef COCKATRICE_COMMANDER_SPELLBOOK_ESTIMATE_BRACKET_RESULT_H
#define COCKATRICE_COMMANDER_SPELLBOOK_ESTIMATE_BRACKET_RESULT_H
#include "commander_spellbook_card_result.h"
#include "commander_spellbook_variant_result.h"
#include <QJsonObject>
#include <QList>
struct EstimateBracketResult
{
static EstimateBracketResult fromJson(const QJsonObject &json);
QString bracketTag;
QList<CommanderSpellbookCardResult> gameChangerCards;
QList<CommanderSpellbookCardResult> massLandDenialCards;
QList<CommanderSpellbookCardResult> extraTurnCards;
QList<CommanderSpellbookVariantResult> massLandDenialTemplates;
QList<CommanderSpellbookVariantResult> extraTurnTemplates;
QList<CommanderSpellbookVariantResult> massLandDenialCombos;
QList<CommanderSpellbookVariantResult> extraTurnCombos;
QList<CommanderSpellbookVariantResult> lockCombos;
QList<CommanderSpellbookVariantResult> skipTurnsCombos;
QList<CommanderSpellbookVariantResult> definitelyTwoCardCombos;
QList<CommanderSpellbookVariantResult> arguablyTwoCardCombos;
};
#endif // COCKATRICE_COMMANDER_SPELLBOOK_ESTIMATE_BRACKET_RESULT_H

View file

@ -0,0 +1,35 @@
#include "commander_spellbook_variant_result.h"
CommanderSpellbookVariantResult CommanderSpellbookVariantResult::fromJson(const QJsonObject &json)
{
CommanderSpellbookVariantResult result;
result.id = json.value("id").toString();
result.status = json.value("status").toString();
result.uses = json.value("uses").toArray();
result.cardRequires = json.value("requires").toArray();
result.produces = json.value("produces").toArray();
result.of = json.value("of").toArray();
result.includes = json.value("includes").toArray();
result.manaNeeded = json.value("manaNeeded").toArray();
result.manaValueNeeded = json.value("manaValueNeeded").toArray();
result.easyPrerequisites = json.value("easyPrerequisites").toArray();
result.notablePrerequisites = json.value("notablePrerequisites").toArray();
result.description = json.value("description").toString();
result.notes = json.value("notes").toString();
result.popularity = json.value("popularity").toDouble();
result.spoiler = json.value("spoiler").toBool();
result.bracketTag = json.value("bracketTag").toString();
result.legalities = json.value("legalities").toObject();
result.prices = json.value("prices").toObject();
result.variantCount = json.value("variantCount").toInt();
return result;
}

View file

@ -0,0 +1,39 @@
#ifndef COCKATRICE_COMMANDER_SPELLBOOK_VARIANT_RESULT_H
#define COCKATRICE_COMMANDER_SPELLBOOK_VARIANT_RESULT_H
#include <QJsonArray>
#include <QJsonObject>
struct CommanderSpellbookVariantResult
{
static CommanderSpellbookVariantResult fromJson(const QJsonObject &json);
QString id;
QString status;
QJsonArray uses;
QJsonArray cardRequires;
QJsonArray produces;
QJsonArray of;
QJsonArray includes;
QJsonArray manaNeeded;
QJsonArray manaValueNeeded;
QJsonArray easyPrerequisites;
QJsonArray notablePrerequisites;
QString description;
QString notes;
double popularity = 0.0;
bool spoiler = false;
QString bracketTag;
QJsonObject legalities;
QJsonObject prices;
int variantCount = 0;
};
#endif // COCKATRICE_COMMANDER_SPELLBOOK_VARIANT_RESULT_H

View file

@ -0,0 +1,53 @@
#include "commander_bracket_service.h"
#include "../../../../../client/settings/cache_settings.h"
#include <libcockatrice/settings/commander_bracket_settings.h>
CommanderBracketService &CommanderBracketService::instance()
{
static CommanderBracketService service;
return service;
}
CommanderBracketService::CommanderBracketService(QObject *parent) : QObject(parent)
{
connect(&CommanderSpellbookApiAccessor::instance(), &CommanderSpellbookApiAccessor::estimateBracketFinished, this,
&CommanderBracketService::onEstimateBracketFinished);
connect(&CommanderSpellbookApiAccessor::instance(), &CommanderSpellbookApiAccessor::estimateBracketError, this,
&CommanderBracketService::onEstimateBracketError);
}
quint64 CommanderBracketService::estimateBracket(const DeckList &deck, QObject *requester)
{
return CommanderSpellbookApiAccessor::instance().estimateBracket(deck, requester);
}
void CommanderBracketService::onEstimateBracketFinished(CommanderSpellbookApiAccessor::RequestId id,
QObject *requester,
const EstimateBracketResult &result)
{
CommanderBracketEstimate estimate;
estimate.bracketTag = result.bracketTag;
estimate.rawResult = result;
auto &brackets = SettingsCache::instance().commanderBrackets();
estimate.officialName = brackets.officialName(result.bracketTag);
estimate.displayName = brackets.displayName(result.bracketTag);
estimate.explanation = brackets.explanation(result.bracketTag);
emit estimateFinished(id, requester, estimate);
}
void CommanderBracketService::onEstimateBracketError(CommanderSpellbookApiAccessor::RequestId id,
QObject *requester,
const QString &error)
{
emit estimateError(id, requester, error);
}

View file

@ -0,0 +1,45 @@
#ifndef COCKATRICE_COMMANDER_BRACKET_SERVICE_H
#define COCKATRICE_COMMANDER_BRACKET_SERVICE_H
#include "commander_spellbook_api_accessor.h"
#include "libcockatrice/deck_list/deck_list.h"
#include <QObject>
struct CommanderBracketEstimate
{
QString bracketTag;
QString officialName;
QString displayName;
QString explanation;
EstimateBracketResult rawResult;
};
class CommanderBracketService : public QObject
{
Q_OBJECT
public:
static CommanderBracketService &instance();
quint64 estimateBracket(const DeckList &deck, QObject *requester);
signals:
void estimateFinished(quint64 requestId, QObject *requester, const CommanderBracketEstimate &estimate);
void estimateError(quint64 requestId, QObject *requester, const QString &error);
private slots:
void onEstimateBracketFinished(CommanderSpellbookApiAccessor::RequestId id,
QObject *requester,
const EstimateBracketResult &result);
void onEstimateBracketError(CommanderSpellbookApiAccessor::RequestId id, QObject *requester, const QString &error);
private:
explicit CommanderBracketService(QObject *parent = nullptr);
};
#endif

View file

@ -0,0 +1,269 @@
#include "commander_bracket_widget.h"
#include "../../../../../client/settings/cache_settings.h"
#include "commander_bracket_service.h"
#include <QComboBox>
#include <QDialog>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QTimer>
#include <QToolButton>
#include <QVBoxLayout>
#include <libcockatrice/settings/deck_editor_settings.h>
CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent)
{
bracketLabel = new QLabel(tr("Bracket:"), this);
bracketValueLabel = new QLabel(this);
bracketValueLabel->setText("-");
bracketValueLabel->setObjectName("bracketValueLabel");
bracketInfoButton = new QToolButton(this);
bracketInfoButton->setText("?");
bracketInfoButton->setAutoRaise(true);
bracketInfoButton->setEnabled(false);
bracketRefreshButton = new QToolButton(this);
bracketRefreshButton->setIcon(QPixmap("theme:icons/reload"));
bracketRefreshButton->setAutoRaise(true);
connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate);
auto *layout = new QGridLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(bracketLabel, 0, 0);
auto *bracketRow = new QHBoxLayout;
bracketRow->addWidget(bracketValueLabel);
bracketRow->addWidget(bracketInfoButton);
bracketRow->addWidget(bracketRefreshButton);
bracketRow->addStretch();
layout->addLayout(bracketRow, 0, 1);
connect(&CommanderBracketService::instance(), &CommanderBracketService::estimateFinished, this,
&CommanderBracketWidget::onEstimateBracketFinished);
connect(&CommanderBracketService::instance(), &CommanderBracketService::estimateError, this,
&CommanderBracketWidget::onEstimateBracketError);
connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::commanderSpellbookIntegrationEnabledChanged,
this, &CommanderBracketWidget::maybeAutoEstimateBracket);
connect(&SettingsCache::instance().deckEditor(),
&DeckEditorSettings::commanderSpellbookIntegrationUseOfficialBracketNamesChanged, this,
&CommanderBracketWidget::maybeAutoEstimateBracket);
setVisible(false);
}
void CommanderBracketWidget::setDeck(const QSharedPointer<DeckList> &_deck)
{
deck = _deck;
requestId = 0; // invalidate any in-flight estimate for the previous deck
// Reset the displayed bracket
bracketValueLabel->setText("-");
bracketInfoButton->setToolTip({});
bracketInfoButton->setEnabled(false);
bracketRefreshButton->setEnabled(true);
maybeAutoEstimateBracket();
}
bool CommanderBracketWidget::promptCommanderSpellbookIntegration()
{
QDialog dialog(this);
dialog.setWindowTitle(tr("CommanderSpellbook integration"));
auto *mainLayout = new QVBoxLayout(&dialog);
// Main text
auto *label = new QLabel(tr("CommanderSpellbook can analyze your deck and estimate its Commander bracket.\n\n"
"This sends your deck list to an external service.\n\n"
"CommanderSpellbook uses its own bracket naming system based on their own algorithm. "
"These names can be mapped to the official Commander brackets, but the mapping "
"is only an approximation."));
label->setWordWrap(true);
mainLayout->addWidget(label);
// Naming selector
auto *formLayout = new QFormLayout;
auto *namingCombo = new QComboBox(&dialog);
namingCombo->addItem(tr("CommanderSpellbook bracket names"));
namingCombo->addItem(tr("Official Commander bracket names (approximate)"));
namingCombo->setCurrentIndex(
SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames() ? 1 : 0);
// Create label + explainer button
auto *labelWidget = new QWidget(&dialog);
auto *labelLayout = new QHBoxLayout(labelWidget);
labelLayout->setContentsMargins(0, 0, 0, 0);
auto *namingLabel = new QLabel(tr("Bracket naming:"), labelWidget);
auto *explainerButton = new QToolButton(labelWidget);
explainerButton->setText("?");
explainerButton->setAutoRaise(true);
explainerButton->setEnabled(false);
explainerButton->setToolTip(CommanderBracketNames::Explainer);
labelLayout->addWidget(namingLabel);
labelLayout->addWidget(explainerButton);
labelLayout->addStretch(); // push the button next to label, combo stays aligned
// Add row with the custom label widget
formLayout->addRow(labelWidget, namingCombo);
mainLayout->addLayout(formLayout);
// Buttons
auto *buttonBox = new QDialogButtonBox(&dialog);
auto *enableBtn = buttonBox->addButton(tr("Enable"), QDialogButtonBox::AcceptRole);
auto *automaticBtn = buttonBox->addButton(tr("Automatic"), QDialogButtonBox::ApplyRole);
auto *disableBtn = buttonBox->addButton(tr("Disable"), QDialogButtonBox::RejectRole);
mainLayout->addWidget(buttonBox);
// Track which button was clicked
QAbstractButton *clickedButton = nullptr;
QObject::connect(buttonBox, &QDialogButtonBox::clicked, &dialog, [&](QAbstractButton *btn) {
clickedButton = btn;
dialog.accept();
});
dialog.exec();
// Persist naming choice (if not disabled)
if (clickedButton != disableBtn) {
bool useOfficial = namingCombo->currentIndex() == 1;
SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationUseOfficialBracketNames(useOfficial);
}
// Persist integration mode
if (clickedButton == disableBtn) {
SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled(
commanderSpellbookIntegrationEnabledIndexDisabled);
return false;
}
if (clickedButton == enableBtn) {
SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled(
commanderSpellbookIntegrationEnabledIndexEnabled);
return true;
}
if (clickedButton == automaticBtn) {
SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled(
commanderSpellbookIntegrationEnabledIndexAutomatic);
return true;
}
return false;
}
void CommanderBracketWidget::updateBracketVisibility(bool visible)
{
setVisible(visible);
}
void CommanderBracketWidget::requestBracketEstimate()
{
bracketRefreshButton->setEnabled(false);
bracketInfoButton->setEnabled(false);
bracketValueLabel->setText(tr("Calculating…"));
requestId = CommanderBracketService::instance().estimateBracket(*deck, this);
}
void CommanderBracketWidget::onEstimateBracketFinished(quint64 id,
QObject *requester,
const CommanderBracketEstimate &result)
{
if (requester != this || id != requestId) {
return;
}
BracketExplainer explainer;
lastBracketExplanation = explainer.explain(result.rawResult);
// Display bracket
bracketValueLabel->setText(
SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames()
? result.officialName
: result.displayName);
bracketRefreshButton->setEnabled(true);
// Build tooltip
QString tooltip;
for (const auto &section : lastBracketExplanation.sections) {
tooltip += "<b>" + section.title + "</b><br>";
for (const auto &line : section.bulletPoints) {
tooltip += "• " + line + "<br>";
}
tooltip += "<br>";
}
bracketInfoButton->setToolTip(tooltip);
bracketInfoButton->setEnabled(!tooltip.isEmpty());
}
void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *requester, const QString & /*error*/)
{
if (requester != this || id != requestId) {
return;
}
bracketValueLabel->setText("-");
bracketRefreshButton->setEnabled(true);
bracketInfoButton->setToolTip({});
bracketInfoButton->setEnabled(false);
}
void CommanderBracketWidget::maybeAutoEstimateBracket()
{
const QString formatKey = deck->getGameFormat();
const bool isCommander = (formatKey.compare("commander", Qt::CaseInsensitive) == 0);
int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled();
if (!isCommander || mode == commanderSpellbookIntegrationEnabledIndexDisabled) {
updateBracketVisibility(false);
return;
}
if (mode == commanderSpellbookIntegrationEnabledIndexUnprompted) {
if (prompting) {
return;
}
prompting = true;
const bool accepted = promptCommanderSpellbookIntegration();
prompting = false;
if (!accepted) {
updateBracketVisibility(false);
return;
}
}
updateBracketVisibility(true);
mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled();
if (mode != commanderSpellbookIntegrationEnabledIndexAutomatic) {
return;
}
// Avoid firing if we already have a result or a request in flight
if (!bracketRefreshButton->isEnabled()) {
return;
}
// Defer to avoid races during init / model rebuild
QTimer::singleShot(0, this, &CommanderBracketWidget::requestBracketEstimate);
}
void CommanderBracketWidget::retranslateUi()
{
bracketLabel->setText(tr("Bracket:"));
bracketInfoButton->setToolTip(tr("Why this bracket?"));
bracketRefreshButton->setToolTip(tr("Recalculate bracket"));
}

View file

@ -0,0 +1,46 @@
#ifndef COCKATRICE_COMMANDER_BRACKET_WIDGET_H
#define COCKATRICE_COMMANDER_BRACKET_WIDGET_H
#include "commander_spellbook_bracket_explainer.h"
#include <QSharedPointer>
#include <QWidget>
#include <libcockatrice/deck_list/deck_list.h>
class QLabel;
class QToolButton;
struct CommanderBracketEstimate;
class CommanderBracketWidget : public QWidget
{
Q_OBJECT
public:
explicit CommanderBracketWidget(QWidget *parent = nullptr);
void setDeck(const QSharedPointer<DeckList> &_deck);
void retranslateUi();
private slots:
void requestBracketEstimate();
void onEstimateBracketFinished(quint64 id, QObject *requester, const CommanderBracketEstimate &result);
void onEstimateBracketError(quint64 id, QObject *requester, const QString &error);
void maybeAutoEstimateBracket();
private:
bool promptCommanderSpellbookIntegration();
void updateBracketVisibility(bool visible);
QSharedPointer<DeckList> deck;
bool prompting = false;
quint64 requestId = 0;
QLabel *bracketLabel;
QLabel *bracketValueLabel;
QToolButton *bracketInfoButton;
QToolButton *bracketRefreshButton;
BracketExplanation lastBracketExplanation;
};
#endif // COCKATRICE_COMMANDER_BRACKET_WIDGET_H

View file

@ -0,0 +1,75 @@
#include "commander_spellbook_api_accessor.h"
#include "api_response/commander_spellbook_deck_request.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkRequest>
#include <QUrl>
#include <version_string.h>
static const QUrl ESTIMATE_BRACKET_URL(QStringLiteral("https://backend.commanderspellbook.com/estimate-bracket"));
CommanderSpellbookApiAccessor &CommanderSpellbookApiAccessor::instance()
{
static CommanderSpellbookApiAccessor instance;
return instance;
}
CommanderSpellbookApiAccessor::CommanderSpellbookApiAccessor(QObject *parent) : QObject(parent)
{
}
CommanderSpellbookApiAccessor::RequestId CommanderSpellbookApiAccessor::estimateBracket(const DeckList &deck,
QObject *requester)
{
CommanderSpellbookDeckRequest deckRequest = CommanderSpellbookDeckRequest::fromDeckList(deck);
QJsonDocument doc(deckRequest.toJson());
QByteArray body = doc.toJson(QJsonDocument::Compact);
QNetworkRequest req(ESTIMATE_BRACKET_URL);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
QNetworkReply *reply = network.post(req, body);
const RequestId id = nextRequestId++;
reply->setProperty("requestId", QVariant::fromValue(id));
reply->setProperty("requester", QVariant::fromValue(requester));
connect(reply, &QNetworkReply::finished, this, [this, reply]() { onEstimateReplyFinished(reply); });
return id;
}
void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply)
{
reply->deleteLater();
const RequestId id = reply->property("requestId").toULongLong();
QObject *requester = reply->property("requester").value<QObject *>();
if (!requester) {
// Requester died — silently drop
return;
}
if (reply->error() != QNetworkReply::NoError) {
emit estimateBracketError(id, requester, reply->errorString());
return;
}
QJsonParseError err;
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
emit estimateBracketError(id, requester, QStringLiteral("Invalid JSON response"));
return;
}
EstimateBracketResult result = EstimateBracketResult::fromJson(doc.object());
emit estimateBracketFinished(id, requester, result);
}

View file

@ -0,0 +1,37 @@
#ifndef COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H
#define COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H
#include "api_response/commander_spellbook_estimate_bracket_result.h"
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QObject>
#include <libcockatrice/deck_list/deck_list.h>
class CommanderSpellbookApiAccessor final : public QObject
{
Q_OBJECT
public:
static CommanderSpellbookApiAccessor &instance();
using RequestId = quint64;
RequestId estimateBracket(const DeckList &deck, QObject *requester);
signals:
void estimateBracketFinished(RequestId id, QObject *requester, const EstimateBracketResult &result);
void estimateBracketError(RequestId id, QObject *requester, const QString &errorMessage);
private:
explicit CommanderSpellbookApiAccessor(QObject *parent = nullptr);
Q_DISABLE_COPY_MOVE(CommanderSpellbookApiAccessor)
void onEstimateReplyFinished(QNetworkReply *reply);
QNetworkAccessManager network;
RequestId nextRequestId = 1;
};
#endif // COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H

View file

@ -0,0 +1,120 @@
#include "commander_spellbook_bracket_explainer.h"
static QString cardList(const QList<CommanderSpellbookCardResult> &cards, int max = 5)
{
QStringList names;
for (int i = 0; i < cards.size() && i < max; ++i) {
names << cards[i].name;
}
if (cards.size() > max) {
names << QString("and %1 more").arg(cards.size() - max);
}
return names.join(", ");
}
static QString comboCount(const QList<CommanderSpellbookVariantResult> &variants)
{
return QString::number(variants.size());
}
BracketExplanation BracketExplainer::explain(const EstimateBracketResult &r)
{
BracketExplanation out;
out.bracket = r.bracketTag;
if (!r.gameChangerCards.isEmpty()) {
BracketExplanationSection s;
s.title = "Game-changing cards";
s.bulletPoints << QString("Your deck contains %1 game-changing cards, such as %2.")
.arg(r.gameChangerCards.size())
.arg(cardList(r.gameChangerCards));
out.sections << s;
}
if (!r.extraTurnCards.isEmpty() || !r.extraTurnTemplates.isEmpty() || !r.extraTurnCombos.isEmpty()) {
BracketExplanationSection s;
s.title = "Extra turns";
if (!r.extraTurnCards.isEmpty()) {
s.bulletPoints << QString("The deck contains %1 extra-turn cards (%2).")
.arg(r.extraTurnCards.size())
.arg(cardList(r.extraTurnCards));
}
if (!r.extraTurnTemplates.isEmpty()) {
s.bulletPoints << QString("%1 extra-turn templates were identified.").arg(comboCount(r.extraTurnTemplates));
}
if (!r.extraTurnCombos.isEmpty()) {
s.bulletPoints
<< QString("%1 extra-turn combo variants were identified.").arg(comboCount(r.extraTurnCombos));
}
out.sections << s;
}
if (!r.massLandDenialCards.isEmpty() || !r.massLandDenialTemplates.isEmpty() || !r.massLandDenialCombos.isEmpty()) {
BracketExplanationSection s;
s.title = "Mass land denial";
if (!r.massLandDenialCards.isEmpty()) {
s.bulletPoints << QString("The deck contains %1 mass land denial cards (%2).")
.arg(r.massLandDenialCards.size())
.arg(cardList(r.massLandDenialCards));
}
if (!r.massLandDenialTemplates.isEmpty()) {
s.bulletPoints
<< QString("%1 mass land denial templates were identified.").arg(comboCount(r.massLandDenialTemplates));
}
if (!r.massLandDenialCombos.isEmpty()) {
s.bulletPoints << QString("%1 mass land denial combo variants were identified.")
.arg(comboCount(r.massLandDenialCombos));
}
out.sections << s;
}
if (!r.lockCombos.isEmpty() || !r.skipTurnsCombos.isEmpty()) {
BracketExplanationSection s;
s.title = "Lock pieces";
if (!r.lockCombos.isEmpty()) {
s.bulletPoints << QString("%1 lock combo variants were detected.").arg(comboCount(r.lockCombos));
}
if (!r.skipTurnsCombos.isEmpty()) {
s.bulletPoints << QString("%1 skip-turn combo variants were detected.").arg(comboCount(r.skipTurnsCombos));
}
out.sections << s;
}
if (!r.definitelyTwoCardCombos.isEmpty() || !r.arguablyTwoCardCombos.isEmpty()) {
BracketExplanationSection s;
s.title = "Two-card combos";
if (!r.definitelyTwoCardCombos.isEmpty()) {
s.bulletPoints << QString("%1 definite two-card combo variants were identified.")
.arg(comboCount(r.definitelyTwoCardCombos));
}
if (!r.arguablyTwoCardCombos.isEmpty()) {
s.bulletPoints << QString("%1 arguable two-card combo variants were identified.")
.arg(comboCount(r.arguablyTwoCardCombos));
}
out.sections << s;
}
return out;
}

View file

@ -0,0 +1,43 @@
#ifndef COCKATRICE_COMMANDER_SPELLBOOK_BRACKET_EXPLAINER_H
#define COCKATRICE_COMMANDER_SPELLBOOK_BRACKET_EXPLAINER_H
#include "api_response/commander_spellbook_estimate_bracket_result.h"
namespace CommanderBracketNames
{
inline const char *CommanderSpellbookBracketNames = QT_TR_NOOP("CommanderSpellbook");
inline const char *OfficialCommanderBracketNames = QT_TR_NOOP("Official (approximate)");
inline const char *Explainer = QT_TR_NOOP(
"The bracket system combines both objective data, as well as subjective play experience to estimate a "
"bracket for a deck.\nCommanderSpellbook's estimation is algorithmical, which means that it can only operate "
"on the objective data, not the subjective intent. \nThey have chosen to represent this by defining their "
"own bracket system which matches their algorithm.\n"
"This custom bracket system maps loosely to the standard system. \nYou may choose to use these mapped "
"standardized names if these are more familiar to you, however, you should keep in mind that these are just "
"rough estimations.\n\nAlways consider the subjective factors of the bracket system when determing a deck's "
"final bracket!");
} // namespace CommanderBracketNames
struct BracketExplanationSection
{
QString title;
QStringList bulletPoints;
};
struct BracketExplanation
{
QString bracket;
QList<BracketExplanationSection> sections;
bool isEmpty() const
{
return sections.isEmpty();
}
};
class BracketExplainer
{
public:
static BracketExplanation explain(const EstimateBracketResult &result);
};
#endif // COCKATRICE_COMMANDER_SPELLBOOK_BRACKET_EXPLAINER_H

View file

@ -0,0 +1,62 @@
#include "handle_commander_brackets.h"
#include "../../../../../client/settings/cache_settings.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QUrl>
#include <libcockatrice/settings/commander_bracket_settings.h>
static const QUrl COMMANDER_BRACKET_JSON_URL(QStringLiteral("https://cockatrice.github.io/commander-brackets.json"));
HandleCommanderBrackets::HandleCommanderBrackets(QObject *parent)
: QObject(parent), nam(new QNetworkAccessManager(this)), reply(nullptr)
{
}
void HandleCommanderBrackets::downloadBracketDefinitions()
{
if (reply) {
return;
}
reply = nam->get(QNetworkRequest(COMMANDER_BRACKET_JSON_URL));
connect(reply, &QNetworkReply::finished, this, &HandleCommanderBrackets::actFinishParsingDownloadedData);
}
void HandleCommanderBrackets::actFinishParsingDownloadedData()
{
reply = qobject_cast<QNetworkReply *>(sender());
if (reply->error() != QNetworkReply::NoError) {
emit sigBracketDefinitionsDownloadFailed(reply->error());
reply->deleteLater();
return;
}
QJsonParseError parseError;
auto document = QJsonDocument::fromJson(reply->readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
emit sigBracketDefinitionsDownloadFailed(QNetworkReply::UnknownContentError);
reply->deleteLater();
return;
}
updateBracketDefinitions(document.toVariant().toMap());
emit sigBracketDefinitionsDownloaded();
reply->deleteLater();
}
void HandleCommanderBrackets::updateBracketDefinitions(const QVariantMap &jsonMap)
{
const auto bracketList = jsonMap.value("brackets").toList();
SettingsCache::instance().commanderBrackets().saveDefinitions(bracketList);
SettingsCache::instance().commanderBrackets().reloadDefinitions(bracketList);
}

View file

@ -0,0 +1,31 @@
#ifndef COCKATRICE_HANDLE_COMMANDER_BRACKETS_H
#define COCKATRICE_HANDLE_COMMANDER_BRACKETS_H
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QObject>
class HandleCommanderBrackets : public QObject
{
Q_OBJECT
public:
explicit HandleCommanderBrackets(QObject *parent = nullptr);
void downloadBracketDefinitions();
signals:
void sigBracketDefinitionsDownloaded();
void sigBracketDefinitionsDownloadFailed(QNetworkReply::NetworkError error);
private slots:
void actFinishParsingDownloadedData();
private:
void updateBracketDefinitions(const QVariantMap &jsonMap);
QNetworkAccessManager *nam;
QNetworkReply *reply;
};
#endif // COCKATRICE_HANDLE_COMMANDER_BRACKETS_H

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>
@ -25,7 +25,7 @@
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/models/database/card/card_completer_proxy_model.h>
#include <libcockatrice/models/database/card/card_search_model.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <version_string.h>
static bool canBeCommander(const CardInfoPtr &cardInfo)
@ -63,42 +63,21 @@ 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(); });
settingsButton = new SettingsButtonWidget(this);
cardSizeSlider =
new CardSizeWidget(this, nullptr, SettingsCache::instance().visualDeckStorage().getEDHRecCardSize());
connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setEDHRecCardSize);
cardSizeSlider = new CardSizeWidget(this, nullptr, SettingsCache::instance().cardsDisplay().getEDHRecCardSize());
connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setEDHRecCardSize);
settingsButton->addSettingsWidget(cardSizeSlider);

View file

@ -135,6 +135,7 @@ void TabAccount::retranslateUi()
void TabAccount::processListUsersResponse(const Response &response)
{
const Response_ListUsers &resp = response.GetExtension(Response_ListUsers::ext);
allUsersList->beginBulkLoad();
for (int i = 0; i < resp.user_list_size(); ++i) {
const ServerInfo_User &info = resp.user_list(i);
const QString &userName = QString::fromStdString(info.name());
@ -142,8 +143,8 @@ void TabAccount::processListUsersResponse(const Response &response)
ignoreList->setUserOnline(userName, true);
buddyList->setUserOnline(userName, true);
}
allUsersList->endBulkLoad();
allUsersList->sortItems();
ignoreList->sortItems();
buddyList->sortItems();
}
@ -188,18 +189,20 @@ void TabAccount::processUserLeftEvent(const Event_UserLeft &event)
void TabAccount::buddyListReceived(const QList<ServerInfo_User> &_buddyList)
{
buddyList->beginBulkLoad();
for (const auto &user : _buddyList) {
buddyList->processUserInfo(user, false);
}
buddyList->sortItems();
buddyList->endBulkLoad();
}
void TabAccount::ignoreListReceived(const QList<ServerInfo_User> &_ignoreList)
{
ignoreList->beginBulkLoad();
for (const auto &user : _ignoreList) {
ignoreList->processUserInfo(user, false);
}
ignoreList->sortItems();
ignoreList->endBulkLoad();
}
void TabAccount::processAddToListEvent(const Event_AddToList &event)

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>
@ -258,7 +265,7 @@ void TabGame::resetChatAndPhase()
void TabGame::emitUserEvent()
{
bool globalEvent = !game->getPlayerManager()->isSpectator() ||
SettingsCache::instance().interface().getSpectatorNotificationsEnabled();
SettingsCache::instance().userInterface().getSpectatorNotificationsEnabled();
emit userEvent(globalEvent);
updatePlayerListDockTitle();
}
@ -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;
@ -242,14 +264,14 @@ void TabRoom::actClearChat()
void TabRoom::actOpenChatSettings()
{
DlgSettings settings(this);
settings.setTab(4);
settings.setTab(DlgSettings::MessagesPage);
settings.exec();
}
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);

View file

@ -39,9 +39,9 @@
#include <libcockatrice/protocol/pb/serverinfo_room.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/settings/chat_settings.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/tabs_settings.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
QRect MacOSTabFixStyle::subElementRect(SubElement element, const QStyleOption *option, const QWidget *widget) const
{
@ -583,6 +583,7 @@ void TabSupervisor::actTabServer(bool checked)
void TabSupervisor::openTabServer()
{
SettingsCache::instance().tabs().setTabServerOpen(true);
if (tabServer) {
return;
}
@ -910,7 +911,7 @@ void TabSupervisor::talkLeft(TabMessage *tab)
*/
void TabSupervisor::openDeckInNewTab(const LoadedDeck &deckToOpen)
{
int type = SettingsCache::instance().visualDeckStorage().getDefaultDeckEditorType();
int type = SettingsCache::instance().deckEditor().getDefaultDeckEditorType();
switch (type) {
case ClassicDeckEditor:
addDeckEditorTab(deckToOpen);
@ -1009,7 +1010,7 @@ void TabSupervisor::tabUserEvent(bool globalEvent)
tab->setContentsChanged(true);
setTabIcon(indexOf(tab), QPixmap("theme:icons/tab_changed"));
}
if (globalEvent && SettingsCache::instance().interface().getNotificationsEnabled()) {
if (globalEvent && SettingsCache::instance().userInterface().getNotificationsEnabled()) {
QApplication::alert(this);
}
}
@ -1104,7 +1105,7 @@ void TabSupervisor::processUserJoined(const ServerInfo_User &userInfoJoined)
}
}
if (SettingsCache::instance().interface().getBuddyConnectNotificationsEnabled()) {
if (SettingsCache::instance().userInterface().getBuddyConnectNotificationsEnabled()) {
QApplication::alert(this);
this->actShowPopup(tr("Your buddy %1 has signed on!").arg(userName));
}

View file

@ -0,0 +1,439 @@
#include "card_completer_delegate.h"
#include "../cards/additional_info/mana_cost_widget.h"
#include <QFontMetrics>
#include <QLinearGradient>
#include <QPainter>
#include <QPainterPath>
#include <QRegularExpression>
#include <QSet>
#include <QStyleOptionViewItem>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/printing/printing_info.h>
#include <libcockatrice/models/database/card/card_search_model.h>
// ---------------------------------------------------------------------------
// Internal colour helpers
// ---------------------------------------------------------------------------
namespace
{
struct ManaColor
{
QColor fill;
QColor rim;
QColor text;
};
ManaColor manaColor(QChar symbol)
{
switch (symbol.unicode()) {
case 'W':
return {QColor(248, 248, 246), QColor(190, 180, 160), QColor(80, 70, 50)};
case 'U':
return {QColor(55, 130, 210), QColor(30, 90, 160), QColor(255, 255, 255)};
case 'B':
return {QColor(90, 65, 160), QColor(55, 38, 110), QColor(220, 200, 255)};
case 'R':
return {QColor(210, 55, 55), QColor(150, 30, 30), QColor(255, 255, 255)};
case 'G':
return {QColor(45, 148, 90), QColor(28, 95, 58), QColor(255, 255, 255)};
default:
return {QColor(100, 115, 135), QColor(65, 78, 95), QColor(230, 235, 240)};
}
}
QColor blend(QColor a, QColor b, qreal t)
{
return QColor::fromRgbF(a.redF() + (b.redF() - a.redF()) * t, a.greenF() + (b.greenF() - a.greenF()) * t,
a.blueF() + (b.blueF() - a.blueF()) * t, a.alphaF() + (b.alphaF() - a.alphaF()) * t);
}
} // namespace
// ---------------------------------------------------------------------------
QColor CardCompleterDelegate::accentForColors(const QString &colors)
{
if (colors.isEmpty()) {
return QColor(100, 115, 135);
}
QSet<QChar> seen;
for (const QChar c : colors) {
if (QString("WUBRG").contains(c)) {
seen.insert(c);
}
}
if (seen.size() > 1) {
return QColor(205, 145, 25);
}
if (seen.isEmpty()) {
return QColor(100, 115, 135);
}
return manaColor(*seen.begin()).fill;
}
// ---------------------------------------------------------------------------
CardCompleterDelegate::CardCompleterDelegate(QObject *parent) : QStyledItemDelegate(parent)
{
symbolCache.setMaxCost(64);
setCodeCache.setMaxCost(64);
}
// ---------------------------------------------------------------------------
// sizeHint
// ---------------------------------------------------------------------------
QSize CardCompleterDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
Q_UNUSED(option)
if (!index.isValid()) {
return QStyledItemDelegate::sizeHint(option, index);
}
// Fixed wide rows so the popup has room for name, type line, set and mana
return {480, CardRowHeight};
}
// ---------------------------------------------------------------------------
// Mana symbol painting
// ---------------------------------------------------------------------------
const QPixmap *CardCompleterDelegate::cachedSymbolPixmap(const QString &symbol, int size) const
{
const QString key = symbol + QString::number(size);
if (symbolCache.contains(key)) {
return symbolCache[key];
}
QPixmap src(QString("theme:icons/mana/%1").arg(symbol));
if (!src.isNull()) {
auto *pm = new QPixmap(src.scaled(size, size, Qt::KeepAspectRatio, Qt::SmoothTransformation));
symbolCache.insert(key, pm);
return pm;
}
return nullptr;
}
// ---------------------------------------------------------------------------
void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QString &symbol, int radius) const
{
const QRect pip(centre.x() - radius, centre.y() - radius, radius * 2, radius * 2);
const QPixmap *px = cachedSymbolPixmap(symbol, radius * 2);
if (px && !px->isNull()) {
p->drawPixmap(pip, *px);
return;
}
bool isNumeric = false;
const int numVal = symbol.toInt(&isNumeric);
const QString label = isNumeric ? QString::number(numVal) : symbol;
const ManaColor mc =
(symbol.length() == 1 && QString("WUBRG").contains(symbol)) ? manaColor(symbol[0]) : manaColor(QChar('X'));
QPainterPath circle;
circle.addEllipse(pip);
p->save();
p->setClipPath(circle);
p->fillPath(circle, mc.fill);
p->restore();
p->setPen(QPen(mc.rim, 1.2));
p->setBrush(Qt::NoBrush);
p->drawEllipse(pip.adjusted(1, 1, -1, -1));
QFont f = p->font();
f.setPixelSize(qMax(radius - 1, 7));
f.setBold(true);
p->setFont(f);
p->setPen(mc.text);
p->drawText(pip, Qt::AlignCenter, label);
}
// ---------------------------------------------------------------------------
int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const
{
if (manaCost.isEmpty()) {
return row.right();
}
const int diam = radius * 2;
// Split, adventure, aftermath and prepare cards store both halves of the
// cost joined by "//" (e.g. "1W // W"); draw each half as its own group.
static const QRegularExpression splitRegex("\\s*//\\s*");
QList<QStringList> parts;
for (const QString &part : manaCost.split(splitRegex, Qt::SkipEmptyParts)) {
const QStringList symbols = ManaCostWidget::parseManaCost(part);
if (!symbols.isEmpty()) {
parts.append(symbols);
}
}
int totalW = 0;
for (int i = 0; i < parts.size(); ++i) {
if (i > 0) {
totalW += PartGap;
}
totalW += parts.at(i).size() * diam + qMax(0, parts.at(i).size() - 1) * SymbolSpacing;
}
const int rightPad = 14;
int x = row.right() - rightPad - totalW + radius;
const int cy = row.center().y();
for (int i = 0; i < parts.size(); ++i) {
const QStringList &symbols = parts.at(i);
for (const QString &sym : symbols) {
drawManaSymbol(p, {x, cy}, sym, radius);
x += diam + SymbolSpacing;
}
if (i < parts.size() - 1) {
x += PartGap - SymbolSpacing;
}
}
return row.right() - rightPad - totalW - 10;
}
// ---------------------------------------------------------------------------
QString CardCompleterDelegate::setCodeForCard(const QSharedPointer<CardInfo> &card) const
{
if (!card) {
return QString();
}
const QString name = card->getName();
if (setCodeCache.contains(name)) {
return *setCodeCache[name];
}
QString code;
const PrintingInfo printing = CardDatabaseManager::query()->getPreferredPrinting(card);
if (auto set = printing.getSet()) {
code = set->getShortName();
}
auto *cached = new QString(code);
setCodeCache.insert(name, cached);
return code;
}
// ---------------------------------------------------------------------------
// paint
// ---------------------------------------------------------------------------
void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
if (!index.isValid()) {
QStyledItemDelegate::paint(painter, option, index);
return;
}
const QRect rect = option.rect;
const QPalette &pal = option.palette;
const bool selected = option.state & QStyle::State_Selected;
const bool hovered = option.state & QStyle::State_MouseOver;
painter->save();
painter->setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing | QPainter::SmoothPixmapTransform);
auto card = index.data(CardSearchModel::CardInfoRole).value<QSharedPointer<CardInfo>>();
if (!card) {
painter->fillRect(rect, pal.color(QPalette::Base));
painter->restore();
return;
}
const QString cardName = card->getName();
const QString manaCost = card->getManaCost();
const QString typeLine = card->getCardType();
const QString setCode = setCodeForCard(card);
const QColor accent = accentForColors(card->getColors());
const QColor base = pal.color(QPalette::Base);
const QColor textColor = pal.color(QPalette::Text);
const QColor secondaryColor = pal.color(QPalette::PlaceholderText);
QColor tinted = blend(base, accent, 0.40);
if (hovered) {
tinted = blend(tinted, Qt::white, 0.05);
}
const QRectF cardRect = rect.adjusted(3, 2, -3, -2);
// -----------------------------------------------------------------------
// Main card body
// -----------------------------------------------------------------------
QPainterPath path;
path.addRoundedRect(cardRect, 7, 7);
painter->save();
painter->setClipPath(path);
QLinearGradient bodyGrad(cardRect.topLeft(), cardRect.bottomLeft());
bodyGrad.setColorAt(0.0, blend(tinted, Qt::white, 0.10));
bodyGrad.setColorAt(1.0, blend(tinted, Qt::black, 0.18));
painter->fillPath(path, bodyGrad);
// -----------------------------------------------------------------------
// Accent strip
// -----------------------------------------------------------------------
QRectF accentRect(cardRect.left(), cardRect.top(), AccentBarWidth, cardRect.height());
QLinearGradient accentGrad(accentRect.topLeft(), accentRect.bottomLeft());
accentGrad.setColorAt(0.0, blend(accent, Qt::white, 0.20));
accentGrad.setColorAt(1.0, blend(accent, Qt::black, 0.25));
painter->fillRect(accentRect, accentGrad);
// -----------------------------------------------------------------------
// Right mana zone
// -----------------------------------------------------------------------
const QRectF manaZone(cardRect.right() - 110, cardRect.top(), 110, cardRect.height());
QLinearGradient manaGrad(manaZone.topLeft(), manaZone.bottomLeft());
manaGrad.setColorAt(0, QColor(0, 0, 0, 18));
manaGrad.setColorAt(1, QColor(0, 0, 0, 42));
painter->fillRect(manaZone, manaGrad);
painter->restore();
// -----------------------------------------------------------------------
// Border
// -----------------------------------------------------------------------
QColor border = blend(accent, Qt::black, 0.45);
if (hovered) {
border = blend(border, Qt::white, 0.18);
}
painter->setPen(QPen(border, 1.2));
painter->drawPath(path);
// -----------------------------------------------------------------------
// Selection glow
// -----------------------------------------------------------------------
if (selected) {
QColor glow = pal.color(QPalette::Highlight);
glow.setAlpha(30);
painter->fillPath(path, glow);
painter->setPen(QPen(pal.color(QPalette::Highlight), 2));
painter->drawPath(path);
}
// -----------------------------------------------------------------------
// Mana cost
// -----------------------------------------------------------------------
const int costLeft = drawManaCost(painter, cardRect.toRect(), manaCost, SymbolRadius);
// -----------------------------------------------------------------------
// Card name + type line + set code
// -----------------------------------------------------------------------
const int textLeft = cardRect.left() + AccentBarWidth + 12;
const int textRight = costLeft - 8;
const int textWidth = qMax(0, textRight - textLeft);
// -----------------------------------------------------------------------
// Card name (top band)
// -----------------------------------------------------------------------
{
const QRect nameRect(textLeft, rect.top() + 2, textWidth, 20);
QFont f = option.font;
f.setPixelSize(13);
f.setBold(true);
painter->setFont(f);
const QString nameText = QFontMetrics(f).elidedText(cardName, Qt::ElideRight, nameRect.width());
painter->setPen(QColor(0, 0, 0, 140));
painter->drawText(nameRect.translated(0, 1), Qt::AlignLeft | Qt::AlignVCenter, nameText);
painter->setPen(textColor);
painter->drawText(nameRect, Qt::AlignLeft | Qt::AlignVCenter, nameText);
}
// -----------------------------------------------------------------------
// Type line + set code (bottom band)
// -----------------------------------------------------------------------
{
const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24);
QFont f = option.font;
f.setPixelSize(10);
painter->setFont(f);
QString infoLine = typeLine;
if (!setCode.isEmpty()) {
infoLine += " \u00b7 " + setCode;
}
const QString infoText = QFontMetrics(f).elidedText(infoLine, Qt::ElideRight, infoRect.width());
painter->setPen(QColor(0, 0, 0, 120));
painter->drawText(infoRect.translated(0, 1), Qt::AlignLeft | Qt::AlignVCenter, infoText);
painter->setPen(secondaryColor);
painter->drawText(infoRect, Qt::AlignLeft | Qt::AlignVCenter, infoText);
}
painter->restore();
}

View file

@ -0,0 +1,63 @@
/**
* @file card_completer_delegate.h
* @ingroup UtilityWidgets
*/
//! \todo Document this file.
#ifndef CARD_COMPLETER_DELEGATE_H
#define CARD_COMPLETER_DELEGATE_H
#include <QCache>
#include <QColor>
#include <QPixmap>
#include <QStyledItemDelegate>
class CardInfo;
/**
* @brief Paints styled card completer popup rows.
*
* Each row shows the card name, type line, set code and mana cost pips,
* color-coded by the card's color identity. Card data is read directly from
* the CardSearchModel::CardInfoRole so no extra database lookups are needed.
*/
class CardCompleterDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
explicit CardCompleterDelegate(QObject *parent = nullptr);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
private:
// Mana symbol pixmaps, loaded once and cached
mutable QCache<QString, QPixmap> symbolCache;
// Set short codes, resolved once per card name and cached
mutable QCache<QString, QString> setCodeCache;
// Resolve the card's color string ("RG", "W", "", ...) → accent QColor
static QColor accentForColors(const QString &colors);
// Draw a single mana symbol pip at centre point
void drawManaSymbol(QPainter *p, QPoint centre, const QString &symbol, int radius) const;
// Draw all mana pips for a cost string like "2RG" or "{2}{R}{G}"; split and
// adventure costs ("1W // W") are drawn as separate groups. Returns the left-most x used
int drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const;
// Load (or return cached) a mana icon pixmap; falls back to painted circle
const QPixmap *cachedSymbolPixmap(const QString &symbol, int size) const;
// Resolve the preferred printing's set short code for a card
QString setCodeForCard(const QSharedPointer<CardInfo> &card) const;
static constexpr int CardRowHeight = 40;
static constexpr int AccentBarWidth = 5;
static constexpr int SymbolRadius = 9;
static constexpr int SymbolSpacing = 2;
static constexpr int PartGap = 14;
};
#endif // CARD_COMPLETER_DELEGATE_H

View file

@ -0,0 +1,340 @@
#include "card_completer_styler.h"
#include "../cards/card_info_picture_enlarged_widget.h"
#include "card_completer_delegate.h"
#include "reversed_completer_model.h"
#include <QAbstractItemModel>
#include <QAbstractItemView>
#include <QCompleter>
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QPropertyAnimation>
#include <QScreen>
#include <QSize>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/models/database/card/card_search_model.h>
namespace
{
const QSize PreviewSize(300, 419);
const int PreviewMargin = 16;
const int FadeDuration = 120;
} // namespace
// ---------------------------------------------------------------------------
void CardCompleterStyler::apply(QCompleter *completer)
{
if (!completer) {
return;
}
// The styler lives as long as the completer
new CardCompleterStyler(completer, completer);
}
// ---------------------------------------------------------------------------
CardCompleterStyler::CardCompleterStyler(QCompleter *completer, QObject *parent)
: QObject(parent), completer(completer), reversedModel(nullptr), preview(nullptr), above(false)
{
auto *popup = completer->popup();
// Wrap the completer's model so its row order can be reversed when the
// popup is shown above the text edit
QAbstractItemModel *sourceModel = completer->model();
reversedModel = new ReversedCompleterModel(completer);
reversedModel->setSourceModel(sourceModel);
completer->setModel(reversedModel);
popup->setItemDelegate(new CardCompleterDelegate(popup));
popup->viewport()->setMouseTracking(true);
popup->installEventFilter(this);
popup->viewport()->installEventFilter(this);
connect(popup->selectionModel(), &QItemSelectionModel::currentRowChanged, this,
&CardCompleterStyler::updatePreview);
connect(completer, qOverload<const QString &>(&QCompleter::activated), this, &CardCompleterStyler::hidePreview);
connect(completer->completionModel(), &QAbstractItemModel::modelReset, this,
&CardCompleterStyler::onCompletionReset);
}
CardCompleterStyler::~CardCompleterStyler()
{
if (preview) {
preview->deleteLater();
}
}
// ---------------------------------------------------------------------------
bool CardCompleterStyler::eventFilter(QObject *obj, QEvent *ev)
{
auto *popup = completer->popup();
if (obj == popup->viewport()) {
if (ev->type() == QEvent::MouseMove) {
updatePreviewFromHover(static_cast<QMouseEvent *>(ev)->pos());
}
} else if (obj == popup) {
switch (ev->type()) {
case QEvent::Show:
case QEvent::Move:
case QEvent::Resize:
updateOrientation();
reposition();
break;
case QEvent::Hide:
hidePreview();
break;
case QEvent::KeyPress:
if (handlePopupKeyPress(static_cast<QKeyEvent *>(ev))) {
return true;
}
break;
default:
break;
}
}
return QObject::eventFilter(obj, ev);
}
// ---------------------------------------------------------------------------
bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event)
{
const int key = event->key();
if (key != Qt::Key_Up && key != Qt::Key_Down) {
return false;
}
if (!above) {
return false;
}
auto *popup = completer->popup();
const int rowCount = completer->completionModel()->rowCount();
if (rowCount == 0) {
return true;
}
// With the popup above the text edit the rows are shown in reverse order and
// the closest match is in the bottom-most row. The up arrow then advances
// through the list (towards its end at the top of the popup).
const int currentRow = popup->currentIndex().row();
const int step = (key == Qt::Key_Up) ? -1 : 1;
const int newRow = qBound(0, currentRow + step, rowCount - 1);
if (newRow != currentRow) {
popup->setCurrentIndex(completer->completionModel()->index(newRow, completer->completionColumn()));
}
return true;
}
// ---------------------------------------------------------------------------
void CardCompleterStyler::updateOrientation()
{
above = isPopupAboveWidget();
reversedModel->setEnabled(above);
}
// ---------------------------------------------------------------------------
void CardCompleterStyler::ensureClosestSelected()
{
auto *popup = completer->popup();
auto *completionModel = completer->completionModel();
const int rowCount = completionModel->rowCount();
if (rowCount == 0) {
return;
}
const int currentRow = popup->currentIndex().row();
const int closestRow = rowCount - 1;
if (currentRow != closestRow) {
popup->setCurrentIndex(completionModel->index(closestRow, completer->completionColumn()));
}
}
// ---------------------------------------------------------------------------
void CardCompleterStyler::onCompletionReset()
{
if (above) {
ensureClosestSelected();
}
}
// ---------------------------------------------------------------------------
bool CardCompleterStyler::isPopupAboveWidget() const
{
auto *popup = completer->popup();
QWidget *widget = completer->widget();
if (!widget || !popup->isVisible()) {
return false;
}
const QPoint popupBottom = popup->mapToGlobal(QPoint(0, popup->height()));
const QPoint fieldTop = widget->mapToGlobal(QPoint(0, 0));
return popupBottom.y() <= fieldTop.y();
}
// ---------------------------------------------------------------------------
void CardCompleterStyler::updatePreviewFromHover(const QPoint &pos)
{
const QModelIndex index = completer->popup()->indexAt(pos);
// Hovering updates the preview but must not change the current completion
if (index.isValid() && index != previewedIndex) {
updatePreview(index);
}
}
// ---------------------------------------------------------------------------
void CardCompleterStyler::updatePreview(const QModelIndex &index)
{
previewedIndex = index;
if (!index.isValid()) {
hidePreview();
return;
}
auto card = index.data(CardSearchModel::CardInfoRole).value<QSharedPointer<CardInfo>>();
if (!card) {
hidePreview();
return;
}
if (!preview) {
QWidget *contextWindow = completer->widget() ? completer->widget()->window() : nullptr;
preview = new CardInfoPictureEnlargedWidget(contextWindow);
preview->hide();
preview->setWindowOpacity(0.0);
}
const ExactCard exact = CardDatabaseManager::query()->getCard({card->getName()});
if (!exact) {
hidePreview();
return;
}
preview->setCardPixmap(exact, PreviewSize);
reposition();
showPreview();
}
// ---------------------------------------------------------------------------
void CardCompleterStyler::showPreview()
{
if (!preview) {
return;
}
stopPreviewFade();
preview->show();
preview->raise();
auto *fade = new QPropertyAnimation(preview, "windowOpacity", preview);
fade->setDuration(FadeDuration);
fade->setStartValue(preview->windowOpacity());
fade->setEndValue(1.0);
fade->start(QAbstractAnimation::DeleteWhenStopped);
}
// ---------------------------------------------------------------------------
void CardCompleterStyler::hidePreview()
{
if (!preview) {
return;
}
// Stop any in-flight fade first so a stale one cannot keep the preview visible
stopPreviewFade();
preview->hide();
}
// ---------------------------------------------------------------------------
void CardCompleterStyler::stopPreviewFade()
{
if (!preview) {
return;
}
const auto animations = preview->findChildren<QPropertyAnimation *>();
for (auto *animation : animations) {
animation->stop();
}
}
// ---------------------------------------------------------------------------
void CardCompleterStyler::reposition()
{
if (!preview) {
return;
}
auto *popup = completer->popup();
const QRect popupGlobalRect(popup->mapToGlobal(popup->rect().topLeft()), popup->rect().size());
int anchorY;
if (previewedIndex.isValid()) {
const QRect itemRect = popup->visualRect(previewedIndex);
const QPoint itemCenter = popup->viewport()->mapToGlobal(itemRect.center());
anchorY = itemCenter.y();
} else {
anchorY = popupGlobalRect.center().y();
}
const QScreen *screen = popup->screen();
const QRect screenGeom = screen ? screen->availableGeometry() : QRect();
const int rightX = popupGlobalRect.right() + PreviewMargin;
const int leftX = popupGlobalRect.left() - PreviewMargin - preview->width();
int x;
int y = anchorY - preview->height() / 2;
if (screenGeom.isEmpty()) {
x = rightX;
} else if (rightX + preview->width() <= screenGeom.right()) {
x = rightX;
} else if (leftX >= screenGeom.left()) {
x = leftX;
} else {
x = rightX;
}
if (!screenGeom.isEmpty()) {
x = qBound(screenGeom.left(), x, qMax(screenGeom.left(), screenGeom.right() - preview->width()));
y = qBound(screenGeom.top(), y, qMax(screenGeom.top(), screenGeom.bottom() - preview->height()));
}
preview->move(x, y);
}

View file

@ -0,0 +1,67 @@
/**
* @file card_completer_styler.h
* @ingroup UtilityWidgets
*/
//! \todo Document this file.
#ifndef CARD_COMPLETER_STYLER_H
#define CARD_COMPLETER_STYLER_H
#include <QModelIndex>
#include <QObject>
class CardInfoPictureEnlargedWidget;
class QCompleter;
class QKeyEvent;
class QPoint;
class ReversedCompleterModel;
/**
* @brief Applies styled row painting and a card-image preview to a card completer.
*
* The completer popup rows are painted by CardCompleterDelegate and the image of
* the currently selected (or hovered) row is shown beside the popup.
*/
class CardCompleterStyler : public QObject
{
Q_OBJECT
public:
/**
* @brief Styles @p completer and follows its current selection.
*
* The styler is parented to the completer so it lives exactly as long as the
* completer itself.
*/
static void apply(QCompleter *completer);
protected:
bool eventFilter(QObject *obj, QEvent *ev) override;
private slots:
void updatePreview(const QModelIndex &index);
void updatePreviewFromHover(const QPoint &pos);
void onCompletionReset();
private:
explicit CardCompleterStyler(QCompleter *completer, QObject *parent = nullptr);
~CardCompleterStyler() override;
void showPreview();
void hidePreview();
void stopPreviewFade();
void reposition();
void updateOrientation();
void ensureClosestSelected();
bool handlePopupKeyPress(QKeyEvent *event);
bool isPopupAboveWidget() const;
QCompleter *completer;
ReversedCompleterModel *reversedModel;
CardInfoPictureEnlargedWidget *preview;
QModelIndex previewedIndex;
bool above;
};
#endif // CARD_COMPLETER_STYLER_H

View file

@ -0,0 +1,52 @@
#include "completer_utils.h"
#include "card_completer_styler.h"
#include <QCompleter>
#include <QLineEdit>
#include <QObject>
#include <QRegularExpression>
#include <QStringListModel>
#include <libcockatrice/models/database/card/card_completer_proxy_model.h>
#include <libcockatrice/models/database/card/card_search_model.h>
#include <libcockatrice/models/database/card_database_display_model.h>
CardCompleterSetup createCardCompleter(CardDatabaseDisplayModel *displayModel, QObject *parent, int maxVisibleItems)
{
auto *searchModel = new CardSearchModel(displayModel, parent);
auto *proxyModel = new CardCompleterProxyModel(parent);
proxyModel->setSourceModel(searchModel);
proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
auto *completer = new QCompleter(proxyModel, parent);
completer->setCompletionRole(Qt::DisplayRole);
completer->setCompletionMode(QCompleter::PopupCompletion);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setFilterMode(Qt::MatchContains);
completer->setMaxVisibleItems(maxVisibleItems);
CardCompleterStyler::apply(completer);
return {searchModel, proxyModel, completer};
}
void connectCardCompleterSearch(QLineEdit *edit, const CardCompleterSetup &setup)
{
QObject::connect(edit, &QLineEdit::textEdited, setup.searchModel, &CardSearchModel::updateSearchResults);
QObject::connect(edit, &QLineEdit::textEdited, setup.completer, [setup](const QString &text) {
setup.proxyModel->setFilterRegularExpression(
QRegularExpression(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
setup.completer->complete();
}
});
}
QCompleter *createMentionCompleter(QStringListModel *model, QObject *parent)
{
auto *completer = new QCompleter(model, parent);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setMaxVisibleItems(5);
completer->setFilterMode(Qt::MatchStartsWith);
return completer;
}

Some files were not shown because too many files have changed in this diff Show more