mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-22 17:45:09 -07:00
[DeckShare] Open shared decks via links with a gated preview flow (#7244)
* [DeckShare] Open shared decks via links with a gated preview flow - Serialized url-chain dispatcher in IntentUrlParser; queue-drained urlChainFinished(bool) drives the startup auto-connect fallback - Open-shared-deck intent with sequential download state machine, 15s per-item timeout, partial-success offer, livable Cancel via ApplicationModal dlg_login_prompt interactive fallback - Preview dialog: download progress label, share vocab sweep, palette-highlight selection frame, Space/Enter keyboard toggle, NoFocus checkbox, double-click tile opens immediately - Confirm-before-server-migration with one-shot restore to the previous server on failed/cancelled chains (statusChanged settle deferral), hostname-only identity comparisons - Skip credential link when already connected; arrow-key navigation in FlowWidget; card glows use palette highlight - Address code-review M1-M4 and UI/UX QA blockers 1-2 * [DeckShare] End the open-shared-deck files with a trailing newline * [DeckShare] Forward a dependency's cancellation as the owner's own * [DeckShare] Let intent chains opt into the link sign-in dialog * [DeckShare] Track link-intent chains per-run so each can restore its own session * [Settings] Match a server on the exact host and port when adding it * [DeckShare] Confirm the share link's target server before opening a deck * [DeckShare] Reformat the link sign-in intent constructor * [DeckShare] Time the share-list round trip and backstop silently-destroyed intent chains * [Client] Drain a single-instance payload before its handlers read the socket again * [Client] Treat a busy single-instance primary as alive instead of stealing its socket * [DeckShare] Keep arrow-key navigation between flow items inside a scroll area * [Client] Skip the startup connection when a macOS URL launch owns the connection * [Client] Redact share secrets from activation URL logs * [Client] Make the link-connection gates port-aware and keyboard-safe Second-pass review notes for the shared-deck link flow (Cockatrice#7244): - FlowWidget arrow-key navigation is opt-in via addNavigableWidget, so combo/spin controls on the analytics flows keep their own arrow keys - isConnectedTo and the open-deck/join-game preconditions compare the configured server port alongside the host, so a same-host/different-port link cannot resolve its share token or game id on the wrong instance - the link sign-in dialog reuses an existing server entry's saved name instead of renaming it to the raw hostname - skipStartupAutoConnect is cleared once the launch chain connects, so a later mid-session declined link cannot fire the startup fallback - the plain-launch path of SingleInstanceManager no longer blocks on the primary's ACK - link- and server-supplied text is html-escaped in the confirm prompts and shared-deck preview so markup cannot spoof the shown messages --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
parent
ba2900dcb9
commit
8ca749c07d
29 changed files with 1666 additions and 86 deletions
|
|
@ -133,12 +133,14 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event)
|
|||
path.addRoundedRect(glowRect, radius, radius);
|
||||
|
||||
// Soft outer glow
|
||||
QColor glowColor(0, 150, 255, 80); // subtle blu
|
||||
QColor glowColor = palette().color(QPalette::Highlight);
|
||||
glowColor.setAlpha(80);
|
||||
painter.setPen(QPen(glowColor, 6));
|
||||
painter.drawPath(path);
|
||||
|
||||
// Thin inner border for crispness
|
||||
QColor borderColor(0, 150, 255, 200);
|
||||
QColor borderColor = palette().color(QPalette::Highlight);
|
||||
borderColor.setAlpha(200);
|
||||
painter.setPen(QPen(borderColor, 2));
|
||||
painter.drawRoundedRect(pixmapRect, radius, radius);
|
||||
|
||||
|
|
|
|||
|
|
@ -27,14 +27,16 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent,
|
|||
const QColor &textColor,
|
||||
const QColor &outlineColor,
|
||||
const int fontSize,
|
||||
const Qt::Alignment alignment)
|
||||
const Qt::Alignment alignment,
|
||||
const bool _emitClickImmediately)
|
||||
: CardInfoPictureWithTextOverlayWidget(parent,
|
||||
hoverToZoomEnabled,
|
||||
raiseOnEnter,
|
||||
textColor,
|
||||
outlineColor,
|
||||
fontSize,
|
||||
alignment)
|
||||
alignment),
|
||||
emitClickImmediately(_emitClickImmediately)
|
||||
{
|
||||
singleClickTimer = new QTimer(this);
|
||||
singleClickTimer->setSingleShot(true);
|
||||
|
|
@ -50,8 +52,13 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent,
|
|||
void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
lastMouseEvent = event;
|
||||
singleClickTimer->start(QApplication::doubleClickInterval());
|
||||
if (emitClickImmediately) {
|
||||
emit imageClicked(event, this);
|
||||
emit imageSingleClicked();
|
||||
} else {
|
||||
lastMouseEvent = event;
|
||||
singleClickTimer->start(QApplication::doubleClickInterval());
|
||||
}
|
||||
} else {
|
||||
emit imageClicked(event, this);
|
||||
event->accept();
|
||||
|
|
@ -61,7 +68,14 @@ void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event)
|
|||
void DeckPreviewCardPictureWidget::mouseDoubleClickEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
singleClickTimer->stop(); // Prevent single-click logic
|
||||
emit imageDoubleClicked(lastMouseEvent, this);
|
||||
if (emitClickImmediately) {
|
||||
// Do not report a second single click for the second press of the
|
||||
// double-click; the consumer maps the double-click to select+open.
|
||||
lastMouseEvent = event;
|
||||
emit imageDoubleClicked(event, this);
|
||||
} else {
|
||||
singleClickTimer->stop(); // Prevent single-click logic
|
||||
emit imageDoubleClicked(lastMouseEvent, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,28 @@ class DeckPreviewCardPictureWidget final : public CardInfoPictureWithTextOverlay
|
|||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Constructs a DeckPreviewCardPictureWidget.
|
||||
* @param parent The parent widget.
|
||||
* @param hoverToZoomEnabled If this widget will spawn a larger widget when hovered over.
|
||||
* @param raiseOnEnter If the widget raises its border when the mouse enters.
|
||||
* @param textColor The color of the overlay text.
|
||||
* @param outlineColor The color of the outline around the text.
|
||||
* @param fontSize The font size of the overlay text.
|
||||
* @param alignment The alignment of the text within the overlay.
|
||||
* @param emitClickImmediately If true, a left click is reported immediately on click
|
||||
* instead of after the double-click interval. Use this for selection surfaces
|
||||
* where reacting to a double-click (select-and-open) would needlessly delay the
|
||||
* single-click feedback. The double-click signal is still emitted.
|
||||
*/
|
||||
explicit DeckPreviewCardPictureWidget(QWidget *parent,
|
||||
bool hoverToZoomEnabled = false,
|
||||
bool raiseOnEnter = false,
|
||||
const QColor &textColor = Qt::white,
|
||||
const QColor &outlineColor = Qt::black,
|
||||
int fontSize = 12,
|
||||
Qt::Alignment alignment = Qt::AlignCenter);
|
||||
Qt::Alignment alignment = Qt::AlignCenter,
|
||||
bool _emitClickImmediately = false);
|
||||
|
||||
signals:
|
||||
void imageClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
|
||||
|
|
@ -36,6 +51,7 @@ signals:
|
|||
private:
|
||||
QTimer *singleClickTimer;
|
||||
QMouseEvent *lastMouseEvent = nullptr; // Store the last mouse event
|
||||
bool emitClickImmediately;
|
||||
|
||||
protected:
|
||||
void mousePressEvent(QMouseEvent *event) override;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
#include "shared_deck_preview_widget.h"
|
||||
|
||||
#include "../cards/additional_info/color_identity_widget.h"
|
||||
#include "../cards/deck_preview_card_picture_widget.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeyEvent>
|
||||
#include <QLabel>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/card/database/card_database_querier.h>
|
||||
|
||||
SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent,
|
||||
const CardDatabaseQuerier *querier,
|
||||
const QString &deckName,
|
||||
const QString &bannerCardName,
|
||||
const QString &colorIdentity,
|
||||
const QString &gameFormat,
|
||||
const QString &deckToolTip)
|
||||
: QWidget(parent)
|
||||
{
|
||||
bannerCardDisplayWidget =
|
||||
new DeckPreviewCardPictureWidget(this, false, false, Qt::white, Qt::black, 12, Qt::AlignCenter, true);
|
||||
bannerCardDisplayWidget->setScaleFactor(100);
|
||||
const ExactCard bannerCard = bannerCardName.isEmpty() ? ExactCard() : querier->getCard(CardRef{bannerCardName, {}});
|
||||
bannerCardDisplayWidget->setCard(bannerCard);
|
||||
bannerCardDisplayWidget->setOverlayText(deckName);
|
||||
setToolTip(deckToolTip.isEmpty() ? deckName : deckToolTip);
|
||||
setFocusPolicy(Qt::StrongFocus);
|
||||
setBaseAccessibleName(deckName);
|
||||
|
||||
colorIdentityWidget = new ColorIdentityWidget(this, colorIdentity);
|
||||
colorIdentityWidget->setVisible(!colorIdentity.isEmpty());
|
||||
|
||||
// gameFormat is server-supplied and the QLabel renders AutoText, so escape it.
|
||||
gameFormatLabel = new QLabel(gameFormat.toHtmlEscaped(), this);
|
||||
gameFormatLabel->setAlignment(Qt::AlignCenter);
|
||||
gameFormatLabel->setVisible(!gameFormat.isEmpty());
|
||||
|
||||
selectionCheckBox = new QCheckBox(this);
|
||||
selectionCheckBox->setToolTip(tr("Select this deck"));
|
||||
// The tile itself is focusable (Space/Enter toggles); keep the checkbox
|
||||
// from creating a second tab stop per tile.
|
||||
selectionCheckBox->setFocusPolicy(Qt::NoFocus);
|
||||
|
||||
// Selection frame reused from the deck-preview selection covenant: a
|
||||
// palette(highlight) border around the banner card, shown while selected.
|
||||
selectionFrame = new QFrame(bannerCardDisplayWidget);
|
||||
selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
selectionFrame->setStyleSheet(QStringLiteral(
|
||||
"QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }"));
|
||||
selectionFrame->setVisible(false);
|
||||
|
||||
auto *selectionRow = new QHBoxLayout;
|
||||
selectionRow->addWidget(selectionCheckBox);
|
||||
selectionRow->addStretch(1);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
layout->addLayout(selectionRow);
|
||||
layout->addWidget(bannerCardDisplayWidget, 0, Qt::AlignHCenter);
|
||||
layout->addWidget(colorIdentityWidget, 0, Qt::AlignHCenter);
|
||||
layout->addWidget(gameFormatLabel, 0, Qt::AlignHCenter);
|
||||
setLayout(layout);
|
||||
|
||||
connect(selectionCheckBox, &QCheckBox::toggled, this, [this](bool checked) {
|
||||
updateSelectionVisual(checked);
|
||||
emit selectionToggled(checked);
|
||||
});
|
||||
connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this,
|
||||
&SharedDeckPreviewWidget::toggleSelection);
|
||||
connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this,
|
||||
&SharedDeckPreviewWidget::activate);
|
||||
}
|
||||
|
||||
bool SharedDeckPreviewWidget::isSelected() const
|
||||
{
|
||||
return selectionCheckBox->isChecked();
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::setSelected(bool selected)
|
||||
{
|
||||
if (isSelected() == selected) {
|
||||
return;
|
||||
}
|
||||
selectionCheckBox->setChecked(selected);
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::updateSelectionVisual(bool selected)
|
||||
{
|
||||
selectionFrame->setVisible(selected);
|
||||
selectionFrame->raise();
|
||||
if (selected) {
|
||||
setAccessibleName(baseAccessibleName + tr(" (selected)"));
|
||||
} else {
|
||||
setAccessibleName(baseAccessibleName);
|
||||
}
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::setBaseAccessibleName(const QString &name)
|
||||
{
|
||||
baseAccessibleName = name;
|
||||
setAccessibleName(name);
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::toggleSelection()
|
||||
{
|
||||
setSelected(!isSelected());
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::activate()
|
||||
{
|
||||
setSelected(true);
|
||||
emit activated();
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::resizeEvent(QResizeEvent *event)
|
||||
{
|
||||
QWidget::resizeEvent(event);
|
||||
updateSelectionFrameGeometry();
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::updateSelectionFrameGeometry()
|
||||
{
|
||||
if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) {
|
||||
return;
|
||||
}
|
||||
selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1));
|
||||
}
|
||||
|
||||
void SharedDeckPreviewWidget::keyPressEvent(QKeyEvent *event)
|
||||
{
|
||||
if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) {
|
||||
toggleSelection();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
QWidget::keyPressEvent(event);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* @file shared_deck_preview_widget.h
|
||||
* @ingroup DeckShareWidgets
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef SHARED_DECK_PREVIEW_WIDGET_H
|
||||
#define SHARED_DECK_PREVIEW_WIDGET_H
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class ColorIdentityWidget;
|
||||
class DeckPreviewCardPictureWidget;
|
||||
class QCheckBox;
|
||||
class QFrame;
|
||||
class QKeyEvent;
|
||||
class QLabel;
|
||||
class QResizeEvent;
|
||||
class CardDatabaseQuerier;
|
||||
|
||||
/**
|
||||
* @brief A selectable preview tile for a deck that has no local file.
|
||||
*
|
||||
* Renders a banner card picture (looked up by name in the card database), the
|
||||
* deck name, color identity and game format. Used to preview decks shared via a
|
||||
* cockatrice:// link (metadata from Command_DeckShareList) and the deck
|
||||
* currently open in the deck editor.
|
||||
*
|
||||
* Selection follows the deck-preview covenant: the tile reports its click
|
||||
* immediately (no double-click interval delay), a palette(highlight) frame
|
||||
* marks the selected tile, and Space/Enter toggles selection from the keyboard.
|
||||
* A double click selects the tile and emits activated() so the caller can open
|
||||
* just that deck.
|
||||
*/
|
||||
class SharedDeckPreviewWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SharedDeckPreviewWidget(QWidget *parent,
|
||||
const CardDatabaseQuerier *querier,
|
||||
const QString &deckName,
|
||||
const QString &bannerCardName,
|
||||
const QString &colorIdentity,
|
||||
const QString &gameFormat = QString(),
|
||||
const QString &deckToolTip = QString());
|
||||
|
||||
[[nodiscard]] bool isSelected() const;
|
||||
void setSelected(bool selected);
|
||||
|
||||
void setBaseAccessibleName(const QString &name);
|
||||
|
||||
signals:
|
||||
void selectionToggled(bool selected);
|
||||
void activated();
|
||||
|
||||
protected:
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void toggleSelection();
|
||||
void activate();
|
||||
|
||||
private:
|
||||
void updateSelectionVisual(bool selected);
|
||||
void updateSelectionFrameGeometry();
|
||||
|
||||
DeckPreviewCardPictureWidget *bannerCardDisplayWidget;
|
||||
ColorIdentityWidget *colorIdentityWidget;
|
||||
QLabel *gameFormatLabel;
|
||||
QCheckBox *selectionCheckBox;
|
||||
QFrame *selectionFrame;
|
||||
QString baseAccessibleName;
|
||||
};
|
||||
|
||||
#endif // SHARED_DECK_PREVIEW_WIDGET_H
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
#include "dlg_login_prompt.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
DlgLoginPrompt::DlgLoginPrompt(const QString &serverText, QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setWindowTitle(tr("Sign in"));
|
||||
|
||||
auto *mainLayout = new QVBoxLayout(this);
|
||||
mainLayout->addWidget(
|
||||
new QLabel(tr("This link requires you to be signed in.\nSign in to %1:").arg(serverText), this));
|
||||
|
||||
auto *formLayout = new QFormLayout;
|
||||
usernameEdit = new QLineEdit(this);
|
||||
passwordEdit = new QLineEdit(this);
|
||||
passwordEdit->setEchoMode(QLineEdit::Password);
|
||||
formLayout->addRow(tr("Username:"), usernameEdit);
|
||||
formLayout->addRow(tr("Password:"), passwordEdit);
|
||||
mainLayout->addLayout(formLayout);
|
||||
|
||||
savePasswordCheckBox = new QCheckBox(tr("Save password for this server"), this);
|
||||
mainLayout->addWidget(savePasswordCheckBox);
|
||||
|
||||
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
mainLayout->addWidget(buttonBox);
|
||||
|
||||
usernameEdit->setFocus();
|
||||
}
|
||||
|
||||
QString DlgLoginPrompt::username() const
|
||||
{
|
||||
return usernameEdit->text().trimmed();
|
||||
}
|
||||
|
||||
QString DlgLoginPrompt::password() const
|
||||
{
|
||||
return passwordEdit->text();
|
||||
}
|
||||
|
||||
bool DlgLoginPrompt::savePassword() const
|
||||
{
|
||||
return savePasswordCheckBox->isChecked();
|
||||
}
|
||||
40
cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h
Normal file
40
cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* @file dlg_login_prompt.h
|
||||
* @ingroup ConnectionDialogs
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef DLG_LOGIN_PROMPT_H
|
||||
#define DLG_LOGIN_PROMPT_H
|
||||
|
||||
#include <QDialog>
|
||||
|
||||
class QCheckBox;
|
||||
class QLineEdit;
|
||||
|
||||
/**
|
||||
* @brief Small sign-in dialog used when a cockatrice:// link needs credentials
|
||||
* that are not saved for the target server.
|
||||
*
|
||||
* The entered name and password are handed to the intent chain; when the user
|
||||
* opts to save them, they are stored in the server settings so that later links
|
||||
* to the same server connect seamlessly.
|
||||
*/
|
||||
class DlgLoginPrompt : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DlgLoginPrompt(const QString &serverText, QWidget *parent = nullptr);
|
||||
|
||||
[[nodiscard]] QString username() const;
|
||||
[[nodiscard]] QString password() const;
|
||||
[[nodiscard]] bool savePassword() const;
|
||||
|
||||
private:
|
||||
QLineEdit *usernameEdit;
|
||||
QLineEdit *passwordEdit;
|
||||
QCheckBox *savePasswordCheckBox;
|
||||
};
|
||||
|
||||
#endif // DLG_LOGIN_PROMPT_H
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
#include "dlg_shared_decks_preview.h"
|
||||
|
||||
#include "../deck_share/shared_deck_preview_widget.h"
|
||||
#include "../general/layout_containers/flow_widget.h"
|
||||
|
||||
#include <QCloseEvent>
|
||||
#include <QDateTime>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/card/database/card_database_querier.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_deck_share_item.pb.h>
|
||||
|
||||
DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent,
|
||||
const CardDatabaseQuerier *querier,
|
||||
const QString &shareName,
|
||||
qint64 expiresAt,
|
||||
const QString &serverText,
|
||||
const QList<ServerInfo_DeckShareItem> &items)
|
||||
: QDialog(parent)
|
||||
{
|
||||
setWindowTitle(tr("Open shared decks"));
|
||||
resize(700, 500);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout(this);
|
||||
|
||||
// shareName and serverText come from the share server, so escape them: the
|
||||
// QLabels render AutoText and markup would otherwise be shown as rich text.
|
||||
auto *titleLabel =
|
||||
new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this);
|
||||
QFont titleFont = titleLabel->font();
|
||||
titleFont.setBold(true);
|
||||
titleFont.setPointSize(titleFont.pointSize() + 2);
|
||||
titleLabel->setFont(titleFont);
|
||||
mainLayout->addWidget(titleLabel);
|
||||
|
||||
if (!serverText.isEmpty()) {
|
||||
mainLayout->addWidget(new QLabel(tr("From %1").arg(serverText.toHtmlEscaped()), this));
|
||||
}
|
||||
|
||||
if (expiresAt > 0) {
|
||||
const QString expiryText = QDateTime::fromSecsSinceEpoch(expiresAt).toLocalTime().toString(Qt::TextDate);
|
||||
mainLayout->addWidget(new QLabel(tr("This share link expires on %1").arg(expiryText), this));
|
||||
}
|
||||
|
||||
downloadStatusLabel = new QLabel(this);
|
||||
downloadStatusLabel->setVisible(false);
|
||||
mainLayout->addWidget(downloadStatusLabel);
|
||||
|
||||
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded);
|
||||
mainLayout->addWidget(flowWidget, 1);
|
||||
|
||||
for (const ServerInfo_DeckShareItem &item : items) {
|
||||
QStringList tags;
|
||||
for (const auto &tag : item.tags()) {
|
||||
tags.append(QString::fromStdString(tag));
|
||||
}
|
||||
|
||||
auto *tile = new SharedDeckPreviewWidget(
|
||||
this, querier, QString::fromStdString(item.name()), QString::fromStdString(item.banner_card()),
|
||||
QString::fromStdString(item.color_identity()), QString::fromStdString(item.game_format()), tags.join(", "));
|
||||
flowWidget->addNavigableWidget(tile);
|
||||
tiles.append(tile);
|
||||
itemIds.append(item.id());
|
||||
}
|
||||
|
||||
if (tiles.size() == 1) {
|
||||
tiles.first()->setSelected(true);
|
||||
}
|
||||
|
||||
auto *buttonBox = new QDialogButtonBox(this);
|
||||
openSelectedButton = buttonBox->addButton(tr("Open selected"), QDialogButtonBox::AcceptRole);
|
||||
openAllButton = buttonBox->addButton(tr("Open all"), QDialogButtonBox::ActionRole);
|
||||
buttonBox->addButton(tr("Cancel"), QDialogButtonBox::RejectRole);
|
||||
mainLayout->addWidget(buttonBox);
|
||||
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, [this]() {
|
||||
onCancel();
|
||||
close();
|
||||
});
|
||||
|
||||
// Esc calls QDialog::reject() directly (which hides the dialog without a
|
||||
// close event), so route it through the same guarded cancel as the button.
|
||||
connect(this, &QDialog::rejected, this, [this]() {
|
||||
onCancel();
|
||||
close();
|
||||
});
|
||||
|
||||
connect(openSelectedButton, &QPushButton::clicked, this, &DlgSharedDecksPreview::openSelected);
|
||||
connect(buttonBox, &QDialogButtonBox::clicked, this, [this, buttonBox](QAbstractButton *button) {
|
||||
if (buttonBox->buttonRole(button) == QDialogButtonBox::ActionRole) {
|
||||
openAll();
|
||||
}
|
||||
});
|
||||
|
||||
for (SharedDeckPreviewWidget *tile : tiles) {
|
||||
connect(tile, &SharedDeckPreviewWidget::selectionToggled, this,
|
||||
&DlgSharedDecksPreview::updateOpenSelectedEnabled);
|
||||
}
|
||||
for (int i = 0; i < tiles.size(); ++i) {
|
||||
const int itemId = itemIds.at(i);
|
||||
// Double-clicking a tile selects it and opens just that deck.
|
||||
connect(tiles.at(i), &SharedDeckPreviewWidget::activated, this, [this, itemId]() {
|
||||
resultEmitted = true;
|
||||
setDownloading(true);
|
||||
emit openRequested(QList<int>{itemId});
|
||||
});
|
||||
}
|
||||
updateOpenSelectedEnabled();
|
||||
}
|
||||
|
||||
QList<int> DlgSharedDecksPreview::selectedItemIds() const
|
||||
{
|
||||
QList<int> selectedIds;
|
||||
for (int i = 0; i < tiles.size(); ++i) {
|
||||
if (tiles.at(i)->isSelected()) {
|
||||
selectedIds.append(itemIds.at(i));
|
||||
}
|
||||
}
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::openSelected()
|
||||
{
|
||||
const QList<int> selectedIds = selectedItemIds();
|
||||
if (selectedIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
resultEmitted = true;
|
||||
setDownloading(true);
|
||||
emit openRequested(selectedIds);
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::openAll()
|
||||
{
|
||||
resultEmitted = true;
|
||||
setDownloading(true);
|
||||
emit openRequested(itemIds);
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::setDownloading(bool downloading)
|
||||
{
|
||||
if (downloadInProgress == downloading) {
|
||||
return;
|
||||
}
|
||||
downloadInProgress = downloading;
|
||||
downloadStatusLabel->setVisible(downloading);
|
||||
for (SharedDeckPreviewWidget *tile : tiles) {
|
||||
tile->setEnabled(!downloading);
|
||||
}
|
||||
openSelectedButton->setEnabled(!downloading);
|
||||
openAllButton->setEnabled(!downloading);
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::setDownloadProgress(int done, int total, const QString ¤tDeckName)
|
||||
{
|
||||
if (!downloadInProgress) {
|
||||
return;
|
||||
}
|
||||
downloadStatusLabel->setText(tr("Downloading deck %1 of %2: %3").arg(done).arg(total).arg(currentDeckName));
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::updateOpenSelectedEnabled()
|
||||
{
|
||||
openSelectedButton->setEnabled(!selectedItemIds().isEmpty());
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::onCancel()
|
||||
{
|
||||
if (!resultEmitted || downloadInProgress) {
|
||||
resultEmitted = true;
|
||||
emit cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
void DlgSharedDecksPreview::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
onCancel();
|
||||
QDialog::closeEvent(event);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
#ifndef COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
|
||||
#define COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QList>
|
||||
|
||||
class FlowWidget;
|
||||
class QCloseEvent;
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class ServerInfo_DeckShareItem;
|
||||
class SharedDeckPreviewWidget;
|
||||
class CardDatabaseQuerier;
|
||||
|
||||
/**
|
||||
* @brief Non-modal preview of the decks contained in a shared-deck link.
|
||||
*
|
||||
* Lets the user pick which of the shared decks to open before anything is
|
||||
* downloaded. Emits openRequested with the ids of the chosen decks, or
|
||||
* cancelled when the user closes the dialog without choosing. Once the user
|
||||
* picks, the dialog switches into a "downloading" state: the tiles and open
|
||||
* buttons are disabled, a progress label shows the current download and Cancel
|
||||
* stays functional so the download can be aborted.
|
||||
*/
|
||||
class DlgSharedDecksPreview : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DlgSharedDecksPreview(QWidget *parent,
|
||||
const CardDatabaseQuerier *querier,
|
||||
const QString &shareName,
|
||||
qint64 expiresAt,
|
||||
const QString &serverText,
|
||||
const QList<ServerInfo_DeckShareItem> &items);
|
||||
|
||||
void setDownloadProgress(int done, int total, const QString ¤tDeckName);
|
||||
|
||||
public slots:
|
||||
void setDownloading(bool downloading);
|
||||
|
||||
signals:
|
||||
void openRequested(const QList<int> &itemIds);
|
||||
void cancelled();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void openSelected();
|
||||
void openAll();
|
||||
void updateOpenSelectedEnabled();
|
||||
void onCancel();
|
||||
|
||||
private:
|
||||
QList<int> selectedItemIds() const;
|
||||
|
||||
FlowWidget *flowWidget;
|
||||
QList<SharedDeckPreviewWidget *> tiles;
|
||||
QList<int> itemIds;
|
||||
QPushButton *openSelectedButton;
|
||||
QPushButton *openAllButton;
|
||||
QLabel *downloadStatusLabel;
|
||||
bool resultEmitted = false;
|
||||
bool downloadInProgress = false;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
#include "flow_widget.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeyEvent>
|
||||
#include <QResizeEvent>
|
||||
#include <QScrollArea>
|
||||
#include <QSizePolicy>
|
||||
|
|
@ -80,13 +81,35 @@ FlowWidget::FlowWidget(QWidget *parent,
|
|||
/**
|
||||
* @brief Adds a widget to the flow layout within the FlowWidget.
|
||||
*
|
||||
* Plain widgets are not filtered for arrow keys: intercepting them would steal
|
||||
* Up/Down/Left/Right from controls that use them (combo boxes, spin boxes
|
||||
* etc.). Widgets that want keyboard navigation between flow items must be
|
||||
* added via addNavigableWidget instead.
|
||||
*
|
||||
* @param widget_to_add The widget to add to the flow layout.
|
||||
*/
|
||||
void FlowWidget::addWidget(QWidget *widget_to_add) const
|
||||
void FlowWidget::addWidget(QWidget *widget_to_add)
|
||||
{
|
||||
flowLayout->addWidget(widget_to_add);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Adds a widget and routes its arrow keys to FlowWidget focus navigation.
|
||||
*
|
||||
* The widget is filtered for arrow-key events so keyboard navigation between
|
||||
* the flow items keeps working even when the flow sits inside a QScrollArea,
|
||||
* which swallows arrow keys before they can reach FlowWidget::keyPressEvent.
|
||||
* Only widgets added through this method are affected; anything that needs its
|
||||
* own arrow keys should use plain addWidget.
|
||||
*
|
||||
* @param widget_to_add The widget to add to the flow layout.
|
||||
*/
|
||||
void FlowWidget::addNavigableWidget(QWidget *widget_to_add)
|
||||
{
|
||||
widget_to_add->installEventFilter(this);
|
||||
flowLayout->addWidget(widget_to_add);
|
||||
}
|
||||
|
||||
void FlowWidget::insertWidgetAtIndex(QWidget *toInsert, int index)
|
||||
{
|
||||
flowLayout->insertWidgetAtIndex(toInsert, index);
|
||||
|
|
@ -177,6 +200,66 @@ QLayoutItem *FlowWidget::itemAt(int index) const
|
|||
return flowLayout->itemAt(index);
|
||||
}
|
||||
|
||||
void FlowWidget::keyPressEvent(QKeyEvent *event)
|
||||
{
|
||||
if (moveFocus(event)) {
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
QWidget::keyPressEvent(event);
|
||||
}
|
||||
|
||||
bool FlowWidget::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (event->type() == QEvent::KeyPress && moveFocus(static_cast<QKeyEvent *>(event))) {
|
||||
return true;
|
||||
}
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
bool FlowWidget::moveFocus(QKeyEvent *event)
|
||||
{
|
||||
// Keyboard navigation between the flow items: arrow keys move focus just
|
||||
// like clicking the sibling tiles would. Only items that can take keyboard
|
||||
// focus (e.g. the deck-preview tiles in shared-deck links) are visited.
|
||||
const bool moveForward = event->key() == Qt::Key_Right || event->key() == Qt::Key_Down;
|
||||
const bool moveBackward = event->key() == Qt::Key_Left || event->key() == Qt::Key_Up;
|
||||
if (!moveForward && !moveBackward) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QList<QWidget *> focusableItems;
|
||||
for (int i = 0; i < flowLayout->count(); ++i) {
|
||||
QWidget *item = flowLayout->itemAt(i)->widget();
|
||||
if (item != nullptr && (item->focusPolicy() & Qt::TabFocus)) {
|
||||
focusableItems.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (focusableItems.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int currentIndex = -1;
|
||||
for (int i = 0; i < focusableItems.size(); ++i) {
|
||||
if (focusableItems.at(i)->hasFocus()) {
|
||||
currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const int delta = moveForward ? 1 : -1;
|
||||
int nextIndex;
|
||||
if (currentIndex < 0) {
|
||||
nextIndex = moveForward ? 0 : focusableItems.size() - 1;
|
||||
} else {
|
||||
nextIndex = (currentIndex + delta + focusableItems.size()) % focusableItems.size();
|
||||
}
|
||||
focusableItems.value(nextIndex)->setFocus();
|
||||
event->accept();
|
||||
return true;
|
||||
}
|
||||
|
||||
int FlowWidget::count() const
|
||||
{
|
||||
return flowLayout->count();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
#include "../../../layouts/flow_layout.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeyEvent>
|
||||
#include <QLoggingCategory>
|
||||
#include <QScrollArea>
|
||||
#include <QWidget>
|
||||
|
|
@ -28,7 +29,8 @@ public:
|
|||
Qt::ScrollBarPolicy horizontalPolicy,
|
||||
Qt::ScrollBarPolicy verticalPolicy);
|
||||
|
||||
void addWidget(QWidget *widget_to_add) const;
|
||||
void addWidget(QWidget *widget_to_add);
|
||||
void addNavigableWidget(QWidget *widget_to_add);
|
||||
void insertWidgetAtIndex(QWidget *toInsert, int index);
|
||||
void removeWidget(QWidget *widgetToRemove) const;
|
||||
void clearLayout();
|
||||
|
|
@ -43,9 +45,15 @@ public slots:
|
|||
void setSpacing(int hSpacing, int vSpacing);
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
|
||||
private:
|
||||
/// @brief Moves keyboard focus to an adjacent flow item for an arrow-key event.
|
||||
/// @return True when the event was an arrow key and was handled.
|
||||
bool moveFocus(QKeyEvent *event);
|
||||
|
||||
Qt::Orientation flowDirection;
|
||||
QHBoxLayout *mainLayout;
|
||||
FlowLayout *flowLayout;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue