[DeckShare] Browse and open public decks with loading, error and accessibility states (#7245)

* [DeckShare] Browse and open public decks with loading, error and accessibility states

Add a public-decks tab that lists decks published by other users using the
server's deck visibility feature, previewing each deck's banner card, color
identity, tags and upload time without downloading the deck list until the
user opens it.

- Add a public-decks tab with a shared-settings widget and a remote model
  that fetches the target user's decks and refreshes both automatically and
  on user request, with a loading indicator and a server-error message
  instead of a blank tab when the fetch fails or the connection drops
- Render each deck as a focusable preview tile whose banner, color identity,
  tags and upload time follow the existing Preview settings, with the deck
  name announced as the tile's accessible name and Space/Enter opening the
  deck, mirroring the shared-deck preview tile
- Show a message box when opening a public deck fails or arrives corrupted
- Publish and unpublish decks from the server storage toolbar and context
  menu, toggling the deck's own visibility bit (what the server persists)
  rather than the inherited effective state, and batch the visibility
  refresh until the last in-flight change is acknowledged
- Add the Show Upload Time setting so the tile's upload stamp can be hidden
  like the other preview details
- Update the retranslateUi wiring for the new public-decks tab and rename
  the share action tooltip from "Deck share" to "Share link"

* [DeckShare] Adapt deck upload to the server-derived banner and tag protocol

The server now derives the banner card and tags from the uploaded deck
list itself, so Command_DeckUpload only carries the client-computed color
identity. Drop the reserved banner/tag setters from the editor and storage
uploads, send the color identity on remote saves, and read tags from the
now-repeated ServerInfo_DeckStorage_TreeItem field.

* [DeckStorage] Refresh the visibility column with a guarded timer instead of a latch counter

A dropped visibility reply used to leave the pendingVisibilityChanges
counter permanently positive, so the Public/Private column never refreshed
again and nothing reset it on disconnect. A restartable single-shot timer
with a boolean guard re-reads the tree whenever publishes quiet down and is
stopped on disconnect, so a lost reply costs one stale refresh instead of
killing the column for the session.

* [DeckStorage] Summarize batch publish failures when the batch drains

Each rejected node stacked its own modal dialog, so publishing a ten-deck
selection against a rejecting server made the user dismiss ten dialogs one
at a time. Failures are now collected while the batch is in flight and shown
as a single summary when the visibility refresh timer fires; a reply that
lands outside an active batch still reports right away.

* [PublicDecks] Time out the loading state so a dropped reply cannot wedge the tab

loading only cleared in decksReceived, but the ping sweep can drop a pending
command without ever emitting finished, leaving the tab stuck on 'Loading
public decks...' and the refresh button permanently inert. A single-shot
timer started per refresh clears the latch and reports a timeout; the latch
also clears when the client disconnects.

* [PublicDecks] Escape remote-crafted text in tooltips and the tab title

Deck names and usernames come from other users' records and Qt renders
QLabel tooltips as AutoText, so a name like '<h1><table>...' parsed as
markup. Escape and bound the deck-name tooltip and escape the username
interpolated into the title label.

* [DeckStorage] Distinguish an inherited public state in the visibility column

The column reported the effective state while publishing toggles the node's
own bit, so a private deck inside a public folder already read 'Public' and
toggling appeared to do nothing (and toggling again silently unpublished
it). The cell now shows 'Public (inherited)' for that case and the tooltip
explains why.

* [PublicDecks] Run retranslateUi at construction and name the refresh button

retranslateUi was never called from the constructor, so the tooltips set
there were absent until a language change. Call it before the first refresh,
and give the icon-only refresh button an accessible name for screen readers.

* [PublicDecks] Keep the empty and status variants correct across language changes

retranslateUi unconditionally rewrote the empty label to the 'nothing
published' variant, stomping the 'no decks match your filters' choice
rebuildGrid had made, and a visible loading message stayed in the old
language. Let retranslateUi pick the same variant rebuildGrid does and
re-show the status so it retranslates.

* [VDS] Share one color-identity match rule between the two deck grids

The remote public decks model verbatim-copied updateColorMatches' switch,
down to the ExactMatch normalization and the fact that Includes/Excludes do
not normalize case. Extract colorIdentityMatches() next to the FilterMode
enum and call it from both so the subtle rule cannot drift.

* [DeckStorage] Drop the unused tree widget model accessor

The accessor handed the model out past the wrapper methods that exist to
keep it encapsulated, and nothing in the stack called it.

* [DeckShare] End the public-decks files with a trailing newline

keeps the final line's diff clean and stops clang-format CI from flagging
the files.

* [VDS] Reuse the shared quick settings widget for the public decks tab

PublicDecksQuickSettingsWidget was VisualDeckStorageQuickSettingsWidget
minus the folders, banner and tooltip controls, with identical wiring for
the shared keys and a version of the near-identical file to keep in sync by
hand. Fold the Show Upload Time checkbox into the shared widget, give it a
setPublicDecksMode() that hides the controls that do not apply, and delete
the duplicate.

* [PublicDecks] Drop stale deck-list replies after the loading timeout

A reply that lands after its own loading timeout (the reverse of the ping
sweep dropping the command) could stop the newer request's timeout timer
and repaint the grid with out-of-date data. Each refresh now captures a
monotonically increasing request id, and only the newest request's reply
updates the grid.

* [PublicDecks] Re-show a displayed failure message on language changes

The status label carries both the loading and the failure message, and
retranslateUi hid it whenever the model was not loading, so a language
change while a server-error or timeout message was on screen swapped it
for the (empty) grid. The tab now keeps the last failure text and
re-shows it when not loading, clearing it once a new refresh starts.

* [DeckStorage] Keep the visibility refresh armed until replies land

The single-shot drain was armed with the 500 ms delay at send time, so a
round trip slower than that drained before the server applied the change,
re-read the old state and never re-armed, leaving the column stale until
a manual refresh. The timer is now armed with the full network timeout at
send time (a lost reply still costs one stale refresh) and re-armed with
the short delay every time a reply lands.

* [DeckShare] Close public decks tabs when the client disconnects

TabSupervisor::stop() built tabsToDelete from the room and game tabs
only, so a public decks tab survived a disconnect, sitting with stale
contents and a refresh button that kept hitting the dead client. Its
values are now folded into the same cleanup.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-09-20 20:22:17 +02:00 committed by GitHub
parent 8ca749c07d
commit 6823d54c1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1275 additions and 37 deletions

View file

@ -0,0 +1,167 @@
#include "public_deck_preview_widget.h"
#include "../../../../client/settings/cache_settings.h"
#include "../../cards/additional_info/color_identity_widget.h"
#include "../../cards/deck_preview_card_picture_widget.h"
#include "../../general/layout_containers/flow_widget.h"
#include "deck_preview_tag_display_widget.h"
#include <QKeyEvent>
#include <QLabel>
#include <QMouseEvent>
#include <QResizeEvent>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry)
: QWidget(parent)
{
bannerCardDisplayWidget = new DeckPreviewCardPictureWidget(this);
bannerCardDisplayWidget->setFontSize(24);
// The whole tile is a single focusable, keyboard-operable control: Tab lands
// on it and Space/Enter opens the deck, mirroring the shared-deck preview tile.
setFocusPolicy(Qt::StrongFocus);
uploadTimeLabel = new QLabel(this);
uploadTimeLabel->setAlignment(Qt::AlignHCenter);
colorIdentityWidget = new ColorIdentityWidget(this);
tagsFlowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded);
tagsFlowWidget->setSpacing(3, 3);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(bannerCardDisplayWidget);
layout->addWidget(uploadTimeLabel);
layout->addWidget(colorIdentityWidget);
layout->addWidget(tagsFlowWidget);
setLayout(layout);
connect(&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::visualDeckStorageShowColorIdentityChanged, this,
&PublicDeckPreviewWidget::updateColorIdentityVisibility);
connect(&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::visualDeckStorageShowTagsOnDeckPreviewsChanged, this,
&PublicDeckPreviewWidget::updateTagsVisibility);
connect(&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::visualDeckStorageShowUploadTimeChanged, this,
&PublicDeckPreviewWidget::updateUploadTimeVisibility);
setEntry(entry);
connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this,
&PublicDeckPreviewWidget::imageClickedEvent);
connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this,
&PublicDeckPreviewWidget::imageDoubleClickedEvent);
// resizeEvent clamps every child to the banner picture's width, so collect them
// once here to keep the resize handler from searching the widget tree on every pass.
fixedWidthChildren = {bannerCardDisplayWidget, uploadTimeLabel, colorIdentityWidget, tagsFlowWidget};
}
void PublicDeckPreviewWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
if (bannerCardDisplayWidget == nullptr) {
return;
}
const int width = bannerCardDisplayWidget->width();
if (width == lastKnownBannerWidth) {
return;
}
lastKnownBannerWidth = width;
for (QWidget *widget : fixedWidthChildren) {
widget->setMaximumWidth(width);
}
}
void PublicDeckPreviewWidget::setEntry(const RemotePublicDecksModel::DeckEntry &entry)
{
deckId = entry.id;
hasColorIdentity = !entry.colorIdentity.isEmpty();
colorIdentityWidget->setColorIdentity(entry.colorIdentity);
updateColorIdentityVisibility();
const ExactCard bannerCard =
entry.bannerCardName.isEmpty()
? ExactCard()
: CardDatabaseManager::query()->getCard(CardRef{entry.bannerCardName, entry.bannerCardProvider});
bannerCardDisplayWidget->setCard(bannerCard);
// The deck name is the overlay text on the banner, like the local preview.
bannerCardDisplayWidget->setOverlayText(entry.name);
// The deck name comes from another user's record, and Qt tooltips are
// rendered as AutoText, so escape and bound it to keep it readable text
// (the overlay painted onto the banner is already a plain painter draw).
setToolTip(entry.name.left(200).toHtmlEscaped());
setBaseAccessibleName(entry.name);
tagsFlowWidget->clearLayout();
for (const QString &tag : entry.tags) {
auto *chip = new DeckPreviewTagDisplayWidget(tagsFlowWidget, tag);
chip->setAttribute(Qt::WA_TransparentForMouseEvents);
tagsFlowWidget->addWidget(chip);
}
hasTags = !entry.tags.isEmpty();
updateTagsVisibility();
uploadTimeLabel->setText(tr("Uploaded %1").arg(entry.uploadTime.toString(Qt::TextDate)));
hasUploadTime = !entry.uploadTime.isNull();
updateUploadTimeVisibility();
}
void PublicDeckPreviewWidget::updateColorIdentityVisibility()
{
colorIdentityWidget->setVisible(
hasColorIdentity && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity());
}
void PublicDeckPreviewWidget::updateTagsVisibility()
{
tagsFlowWidget->setVisible(
hasTags && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews());
}
void PublicDeckPreviewWidget::updateUploadTimeVisibility()
{
uploadTimeLabel->setVisible(hasUploadTime &&
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime());
}
void PublicDeckPreviewWidget::keyPressEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return) {
event->accept();
emit openDeckRequested(deckId);
return;
}
QWidget::keyPressEvent(event);
}
void PublicDeckPreviewWidget::setBaseAccessibleName(const QString &name)
{
baseAccessibleName = name;
setAccessibleName(name);
}
void PublicDeckPreviewWidget::setScaleFactor(int scale)
{
bannerCardDisplayWidget->setScaleFactor(scale);
}
void PublicDeckPreviewWidget::imageClickedEvent(QMouseEvent * /*event*/, DeckPreviewCardPictureWidget * /*instance*/)
{
// Reserved: clicking could show a card popup for the banner card.
}
void PublicDeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent * /*event*/,
DeckPreviewCardPictureWidget * /*instance*/)
{
emit openDeckRequested(deckId);
}

View file

@ -0,0 +1,75 @@
/**
* @file public_deck_preview_widget.h
* @ingroup VisualDeckPreviewWidgets
*/
#ifndef PUBLIC_DECK_PREVIEW_WIDGET_H
#define PUBLIC_DECK_PREVIEW_WIDGET_H
#include "../remote_public_decks_model.h"
#include <QList>
#include <QString>
#include <QWidget>
class ColorIdentityWidget;
class DeckPreviewCardPictureWidget;
class FlowWidget;
class QKeyEvent;
class QLabel;
class QMouseEvent;
class QResizeEvent;
/**
* @brief A preview tile for a public deck published by another user.
*
* Renders the banner card picture (looked up by name/provider in the card
* database) with the deck name overlaid, the color identity, the deck's tags
* (read-only) and its upload time, all from the metadata the server stores for
* the deck, so no deck list is downloaded until the user actually opens the
* deck. Double-clicking the banner requests opening it.
*/
class PublicDeckPreviewWidget final : public QWidget
{
Q_OBJECT
public:
explicit PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry);
void setEntry(const RemotePublicDecksModel::DeckEntry &entry);
/** @brief Sets the accessible name announced to assistive technologies. */
void setBaseAccessibleName(const QString &name);
/** @brief Scales the banner card picture, mirroring the Visual Deck Storage. */
void setScaleFactor(int scale);
signals:
void openDeckRequested(int deckId);
protected:
void resizeEvent(QResizeEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private slots:
void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
void updateColorIdentityVisibility();
void updateTagsVisibility();
void updateUploadTimeVisibility();
private:
int deckId = 0;
QString baseAccessibleName;
bool hasColorIdentity = false;
bool hasTags = false;
bool hasUploadTime = false;
int lastKnownBannerWidth = 0;
QList<QWidget *> fixedWidthChildren;
DeckPreviewCardPictureWidget *bannerCardDisplayWidget;
ColorIdentityWidget *colorIdentityWidget;
FlowWidget *tagsFlowWidget;
QLabel *uploadTimeLabel;
};
#endif // PUBLIC_DECK_PREVIEW_WIDGET_H

View file

@ -0,0 +1,220 @@
#include "remote_public_decks_model.h"
#include "../../../client/settings/cache_settings.h"
#include <QTimer>
#include <algorithm>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_list_other_user.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_deckstorage.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/network_settings.h>
RemotePublicDecksModel::RemotePublicDecksModel(AbstractClient *_client, QObject *parent)
: QAbstractListModel(parent), client(_client)
{
// The ping sweep can drop a pending command without ever emitting finished,
// so loading must not be a latch: time it out and clear it when the client
// goes away, or the tab is stuck on the loading state for the session.
loadingTimeoutTimer = new QTimer(this);
loadingTimeoutTimer->setSingleShot(true);
loadingTimeoutTimer->setInterval(
static_cast<int>((static_cast<qint64>(SettingsCache::instance().network().getTimeOut()) + 1) *
SettingsCache::instance().network().getKeepAlive() * 1000));
connect(loadingTimeoutTimer, &QTimer::timeout, this, &RemotePublicDecksModel::onLoadingTimeout);
connect(client, &AbstractClient::statusChanged, this, [this](ClientStatus status) {
if (status == StatusDisconnected) {
loadingTimeoutTimer->stop();
setLoading(false);
}
});
}
int RemotePublicDecksModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : visibleIndices.size();
}
QVariant RemotePublicDecksModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= visibleIndices.size()) {
return QVariant();
}
if (role == Qt::DisplayRole || role == Qt::ToolTipRole) {
return decks.at(visibleIndices.at(index.row())).name;
}
return QVariant();
}
RemotePublicDecksModel::DeckEntry RemotePublicDecksModel::entryAt(int row) const
{
if (row < 0 || row >= visibleIndices.size()) {
return DeckEntry{};
}
return decks.at(visibleIndices.at(row));
}
void RemotePublicDecksModel::setSearchText(const QString &text)
{
searchText = text.trimmed();
rebuildVisibleIndices();
}
void RemotePublicDecksModel::setColorFilter(VisualDeckStorageSortFilterProxyModel::FilterMode mode,
const QSet<QChar> &colors)
{
colorFilterMode = mode;
activeColors = colors;
rebuildVisibleIndices();
}
void RemotePublicDecksModel::setTagFilter(const QSet<QString> &selected, const QSet<QString> &excluded)
{
includedTags = selected;
excludedTags = excluded;
rebuildVisibleIndices();
}
QSet<QString> RemotePublicDecksModel::allTags() const
{
QSet<QString> all;
for (const DeckEntry &entry : decks) {
all.unite(QSet<QString>(entry.tags.cbegin(), entry.tags.cend()));
}
return all;
}
void RemotePublicDecksModel::rebuildVisibleIndices()
{
QList<int> newIndices;
newIndices.reserve(decks.size());
for (int row = 0; row < decks.size(); ++row) {
const DeckEntry &entry = decks.at(row);
if (!searchText.isEmpty() && !entry.name.contains(searchText, Qt::CaseInsensitive)) {
continue;
}
if (!activeColors.isEmpty()) {
const QString &identity = entry.colorIdentity;
if (!colorIdentityMatches(colorFilterMode, activeColors, identity)) {
continue;
}
}
if (!includedTags.isEmpty()) {
const QSet<QString> entryTags(entry.tags.cbegin(), entry.tags.cend());
bool hasAll = std::all_of(includedTags.begin(), includedTags.end(),
[&entryTags](const QString &tag) { return entryTags.contains(tag); });
if (!hasAll) {
continue;
}
}
if (!excludedTags.isEmpty() && std::any_of(excludedTags.begin(), excludedTags.end(),
[&entry](const QString &tag) { return entry.tags.contains(tag); })) {
continue;
}
newIndices.append(row);
}
beginResetModel();
visibleIndices = newIndices;
endResetModel();
}
void RemotePublicDecksModel::refresh(const QString &userName)
{
if (loading) {
return;
}
// Every refresh captures its own request id so a reply that lands after its
// loading timeout (the reverse of the ping sweep dropping the command) is
// recognised as stale: it must not stop the newer request's timer or paint
// the grid with out-of-date data.
const int seq = ++requestSequence;
setLoading(true);
loadingTimeoutTimer->start();
Command_DeckListOtherUser cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this,
[this, seq](const Response &response, const CommandContainer &commandContainer) {
if (seq != requestSequence) {
return; // a newer refresh superseded this one
}
decksReceived(response, commandContainer);
});
client->sendCommand(pend);
}
void RemotePublicDecksModel::onLoadingTimeout()
{
setLoading(false);
emit loadFailed(tr("The server did not respond in time. Try again."));
}
void RemotePublicDecksModel::clear()
{
decks.clear();
rebuildVisibleIndices();
}
void RemotePublicDecksModel::setLoading(bool value)
{
if (loading == value) {
return;
}
loading = value;
emit loadingChanged(loading);
}
void RemotePublicDecksModel::decksReceived(const Response &response, const CommandContainer & /*commandContainer*/)
{
setLoading(false);
loadingTimeoutTimer->stop();
if (response.response_code() != Response::RespOk) {
emit loadFailed(tr("Failed to load the user's public decks (server response code %1).")
.arg(QString::number(static_cast<int>(response.response_code()))));
return;
}
const Response_DeckList &resp = response.GetExtension(Response_DeckList::ext);
decks.clear();
addFolder(resp.root());
rebuildVisibleIndices();
}
void RemotePublicDecksModel::addFolder(const ServerInfo_DeckStorage_Folder &folder)
{
const int itemCount = folder.items_size();
for (int i = 0; i < itemCount; ++i) {
addTreeItem(folder.items(i));
}
}
void RemotePublicDecksModel::addTreeItem(const ServerInfo_DeckStorage_TreeItem &item)
{
if (item.has_folder()) {
addFolder(item.folder());
return;
}
const ServerInfo_DeckStorage_File &file = item.file();
DeckEntry entry;
entry.id = item.id();
entry.name = QString::fromStdString(item.name());
entry.uploadTime = QDateTime::fromSecsSinceEpoch(file.creation_time());
entry.bannerCardName = QString::fromStdString(file.banner_card_name());
entry.bannerCardProvider = QString::fromStdString(file.banner_card_provider());
entry.colorIdentity = QString::fromStdString(file.color_identity());
QStringList tags;
for (const auto &tag : file.tags()) {
tags.append(QString::fromStdString(tag));
}
entry.tags = tags;
decks.append(entry);
}

View file

@ -0,0 +1,129 @@
/**
* @file remote_public_decks_model.h
* @ingroup DeckStorageWidgets
*/
#ifndef REMOTE_PUBLIC_DECKS_MODEL_H
#define REMOTE_PUBLIC_DECKS_MODEL_H
#include "visual_deck_storage_sort_filter_proxy_model.h"
#include <QAbstractListModel>
#include <QDateTime>
#include <QList>
#include <QSet>
#include <QStringList>
class AbstractClient;
class CommandContainer;
class QTimer;
class Response;
class ServerInfo_DeckStorage_Folder;
class ServerInfo_DeckStorage_TreeItem;
/**
* @brief Flat, read-only list of the public decks published by another user.
*
* Fetches the target user's public decks via Command_DeckListOtherUser and
* flattens the response tree into entries carrying the preview metadata stored
* on the server (banner card name/provider and color identity). No deck list is
* downloaded until the user actually opens a deck.
*
* Name and color-identity filtering is applied against this metadata, mirroring
* the Visual Deck Storage's filter semantics, so the grid can be narrowed like
* the local deck storage.
*/
class RemotePublicDecksModel : public QAbstractListModel
{
Q_OBJECT
public:
struct DeckEntry
{
int id = 0;
QString name;
QDateTime uploadTime;
QString bannerCardName;
QString bannerCardProvider;
QString colorIdentity;
QStringList tags;
};
/**
* @brief The color identity filter mode, shared with the Visual Deck Storage.
*/
using FilterMode = VisualDeckStorageSortFilterProxyModel::FilterMode;
explicit RemotePublicDecksModel(AbstractClient *client, QObject *parent = nullptr);
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/** @brief Fetches the public decks of another user, replacing the current contents. */
void refresh(const QString &userName);
void clear();
/** @brief Sets a case-insensitive substring filter on the deck name. */
void setSearchText(const QString &text);
/** @brief Sets the active color identity filter and mode. */
void setColorFilter(FilterMode mode, const QSet<QChar> &colors);
/** @brief Filters decks by required (`selected`) and forbidden (`excluded`) tags. */
void setTagFilter(const QSet<QString> &selected, const QSet<QString> &excluded);
/** @brief All tags present across all loaded decks, for building filter chips. */
[[nodiscard]] QSet<QString> allTags() const;
/** @brief The number of decks after filtering. */
[[nodiscard]] int filteredCount() const
{
return visibleIndices.size();
}
/** @brief The number of decks before filtering. */
[[nodiscard]] int totalCount() const
{
return decks.size();
}
/** @brief True while a refresh request is in flight and the grid has no data yet. */
[[nodiscard]] bool isLoading() const
{
return loading;
}
[[nodiscard]] DeckEntry entryAt(int row) const;
signals:
/** @brief Emitted when a refresh starts, completes, or fails (see loading()). */
void loadingChanged(bool loading);
/** @brief Emitted when the last refresh failed; contains a user-facing message. */
void loadFailed(const QString &message);
private slots:
void decksReceived(const Response &response, const CommandContainer &commandContainer);
void onLoadingTimeout();
private:
void addFolder(const ServerInfo_DeckStorage_Folder &folder);
void addTreeItem(const ServerInfo_DeckStorage_TreeItem &item);
void rebuildVisibleIndices();
void setLoading(bool value);
AbstractClient *client;
QTimer *loadingTimeoutTimer;
QList<DeckEntry> decks;
QList<int> visibleIndices; ///< Row indices into `decks` that pass the current filters.
bool loading = false;
int requestSequence = 0; ///< Monotonically increases per refresh; only the newest request may update the grid.
QString searchText;
VisualDeckStorageSortFilterProxyModel::FilterMode colorFilterMode = VisualDeckStorageSortFilterProxyModel::Includes;
QSet<QChar> activeColors;
QSet<QString> includedTags;
QSet<QString> excludedTags;
};
#endif // REMOTE_PUBLIC_DECKS_MODEL_H

View file

@ -50,6 +50,15 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg
&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews);
// show upload time on DeckPreviewWidget checkbox
showUploadTimeCheckBox = new QCheckBox(this);
showUploadTimeCheckBox->setChecked(
SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime());
connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
&VisualDeckStorageQuickSettingsWidget::showUploadTimeChanged);
connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime);
// show banner card selector checkbox
showBannerCardComboBoxCheckBox = new QCheckBox(this);
showBannerCardComboBoxCheckBox->setChecked(
@ -94,7 +103,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg
unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacitySpinBox);
// tooltip selector
auto deckPreviewTooltipWidget = new QWidget(this);
deckPreviewTooltipWidget = new QWidget(this);
deckPreviewTooltipLabel = new QLabel(deckPreviewTooltipWidget);
deckPreviewTooltipComboBox = new QComboBox(deckPreviewTooltipWidget);
@ -128,6 +137,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg
this->addSettingsWidget(showTagFilterCheckBox);
this->addSettingsWidget(showColorIdentityCheckBox);
this->addSettingsWidget(showTagsOnDeckPreviewsCheckBox);
this->addSettingsWidget(showUploadTimeCheckBox);
this->addSettingsWidget(showBannerCardComboBoxCheckBox);
this->addSettingsWidget(drawUnusedColorIdentitiesCheckBox);
this->addSettingsWidget(unusedColorIdentityOpacityWidget);
@ -145,6 +155,7 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi()
showTagFilterCheckBox->setText(tr("Show Tag Filter"));
showColorIdentityCheckBox->setText(tr("Show Color Identity"));
showTagsOnDeckPreviewsCheckBox->setText(tr("Show Tags On Deck Previews"));
showUploadTimeCheckBox->setText(tr("Show Upload Time"));
showBannerCardComboBoxCheckBox->setText(tr("Show Banner Card Selection Option"));
drawUnusedColorIdentitiesCheckBox->setText(tr("Draw unused Color Identities"));
unusedColorIdentitiesOpacityLabel->setText(tr("Unused Color Identities Opacity"));
@ -155,6 +166,14 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi()
deckPreviewTooltipComboBox->setItemText(1, tr("Filepath"));
}
void VisualDeckStorageQuickSettingsWidget::setPublicDecksMode(bool enabled)
{
const bool hidden = enabled;
showFoldersCheckBox->setVisible(!hidden);
showBannerCardComboBoxCheckBox->setVisible(!hidden);
deckPreviewTooltipWidget->setVisible(!hidden);
}
bool VisualDeckStorageQuickSettingsWidget::getShowFolders() const
{
return showFoldersCheckBox->isChecked();
@ -185,6 +204,11 @@ bool VisualDeckStorageQuickSettingsWidget::getShowTagsOnDeckPreviews() const
return showTagsOnDeckPreviewsCheckBox->isChecked();
}
bool VisualDeckStorageQuickSettingsWidget::getShowUploadTime() const
{
return showUploadTimeCheckBox->isChecked();
}
int VisualDeckStorageQuickSettingsWidget::getUnusedColorIdentitiesOpacity() const
{
return unusedColorIdentitiesOpacitySpinBox->value();

View file

@ -27,10 +27,12 @@ class VisualDeckStorageQuickSettingsWidget : public SettingsButtonWidget
QCheckBox *showBannerCardComboBoxCheckBox;
QCheckBox *showTagFilterCheckBox;
QCheckBox *showTagsOnDeckPreviewsCheckBox;
QCheckBox *showUploadTimeCheckBox;
QLabel *unusedColorIdentitiesOpacityLabel;
QSpinBox *unusedColorIdentitiesOpacitySpinBox;
QLabel *deckPreviewTooltipLabel;
QComboBox *deckPreviewTooltipComboBox;
QWidget *deckPreviewTooltipWidget;
CardSizeWidget *cardSizeWidget;
public:
@ -46,6 +48,15 @@ public:
explicit VisualDeckStorageQuickSettingsWidget(QWidget *parent = nullptr);
/**
* @brief Hides the controls that do not apply to the public decks tab.
*
* The public decks tab reuses this widget for its quick settings menu but
* has no folders, banner selection or per-deck tooltip, so those controls
* are hidden while every shared key keeps syncing with SettingsCache.
*/
void setPublicDecksMode(bool enabled);
void retranslateUi();
[[nodiscard]] bool getShowFolders() const;
@ -54,6 +65,7 @@ public:
[[nodiscard]] bool getShowBannerCardComboBox() const;
[[nodiscard]] bool getShowTagFilter() const;
[[nodiscard]] bool getShowTagsOnDeckPreviews() const;
[[nodiscard]] bool getShowUploadTime() const;
[[nodiscard]] int getUnusedColorIdentitiesOpacity() const;
[[nodiscard]] TooltipType getDeckPreviewTooltip() const;
[[nodiscard]] int getCardSize() const;
@ -65,6 +77,7 @@ signals:
void showBannerCardComboBoxChanged(bool enabled);
void showTagFilterChanged(bool enabled);
void showTagsOnDeckPreviewsChanged(bool enabled);
void showUploadTimeChanged(bool enabled);
void unusedColorIdentitiesOpacityChanged(int opacity);
void deckPreviewTooltipChanged(TooltipType tooltip);
void cardSizeChanged(int scale);

View file

@ -11,6 +11,34 @@ VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QOb
setDynamicSortFilter(false);
}
bool colorIdentityMatches(VisualDeckStorageSortFilterProxyModel::FilterMode mode,
const QSet<QChar> &colors,
const QString &identity)
{
switch (mode) {
case VisualDeckStorageSortFilterProxyModel::ExactMatch: {
QSet<QChar> activeColorSet;
for (const QChar &color : colors) {
activeColorSet.insert(color.toUpper());
}
QSet<QChar> colorIdentitySet;
for (const QChar &color : identity) {
colorIdentitySet.insert(color.toUpper());
}
return activeColorSet == colorIdentitySet;
}
case VisualDeckStorageSortFilterProxyModel::Includes:
return std::all_of(colors.begin(), colors.end(),
[&identity](const QChar &color) { return identity.contains(color); });
case VisualDeckStorageSortFilterProxyModel::Excludes:
return std::none_of(colors.begin(), colors.end(),
[&identity](const QChar &color) { return identity.contains(color); });
}
return false;
}
void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *model)
{
if (QAbstractItemModel *oldModel = sourceModel()) {
@ -255,34 +283,7 @@ void VisualDeckStorageSortFilterProxyModel::updateColorMatches()
for (int row = 0; row < count; ++row) {
const QString colorIdentity = source->dataForRow(row).colorIdentity;
bool matches = true;
switch (colorFilterMode) {
case ExactMatch: {
QSet<QChar> activeColorSet;
for (const QChar &color : activeColors) {
activeColorSet.insert(color.toUpper());
}
QSet<QChar> colorIdentitySet;
for (const QChar &color : colorIdentity) {
colorIdentitySet.insert(color.toUpper());
}
matches = activeColorSet == colorIdentitySet;
break;
}
case Includes:
matches = std::all_of(activeColors.begin(), activeColors.end(),
[&colorIdentity](const QChar &color) { return colorIdentity.contains(color); });
break;
case Excludes:
matches = std::none_of(activeColors.begin(), activeColors.end(),
[&colorIdentity](const QChar &color) { return colorIdentity.contains(color); });
break;
}
colorMatches[row] = matches;
colorMatches[row] = colorIdentityMatches(colorFilterMode, activeColors, colorIdentity);
}
}

View file

@ -102,4 +102,14 @@ private:
QList<bool> colorMatches; ///< Per-row color identity match.
};
/**
* @brief Whether an identity string matches the active color-identity filter.
*
* The single source of truth for the color identity matching rule, shared by
* the Visual Deck Storage proxy and the remote public decks model.
*/
[[nodiscard]] bool colorIdentityMatches(VisualDeckStorageSortFilterProxyModel::FilterMode mode,
const QSet<QChar> &colors,
const QString &identity);
#endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H