Compare commits

..

6 commits

Author SHA1 Message Date
BruebachL
24d8d8be3b
[VDS] Cache mana symbol renders and skip redundant resizes (#7167)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [VDS] Cache mana symbol renders and skip redundant resizes

- Render each mana symbol once at a bounded master size and derive every
  requested size from the cached master, avoiding repeated full-size SVG
  rasterization on the GUI thread
- Share scaled results through a process-wide cache keyed by symbol and
  size, so repeated widget creation and rescales don't redo the work
- Skip redundant resize work in ColorIdentityWidget and ManaSymbolWidget
  when sizes did not change

Took 8 minutes


Took 50 seconds

* Move to pixmap generator

Took 8 minutes

Took 4 seconds

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-24 19:26:41 +02:00
BruebachL
b13c682a7a
[DeckList] Make deck tree card traversal recursive (#7175)
* [DeckList] Make deck tree card traversal recursive

getCardNodes and forEachCard now descend into nested zones instead of
assuming a flat main/side/token layout. For today's flat trees this is
behavior-preserving; it also removes two latent crashes (unchecked
dynamic_cast dereference, null card nodes passed to forEachCard
callers). Nested zones are introduced by later custom-zones units.

Took 3 minutes


Took 7 seconds

Took 9 seconds

* Fix rebase mistake.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-24 18:44:05 +02:00
BruebachL
22b0f69706
[UserList] Request banner card art for visible rows and hovered popup (#7172)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-24 18:25:14 +02:00
BruebachL
fecbbab983
[Client] Replace playmat crop spinboxes with a direct manipulation preview (#7161)
* [Client] Replace playmat crop spinboxes with a direct manipulation preview

The numeric fields exposed raw parameters with no relation to what the
game draws, accepted values the renderer clamps away, and no way to see
the result before committing

The preview now renders through the exact game pipeline into a viewport
shaped like a fresh board stack plus table area, with dimmed strips
marking where a developed table crops further. Drag pans, wheel zooms,
arrow keys nudge, plus and minus zoom, Backspace or Esc restores the
crop as of focus gain and lets Esc close the dialog when unchanged.
Focus ring and accessible name and description cover keyboard and screen
reader users, new paints use palette roles so themes recolor them

Took 25 minutes

Took 3 minutes


Took 54 seconds

* Remove stale constant

Took 3 minutes


Took 26 seconds

* Rebase

Took 2 minutes


Took 3 seconds

* Add editor spinboxes again

Took 4 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-24 18:22:48 +02:00
BruebachL
0b0ec64fe7
[DeckList] Add maybeboard zone constant and skip it in exports (#7174)
Introduce DECK_ZONE_MAYBEBOARD and its visible name, and treat the
maybeboard as editor-only scratch space: plain-text export, DeckStats
and TappedOut uploads now skip cards living there. Zones of this name
are created by later custom-zones units; until then the skips are inert.

Took 20 minutes

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-24 18:04:22 +02:00
BruebachL
0d0488cb5b
[Client] Edit banner crop by direct manipulation in the card art dialog (#7162)
The offset and zoom fields exposed raw numbers with no relation to the
strip, and the stored printing reset to the first local printing on
every open

The preview now paints through UserListPainter::drawCardArt itself, so
what you see is exactly what the user list renders. Drag pans the art
vertically at output scale, wheel zooms, arrow keys nudge, plus and
minus zoom, Backspace or Esc restores the crop as of focus gain and lets
Esc close the dialog when unchanged. Margins stay explicit spinboxes
since they trim the strip sides with no natural drag mapping. Legacy
stored zoom below the gesture floor is normalized once on open, focus
ring and accessible name and description cover keyboard and screen
reader users, all strings set in retranslateUi

Took 7 minutes

# Commit time for manual adjustment:
# Took 55 seconds


Took 41 seconds

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-24 18:01:59 +02:00
24 changed files with 905 additions and 160 deletions

View file

@ -72,7 +72,7 @@ void DeckStatsInterface::copyDeckWithoutTokens(const DeckList &source, DeckList
{
auto copyIfNotAToken = [&destination](const auto node, const auto card) {
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
if (dbCard && !dbCard->getIsToken()) {
if (dbCard && !dbCard->getIsToken() && node->getName() != DECK_ZONE_MAYBEBOARD) {
DecklistCardNode *addedCard = destination.addCard(card->getName(), node->getName(), -1);
addedCard->setNumber(card->getNumber());
}

View file

@ -99,7 +99,7 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi
{
auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) {
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
if (!dbCard || dbCard->getIsToken()) {
if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) {
return;
}

View file

@ -3,6 +3,7 @@
#include <QApplication>
#include <QDomDocument>
#include <QFile>
#include <QImageReader>
#include <QPainter>
#include <QPalette>
#include <QSvgRenderer>
@ -418,6 +419,57 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded)
QMap<QString, QPixmap> DropdownIconPixmapGenerator::pmCache;
namespace
{
/// Longest side mana symbols are rendered at before being scaled to their final size.
constexpr int MASTER_ICON_SIZE = 128;
QString manaSymbolCacheKey(const QString &symbol, const QSize &size)
{
return symbol + QLatin1Char('|') + QString::number(size.width()) + QLatin1Char('x') +
QString::number(size.height());
}
} // namespace
const QPixmap &ManaSymbolPixmapGenerator::masterIcon(const QString &symbol)
{
auto it = masterCache.constFind(symbol);
if (it != masterCache.constEnd()) {
return it.value();
}
QImageReader reader("theme:icons/mana/" + symbol);
QSize sourceSize = reader.size();
if (!sourceSize.isEmpty()) {
sourceSize.scale(QSize(MASTER_ICON_SIZE, MASTER_ICON_SIZE), Qt::KeepAspectRatio);
reader.setScaledSize(sourceSize);
}
const QPixmap rendered = QPixmap::fromImageReader(&reader);
return masterCache.insert(symbol, rendered).value();
}
QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const QSize &size)
{
const QString key = manaSymbolCacheKey(symbol, size);
auto it = scaledCache.constFind(key);
if (it != scaledCache.constEnd()) {
return it.value();
}
const QPixmap &icon = masterIcon(symbol);
if (icon.isNull()) {
return {};
}
QPixmap scaled = icon.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
scaledCache.insert(key, scaled);
return scaled;
}
QHash<QString, QPixmap> ManaSymbolPixmapGenerator::masterCache;
QHash<QString, QPixmap> ManaSymbolPixmapGenerator::scaledCache;
QPixmap loadColorAdjustedPixmap(const QString &name)
{
if (qApp->palette().windowText().color().lightness() > 200) {

View file

@ -7,6 +7,7 @@
#ifndef PIXMAPGENERATOR_H
#define PIXMAPGENERATOR_H
#include <QHash>
#include <QIcon>
#include <QLoggingCategory>
#include <QMap>
@ -125,6 +126,34 @@ public:
}
};
class ManaSymbolPixmapGenerator
{
private:
static QHash<QString, QPixmap> masterCache;
static QHash<QString, QPixmap> scaledCache;
/**
* @brief Renders \a symbol once at a fixed moderate size, so repeated scalings never
* re-rasterize the source file (SVG sources can be very expensive to rasterize).
*/
static const QPixmap &masterIcon(const QString &symbol);
public:
/**
* @brief Returns a smooth-scaled rendering of the given mana symbol icon.
*
* Results are shared between all callers via a process-wide cache keyed by symbol
* and size, so scaling work is done once per distinct combination instead of once
* per widget creation or resize.
*/
static QPixmap generatePixmap(const QString &symbol, const QSize &size);
static void clear()
{
masterCache.clear();
scaledCache.clear();
}
};
QPixmap loadColorAdjustedPixmap(const QString &name);
#endif

View file

@ -41,6 +41,11 @@ void ColorIdentityWidget::populateManaSymbolWidgets()
// clear old layout
QtUtils::clearLayoutRec(layout);
// The freshly created symbols haven't been sized yet, so force the next resize pass
// to apply the symbol size again.
lastIconSize = -1;
lastWidth = -1;
// populate mana symbols
if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()) {
for (const QString symbol : fullColorIdentity) {
@ -73,21 +78,35 @@ void ColorIdentityWidget::toggleUnusedVisibility()
void ColorIdentityWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
// Layout passes resize this widget repeatedly with identical sizes, so bail out before
// touching the children when neither the width nor the resulting symbol size changed.
const int totalWidth = event->size().width();
if (totalWidth == lastWidth && lastIconSize != -1) {
return;
}
lastWidth = totalWidth;
const int totalHeight = totalWidth / 6; // Set height to 1/4 of the width
setFixedHeight(totalHeight);
QList<ManaSymbolWidget *> manaSymbols = findChildren<ManaSymbolWidget *>();
if (manaSymbols.isEmpty()) {
return;
}
if (!manaSymbols.isEmpty()) {
int totalWidth = event->size().width();
int totalHeight = totalWidth / 6; // Set height to 1/4 of the width
setFixedHeight(totalHeight);
const int spacing = layout->spacing();
const int count = manaSymbols.size();
const int availableWidth = totalWidth - (spacing * (count - 1));
const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height
int spacing = layout->spacing();
int count = manaSymbols.size();
int availableWidth = totalWidth - (spacing * (count - 1));
int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height
if (iconSize == lastIconSize) {
return;
}
lastIconSize = iconSize;
for (ManaSymbolWidget *manaSymbol : manaSymbols) {
manaSymbol->setFixedSize(iconSize, iconSize);
}
for (ManaSymbolWidget *manaSymbol : manaSymbols) {
manaSymbol->setFixedSize(iconSize, iconSize);
}
}

View file

@ -30,6 +30,8 @@ public slots:
private:
QString colorIdentity;
QHBoxLayout *layout;
int lastIconSize = -1; ///< The symbol size last applied, to skip redundant resize passes.
int lastWidth = -1; ///< The width last processed, to skip redundant resize passes.
};
#endif // COLOR_IDENTITY_WIDGET_H

View file

@ -1,15 +1,15 @@
#include "mana_symbol_widget.h"
#include "../../../../client/settings/cache_settings.h"
#include "../../../pixel_map_generator.h"
#include <QResizeEvent>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
ManaSymbolWidget::ManaSymbolWidget(QWidget *parent, QString _symbol, bool _isActive, bool _mayBeToggled)
: QLabel(parent), symbol(_symbol), isActive(_isActive), mayBeToggled(_mayBeToggled)
: QLabel(parent), symbol(std::move(_symbol)), isActive(_isActive), mayBeToggled(_mayBeToggled)
{
loadManaIcon();
setPixmap(manaIcon.scaled(50, 50, Qt::KeepAspectRatio, Qt::SmoothTransformation));
setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(50, 50)));
setMaximumWidth(50);
// Initialize opacity effect
@ -64,16 +64,13 @@ void ManaSymbolWidget::mousePressEvent(QMouseEvent *event)
void ManaSymbolWidget::resizeEvent(QResizeEvent *event)
{
QLabel::resizeEvent(event);
setPixmap(manaIcon.scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
const QSize newSize = event->size();
void ManaSymbolWidget::loadManaIcon()
{
QString filename = "theme:icons/mana/";
if (symbol == "W" || symbol == "U" || symbol == "B" || symbol == "R" || symbol == "G") {
filename += symbol;
// Skip the rescale when the size didn't actually change: layout passes resize these
// widgets repeatedly with identical sizes.
if (newSize.isEmpty() || pixmap().size() == newSize) {
return;
}
manaIcon = QPixmap(filename);
setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, newSize));
}

View file

@ -33,8 +33,6 @@ public:
return symbol[0];
}
void loadManaIcon();
public slots:
void resizeEvent(QResizeEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
@ -44,7 +42,6 @@ signals:
private:
QString symbol;
QPixmap manaIcon;
bool isActive;
bool mayBeToggled;
QGraphicsOpacityEffect *opacityEffect;

View file

@ -3,25 +3,63 @@
#include "../cards/art_crop_attribution.h"
#include "playmat_utils.h"
#include <QKeyEvent>
#include <QLinearGradient>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QWheelEvent>
#include <cmath>
namespace
{
// Mirrors the dialog/proto clamps so gestures can never produce an
// out of range parameter. The zoom FLOOR is dynamic: see
// playmatClampedZoom(), zooming out stops where the sampling window would
// exceed the card itself, so there is no dead range at the bottom end.
constexpr qreal MAX_MARGIN = 0.95;
// Range of in game stack+table aspect ratios worth designing for, derived
// from PlayerGraphicsItem::paint()'s combinedArea = stack table:
// height = 10 + 30 + 3*102 + 2*30 = 406 (TableZone rows)
// width = 1.5*72 + (20 + 5*72 + 15) = 503 (StackZone + TableZone
// at MIN_WIDTH)
// The area's shape depends on GAME CONTENT (played card columns widen the
// table by ~107 px each), not on the window size. Fresh board ≈ 503/406,
// a table grown to roughly double its minimum width ≈ 2.2.
constexpr qreal MIN_TABLE_ASPECT = 503.0 / 406.0; // fresh board: most generous framing
constexpr qreal MAX_TABLE_ASPECT = 2.2; // well developed, wide table
// Keyboard nudge steps (viewport convention: Down looks further down).
constexpr qreal KEY_PAN_MARGIN_STEP = 0.005;
constexpr qreal KEY_PAN_OFFSET_STEP = 0.01;
constexpr qreal KEY_ZOOM_STEP = 1.05;
constexpr qreal WHEEL_ZOOM_BASE = 1.15; // zoom factor per wheel notch
} // namespace
PlaymatPreviewWidget::PlaymatPreviewWidget(QWidget *parent) : QWidget(parent)
{
setMinimumSize(400, 120);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
// The crop is square and drawn contain fit, so the height decides its
// on screen size, keep it generous but let the dialog compress on small
// or high DPI screens
setMinimumSize(400, 180);
QSizePolicy sp(QSizePolicy::Expanding, QSizePolicy::Expanding);
setSizePolicy(sp);
setFocusPolicy(Qt::StrongFocus);
setCursor(Qt::OpenHandCursor);
setAccessibleName(tr("Playmat crop"));
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
}
void PlaymatPreviewWidget::setPixmap(const QPixmap &pixmap)
{
sourcePixmap = pixmap;
setCursor(sourcePixmap.isNull() ? Qt::ArrowCursor : Qt::OpenHandCursor);
update();
}
void PlaymatPreviewWidget::setParams(const PlaymatParams &p)
{
params = p;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
}
@ -31,6 +69,94 @@ void PlaymatPreviewWidget::setAttribution(const QString &attribution)
update();
}
QRectF PlaymatPreviewWidget::activePlayArea() const
{
// The viewport is a frame shaped like a fresh board's stack+table area
// (kMinTableAspect): the most generous framing the game will produce.
// Dimmed strips mark where a wider, developed table crops further.
const QRectF cardRect = QRectF(rect()).adjusted(3, 2, -3, -2);
return PlaymatUtils::aspectFitRect(cardRect.adjusted(6, 4, -4, -4), MIN_TABLE_ASPECT);
}
qreal PlaymatPreviewWidget::samplingWindowSide() const
{
if (sourcePixmap.isNull()) {
return 0.0;
}
// Same clamped window the render path uses, gestures and painting must
// never disagree about geometry.
return PlaymatUtils::playmatWindowSide(sourcePixmap.size(), params);
}
qreal PlaymatPreviewWidget::widgetToSourceScale() const
{
const qreal cropSide = samplingWindowSide();
const QRectF area = activePlayArea();
if (cropSide <= 0.0 || area.isEmpty()) {
return 0.0;
}
// Mirror coverFitRect(): the square crop into the (wider) viewport fills
// its width.
return area.width() / cropSide;
}
void PlaymatPreviewWidget::applyCropDelta(qreal dMarginL, qreal dMarginR, qreal dOffset, qreal zoomFactor)
{
PlaymatParams next = params;
if (dMarginL + dMarginR == 0.0) {
// Pure horizontal pan rebalances the margins along their
// sum constant segment. Individual bounds must not break that
// invariant, otherwise repeated corner drags let one margin grow
// without end, collapsing the viewing window and desynchronizing the
// visual zoom from the readout.
const qreal sum = params.marginPctL + params.marginPctR;
const qreal lo = qMax(0.0, sum - MAX_MARGIN);
const qreal hi = qMin(sum, MAX_MARGIN);
next.marginPctL = qBound(lo, params.marginPctL + dMarginL, hi);
next.marginPctR = sum - next.marginPctL;
} else {
next.marginPctL = qBound(0.0, params.marginPctL + dMarginL, MAX_MARGIN);
next.marginPctR = qBound(0.0, params.marginPctR + dMarginR, MAX_MARGIN);
}
next.verticalOffset = qBound(0.0, params.verticalOffset + dOffset, 1.0);
// Clamp through the shared helper so the floor tracks the new margins:
// zooming out stops exactly where the window reaches the card bounds.
next.zoom = params.zoom * zoomFactor;
if (!sourcePixmap.isNull()) {
next.zoom = PlaymatUtils::playmatClampedZoom(sourcePixmap.size(), next);
}
if (sameCrop(next, params)) {
return;
}
params = next;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
emit paramsEdited(params);
}
bool PlaymatPreviewWidget::sameCrop(const PlaymatParams &a, const PlaymatParams &b) const
{
// Exact comparison on purpose: clamped assignments yield identical bits,
// while qFuzzyCompare based equality misbehaves around zero
return a.marginPctL == b.marginPctL && a.marginPctR == b.marginPctR && a.verticalOffset == b.verticalOffset &&
a.zoom == b.zoom;
}
void PlaymatPreviewWidget::restoreSnapshot()
{
// The snapshot only ever holds values that passed the gesture clamps,
// so it is safe to restore verbatim
if (sameCrop(paramsAtFocusIn, params)) {
return;
}
params = paramsAtFocusIn;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
emit paramsEdited(params);
}
void PlaymatPreviewWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
@ -56,23 +182,40 @@ void PlaymatPreviewWidget::paintEvent(QPaintEvent *)
return;
}
// Draw the playmat art using the same logic as PlayerGraphicsItem
// The preview area represents the combined stack+table play area
// Stack is ~20% width on the left, table is ~80% on the right
const QRectF playArea = cardRect.adjusted(6, 4, -4, -4);
const QRectF playArea = activePlayArea();
// Exactly the game's pipeline (player_graphics_item): cover fit the crop
// into the table shaped viewport, centered, so the frame shows precisely
// what a minimum aspect window shows, and dragging moves the art behind
// the fixed frame.
const QRectF srcRect = PlaymatUtils::computeArtSourceRect(sourcePixmap.size(), params);
const QRectF dstRect = PlaymatUtils::coverFitRect(playArea, srcRect.size());
painter.setClipRect(playArea.toRect());
painter.drawPixmap(dstRect, sourcePixmap, srcRect);
painter.setClipping(false);
// Wider (developed) tables crop further: mark where a kMaxTableAspect
// board stops. Palette driven so theme authors can recolor the markers.
const qreal wideBandHeight = playArea.height() * (MIN_TABLE_ASPECT / MAX_TABLE_ASPECT);
const qreal stripHeight = (playArea.height() - wideBandHeight) / 2.0;
QColor stripColor = palette().color(QPalette::Window);
stripColor.setAlpha(150);
painter.fillRect(QRectF(playArea.left(), playArea.top(), playArea.width(), stripHeight), stripColor);
painter.fillRect(QRectF(playArea.left(), playArea.bottom() - stripHeight, playArea.width(), stripHeight),
stripColor);
QColor hairlineColor = palette().color(QPalette::Highlight);
hairlineColor.setAlpha(110);
painter.setPen(QPen(hairlineColor, 1));
painter.drawLine(QPointF(playArea.left(), playArea.top() + stripHeight),
QPointF(playArea.right(), playArea.top() + stripHeight));
painter.drawLine(QPointF(playArea.left(), playArea.bottom() - stripHeight),
QPointF(playArea.right(), playArea.bottom() - stripHeight));
// Draw zone divider: stack is roughly the left portion
const double stackWidthRatio = 0.18; // Stack is about 18% of total play area
const double stackDividerX = playArea.left() + playArea.width() * stackWidthRatio;
// Subtle semi-transparent overlays to distinguish zones
// Subtle semi transparent overlays to distinguish zones
// Stack zone overlay (slightly darker)
QRectF stackOverlay(playArea.left(), playArea.top(), playArea.width() * stackWidthRatio, playArea.height());
painter.fillRect(stackOverlay, QColor(0, 0, 0, 40));
@ -89,11 +232,150 @@ void PlaymatPreviewWidget::paintEvent(QPaintEvent *)
const double landDividerY = playArea.top() + playArea.height() * 0.65;
painter.setPen(QPen(QColor(255, 255, 255, 30), 1));
painter.drawLine(QPointF(stackDividerX, landDividerY), QPointF(playArea.right(), landDividerY));
painter.setClipping(false);
// Border around entire play area
// Border around the viewport = boundary of every plausible framing.
painter.setPen(QPen(QColor(70, 80, 95, 120), 1));
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(playArea.adjusted(0, 0, -1, -1), 3, 3);
// Visible keyboard focus per the focus cursor contract, Tab must show
// where the keys land
if (hasFocus()) {
QPen focusPen(palette().color(QPalette::Highlight), 2);
painter.setPen(focusPen);
painter.drawRoundedRect(playArea.adjusted(-1, -1, 1, 1), 3, 3);
}
paintArtAttribution(painter, playArea, attributionText, Qt::AlignRight | Qt::AlignBottom, 0.8);
// Zoom readout so the gesture has a visible, stable counterpart.
QColor ink = palette().color(QPalette::WindowText);
ink.setAlpha(160);
painter.setPen(ink);
painter.drawText(QPointF(playArea.left() + 8, playArea.bottom() - 8),
tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
}
void PlaymatPreviewWidget::mousePressEvent(QMouseEvent *event)
{
if (event->button() != Qt::LeftButton || sourcePixmap.isNull() || samplingWindowSide() <= 0.0) {
QWidget::mousePressEvent(event);
return;
}
lastDragPos = event->pos();
setCursor(Qt::ClosedHandCursor);
event->accept();
}
void PlaymatPreviewWidget::mouseMoveEvent(QMouseEvent *event)
{
if (!(event->buttons() & Qt::LeftButton) || sourcePixmap.isNull()) {
QWidget::mouseMoveEvent(event);
return;
}
const QPointF delta = QPointF(event->pos() - lastDragPos);
lastDragPos = event->pos();
const qreal scale = widgetToSourceScale();
// Vertical travel of the SAMPLING window: verticalOffset moves its top
// edge by exactly this much per unit, identical to the render path.
// Windows taller than the art (square/landscape sources zoomed out)
// leave no travel, vertical drags are then boundary no ops.
const qreal travel = static_cast<qreal>(sourcePixmap.height()) - samplingWindowSide();
if (scale <= 0.0) {
event->accept();
return;
}
// Dragging moves the ART with the cursor, so the viewing window slides the
// other way. Horizontal panning rebalances the margins (their sum, hence
// the window width, stays constant), vertical panning moves the window's
// top edge within its available travel.
const qreal sourceW = sourcePixmap.width();
const qreal dMargin = -(delta.x() / scale) / sourceW;
const qreal dOffset = travel > 0.5 ? -(delta.y() / scale) / travel : 0.0;
applyCropDelta(dMargin, -dMargin, dOffset, 1.0);
event->accept();
}
void PlaymatPreviewWidget::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
setCursor(sourcePixmap.isNull() ? Qt::ArrowCursor : Qt::OpenHandCursor);
event->accept();
return;
}
QWidget::mouseReleaseEvent(event);
}
void PlaymatPreviewWidget::wheelEvent(QWheelEvent *event)
{
if (sourcePixmap.isNull() || samplingWindowSide() <= 0.0) {
QWidget::wheelEvent(event);
return;
}
const qreal notches = static_cast<qreal>(event->angleDelta().y()) / 120.0;
if (notches == 0.0) {
event->accept();
return;
}
applyCropDelta(0.0, 0.0, 0.0, std::pow(WHEEL_ZOOM_BASE, notches));
event->accept();
}
void PlaymatPreviewWidget::keyPressEvent(QKeyEvent *event)
{
if (sourcePixmap.isNull()) {
QWidget::keyPressEvent(event);
return;
}
switch (event->key()) {
case Qt::Key_Escape:
if (sameCrop(params, paramsAtFocusIn)) {
// Nothing to undo on this surface, let the event reach the
// dialog so Esc keeps its close meaning there
QWidget::keyPressEvent(event);
return;
}
restoreSnapshot();
break;
case Qt::Key_Backspace:
restoreSnapshot();
break;
case Qt::Key_Left:
applyCropDelta(-KEY_PAN_MARGIN_STEP, KEY_PAN_MARGIN_STEP, 0.0, 1.0);
break;
case Qt::Key_Right:
applyCropDelta(KEY_PAN_MARGIN_STEP, -KEY_PAN_MARGIN_STEP, 0.0, 1.0);
break;
case Qt::Key_Up:
applyCropDelta(0.0, 0.0, -KEY_PAN_OFFSET_STEP, 1.0);
break;
case Qt::Key_Down:
applyCropDelta(0.0, 0.0, KEY_PAN_OFFSET_STEP, 1.0);
break;
case Qt::Key_Plus:
case Qt::Key_Equal:
applyCropDelta(0.0, 0.0, 0.0, KEY_ZOOM_STEP);
break;
case Qt::Key_Minus:
applyCropDelta(0.0, 0.0, 0.0, 1.0 / KEY_ZOOM_STEP);
break;
default:
QWidget::keyPressEvent(event);
return;
}
event->accept();
}
void PlaymatPreviewWidget::focusInEvent(QFocusEvent *event)
{
// Snapshot for the Esc or Backspace reset, restoring whatever the user
// had when the surface took focus
paramsAtFocusIn = params;
QWidget::focusInEvent(event);
}

View file

@ -1,16 +1,23 @@
#ifndef COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H
#define COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H
#include <QFocusEvent>
#include <QPixmap>
#include <QWidget>
#include <libcockatrice/deck_list/deck_list.h>
/**
* @brief Preview widget that shows how a playmat card art will appear
* @brief Interactive crop surface showing how a playmat card art will appear
* across the combined table + stack play area.
*
* Renders a miniature mockup with the card art applied using the
* given PlaymatParams, including faint zone divider lines.
* Renders a fixed frame shaped like a fresh board's stack+table area (the
* most generous framing the game produces): it shows the tallest slice of
* the square crop in normal play, with dimmed strips marking where a wider,
* developed table crops further, exactly the game's own render pipeline.
* The widget doubles as the editor's primary crop control: dragging pans the
* art behind the frame, the wheel zooms, and arrow keys nudge, mirroring
* the stored parameters (margins pan horizontally, verticalOffset
* vertically, zoom scales) so no separate numeric controls are needed.
*/
class PlaymatPreviewWidget : public QWidget
{
@ -23,13 +30,32 @@ public:
void setParams(const PlaymatParams &params);
void setAttribution(const QString &attribution);
signals:
/** @brief Emitted whenever direct manipulation (drag, wheel, keys) changes the crop parameters. */
void paramsEdited(const PlaymatParams &params);
protected:
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void focusInEvent(QFocusEvent *event) override;
private:
QRectF activePlayArea() const; ///< destination rect used for rendering AND gesture math
qreal samplingWindowSide() const; ///< clamped square window side, shared with the render path
qreal widgetToSourceScale() const;
void applyCropDelta(qreal dMarginL, qreal dMarginR, qreal dOffset, qreal zoomFactor);
bool sameCrop(const PlaymatParams &a, const PlaymatParams &b) const;
void restoreSnapshot();
QPixmap sourcePixmap;
PlaymatParams params;
PlaymatParams paramsAtFocusIn; ///< crop as of the latest focus gain, restored by Esc or Backspace
QString attributionText;
QPoint lastDragPos; ///< widget space position of the previous mouse move while panning
};
#endif // COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H

View file

@ -7,6 +7,7 @@
#include "card_database_model.h"
#include "playmat_preview_widget.h"
#include <QCheckBox>
#include <QComboBox>
#include <QCompleter>
#include <QDialogButtonBox>
@ -48,10 +49,6 @@ PlaymatSettingsDialog::PlaymatSettingsDialog(const CardRef &initialCard,
reloadPreview();
}
}
marginLSpin->setValue(initialParams.marginPctL);
marginRSpin->setValue(initialParams.marginPctR);
verticalOffsetSpin->setValue(initialParams.verticalOffset);
zoomSpin->setValue(initialParams.zoom);
retranslateUi();
}
@ -112,23 +109,32 @@ void PlaymatSettingsDialog::setupUi()
connect(providerComboBox, &QComboBox::currentIndexChanged, this, [this]() {
currentCard.providerId = providerComboBox->currentData().toString();
reloadPreview();
onParamChanged();
});
auto *form = new QFormLayout;
controlsForm = form;
cardNameLabel = new QLabel;
printingLabel = new QLabel;
form->addRow(cardNameLabel, searchBar);
form->addRow(printingLabel, providerComboBox);
// Numerical editors expose the raw PlaymatParams for precise input. They
// share the same form as the rows above so every field lines up on one
// label column. They stay hidden until requested since the crop surface
// is the primary control.
marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01);
marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01);
verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01);
zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05);
auto *form = new QFormLayout;
cardNameLabel = new QLabel;
printingLabel = new QLabel;
leftMarginLabel = new QLabel;
rightMarginLabel = new QLabel;
verticalOffsetLabel = new QLabel;
zoomLabel = new QLabel;
form->addRow(cardNameLabel, searchBar);
form->addRow(printingLabel, providerComboBox);
showNumericEditorsCheck = new QCheckBox;
form->addRow(showNumericEditorsCheck);
form->addRow(leftMarginLabel, marginLSpin);
form->addRow(rightMarginLabel, marginRSpin);
form->addRow(verticalOffsetLabel, verticalOffsetSpin);
@ -138,9 +144,14 @@ void PlaymatSettingsDialog::setupUi()
controlsGroup->setLayout(form);
preview = new PlaymatPreviewWidget;
preview->setParams(currentParams);
auto *previewLayout = new QVBoxLayout;
previewLayout->addWidget(preview);
previewCaptionLabel = new QLabel;
previewCaptionLabel->setAlignment(Qt::AlignCenter);
previewCaptionLabel->setWordWrap(true);
previewLayout->addWidget(previewCaptionLabel);
previewGroup = new QGroupBox;
previewGroup->setLayout(previewLayout);
@ -155,16 +166,35 @@ void PlaymatSettingsDialog::setupUi()
accept();
});
auto *root = new QVBoxLayout;
root->addWidget(controlsGroup);
root->addWidget(previewGroup);
root->addWidget(buttons);
setLayout(root);
// The crop surface is the primary control: dragging pans, wheel/keys zoom,
// editing exactly the same stored parameters the numeric fields do.
connect(preview, &PlaymatPreviewWidget::paramsEdited, this, [this](const PlaymatParams &edited) {
currentParams = edited;
QSignalBlocker blockMarginL(marginLSpin);
QSignalBlocker blockMarginR(marginRSpin);
QSignalBlocker blockOffset(verticalOffsetSpin);
QSignalBlocker blockZoom(zoomSpin);
marginLSpin->setValue(edited.marginPctL);
marginRSpin->setValue(edited.marginPctR);
verticalOffsetSpin->setValue(edited.verticalOffset);
zoomSpin->setValue(edited.zoom);
});
connect(showNumericEditorsCheck, &QCheckBox::toggled, this, &PlaymatSettingsDialog::setNumericEditorsVisible);
setNumericEditorsVisible(false);
connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
// The crop surface leads visually, card selection supports it below.
auto *root = new QVBoxLayout;
root->addWidget(previewGroup);
root->addWidget(controlsGroup);
root->addWidget(buttons);
setLayout(root);
}
void PlaymatSettingsDialog::populateProviderCombo(const QString &cardName)
@ -261,17 +291,34 @@ void PlaymatSettingsDialog::onParamChanged()
preview->setParams(currentParams);
}
void PlaymatSettingsDialog::setNumericEditorsVisible(bool visible)
{
controlsForm->setRowVisible(leftMarginLabel, visible);
controlsForm->setRowVisible(rightMarginLabel, visible);
controlsForm->setRowVisible(verticalOffsetLabel, visible);
controlsForm->setRowVisible(zoomLabel, visible);
// A QDialog never resizes itself when its content requirements change,
// so revealing the editors would squeeze the crop group until the info
// caption ran into the preview. Re-fit the dialog to the new size hint.
adjustSize();
}
void PlaymatSettingsDialog::retranslateUi()
{
setWindowTitle(tr("Playmat Settings"));
searchBar->setPlaceholderText(tr("Type a card name..."));
cardNameLabel->setText(tr("Card name:"));
printingLabel->setText(tr("Printing:"));
showNumericEditorsCheck->setText(tr("Show numerical editors"));
leftMarginLabel->setText(tr("Left margin (%):"));
rightMarginLabel->setText(tr("Right margin (%):"));
verticalOffsetLabel->setText(tr("Vertical offset:"));
zoomLabel->setText(tr("Zoom:"));
controlsGroup->setTitle(tr("Parameters"));
previewGroup->setTitle(tr("Preview"));
controlsGroup->setTitle(tr("Card"));
previewGroup->setTitle(tr("Crop"));
previewCaptionLabel->setText(
tr("Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. "
"Dimmed strips mark where a wider table crops further."));
removeButton->setText(tr("Remove Playmat"));
}

View file

@ -5,13 +5,16 @@
#include <QPixmap>
#include <libcockatrice/deck_list/deck_list.h>
class QCheckBox;
class QComboBox;
class QCompleter;
class QDoubleSpinBox;
class QFormLayout;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QWidget;
class CardDatabaseModel;
class CardDatabaseDisplayModel;
class CardSearchModel;
@ -21,10 +24,11 @@ class PlaymatPreviewWidget;
/**
* @brief Dialog for configuring the playmat card art for a deck.
*
* Allows the user to select a card from the database and adjust
* positioning parameters (margins, zoom, vertical offset) for how
* the card art appears as a playmat background across the
* combined table + stack play area.
* The crop surface is the primary control: drag to pan the visible art,
* scroll (or +/- keys) to zoom, arrow keys to nudge. Card name and printing
* are selected below. A checkbox reveals optional numerical editors for the
* raw PlaymatParams. These controls edit the same stored PlaymatParams that
* ship in deck files and player properties.
*/
class PlaymatSettingsDialog : public QDialog
{
@ -40,14 +44,15 @@ public:
private slots:
void onCardNameChanged(const QString &name);
void reloadPreview();
void onParamChanged();
void reloadPreview();
private:
void setupUi();
void populateProviderCombo(const QString &cardName);
void initializeSearchBar();
void retranslateUi();
void setNumericEditorsVisible(bool visible);
QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step);
QLineEdit *searchBar;
@ -63,6 +68,9 @@ private:
QLabel *cardNameLabel;
QLabel *printingLabel;
QLabel *previewCaptionLabel;
QCheckBox *showNumericEditorsCheck;
QFormLayout *controlsForm;
QLabel *leftMarginLabel;
QLabel *rightMarginLabel;
QLabel *verticalOffsetLabel;
@ -75,6 +83,7 @@ private:
QDoubleSpinBox *marginRSpin;
QDoubleSpinBox *verticalOffsetSpin;
QDoubleSpinBox *zoomSpin;
PlaymatPreviewWidget *preview;
QPixmap currentPixmap;

View file

@ -15,19 +15,46 @@
#include <QFormLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QRegularExpression>
#include <QVBoxLayout>
#include <QWheelEvent>
#include <cmath>
#include <libcockatrice/card/database/card_database_manager.h>
namespace
{
// Gesture clamps and step sizes for this direct manipulation surface. The
// gesture zoom floor is 1.0: basescale already cover fits the art, so any
// smaller scale would underfill the strip.
constexpr qreal kMinGestureZoom = 1.0;
constexpr qreal kMaxZoom = 4.0;
constexpr qreal kKeyPanOffsetStep = 0.01;
constexpr qreal kKeyZoomStep = 1.05;
constexpr qreal kWheelZoomBase = 1.15; // zoom factor per wheel notch
} // namespace
CardArtPreviewWidget::CardArtPreviewWidget(QWidget *parent) : QWidget(parent)
{
setMinimumSize(400, 72);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
setFocusPolicy(Qt::StrongFocus);
setAccessibleName(tr("Banner preview"));
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
}
void CardArtPreviewWidget::focusInEvent(QFocusEvent *event)
{
// Snapshot for the Esc or Backspace reset, restoring whatever the user
// had when the surface took focus
paramsAtFocusIn = params;
QWidget::focusInEvent(event);
}
void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap)
@ -39,6 +66,7 @@ void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap)
void CardArtPreviewWidget::setParams(const CardArtParams &p)
{
params = p;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
}
@ -67,9 +95,22 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *)
painter.setBrush(accentColor);
painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2);
// Visible keyboard focus per the focus cursor contract, Tab must show
// where the keys land, including in the empty state
const auto paintFocusRing = [&painter, &cardRect, this]() {
if (!hasFocus()) {
return;
}
QPen focusPen(palette().color(QPalette::Highlight), 2);
painter.setPen(focusPen);
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(cardRect.adjusted(-1, -1, 1, 1), 6, 6);
};
if (sourcePixmap.isNull()) {
painter.setPen(QColor(150, 150, 150));
painter.drawText(rect, Qt::AlignCenter, tr("No card selected"));
paintFocusRing();
return;
}
@ -80,7 +121,7 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *)
&sourcePixmap // direct pixmap
);
// Avatar placeholder so the left-margin interaction is visible
// Avatar placeholder so the left margin interaction is visible
const int avatarX = rect.left() + 14;
const int avatarY = rect.top() + (rect.height() - 36) / 2;
const QRect avatarRect(avatarX, avatarY, 36, 36);
@ -99,37 +140,200 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *)
painter.drawEllipse(avatarRect.adjusted(-1, -1, 1, 1));
paintArtAttribution(painter, cardRect, attributionText);
paintFocusRing();
}
qreal CardArtPreviewWidget::bannerTravel() const
{
if (sourcePixmap.isNull()) {
return 0.0;
}
// Mirror UserListPainter::drawCardArt() exactly: same strip metrics, the
// copy is drawn 1:1, so output pixels equal widget pixels here.
const int cardH = rect().height() - 4;
const int totalW = (rect().right() - 4) - rect().left();
const int marginL = qRound(totalW * params.marginPctL);
const int marginR = qRound(totalW * params.marginPctR);
const int drawW = totalW - marginL - marginR;
const double basescale = qMax(double(drawW) / sourcePixmap.width(), double(cardH) / sourcePixmap.height());
// qRound for literal parity with drawCardArt, which rounds the scaled
// height before computing travel
const double scaledH = qRound(sourcePixmap.height() * basescale * params.zoom);
return scaledH - cardH;
}
void CardArtPreviewWidget::applyCropDelta(qreal dOffset, qreal zoomFactor)
{
CardArtParams next = params;
next.verticalOffset = qBound(0.0, params.verticalOffset + dOffset, 1.0);
next.zoom = qBound(kMinGestureZoom, params.zoom * zoomFactor, kMaxZoom);
if (sameCrop(next, params)) {
return;
}
params = next;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
emit paramsEdited(params);
}
bool CardArtPreviewWidget::sameCrop(const CardArtParams &a, const CardArtParams &b) const
{
// Exact comparison on purpose: clamped assignments yield identical bits,
// while qFuzzyCompare based equality misbehaves around zero
return a.verticalOffset == b.verticalOffset && a.zoom == b.zoom;
}
void CardArtPreviewWidget::restoreSnapshot()
{
// The snapshot only ever holds values that passed the gesture clamps,
// so it is safe to restore verbatim
if (sameCrop(paramsAtFocusIn, params)) {
return;
}
params = paramsAtFocusIn;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
emit paramsEdited(params);
}
void CardArtPreviewWidget::mousePressEvent(QMouseEvent *event)
{
if (event->button() != Qt::LeftButton || sourcePixmap.isNull() || bannerTravel() <= 0.5) {
QWidget::mousePressEvent(event);
return;
}
dragging = true;
lastDragPos = event->pos();
setCursor(Qt::ClosedHandCursor);
event->accept();
}
void CardArtPreviewWidget::mouseMoveEvent(QMouseEvent *event)
{
if (!dragging || sourcePixmap.isNull()) {
QWidget::mouseMoveEvent(event);
return;
}
const qreal dy = event->pos().y() - lastDragPos.y();
lastDragPos = event->pos();
const qreal travel = bannerTravel();
if (travel <= 0.5) {
event->accept();
return;
}
// Dragging moves the ART with the cursor, so the crop window slides the
// other way through the available travel.
applyCropDelta(-dy / travel, 1.0);
event->accept();
}
void CardArtPreviewWidget::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
dragging = false;
unsetCursor();
event->accept();
return;
}
QWidget::mouseReleaseEvent(event);
}
void CardArtPreviewWidget::wheelEvent(QWheelEvent *event)
{
if (sourcePixmap.isNull()) {
QWidget::wheelEvent(event);
return;
}
const qreal notches = static_cast<qreal>(event->angleDelta().y()) / 120.0;
if (notches == 0.0) {
event->accept();
return;
}
applyCropDelta(0.0, std::pow(kWheelZoomBase, notches));
event->accept();
}
void CardArtPreviewWidget::keyPressEvent(QKeyEvent *event)
{
if (sourcePixmap.isNull()) {
QWidget::keyPressEvent(event);
return;
}
switch (event->key()) {
case Qt::Key_Escape:
if (sameCrop(params, paramsAtFocusIn)) {
// Nothing to undo on this surface, let the event reach the
// dialog so Esc keeps its close meaning there
QWidget::keyPressEvent(event);
return;
}
restoreSnapshot();
break;
case Qt::Key_Backspace:
restoreSnapshot();
break;
case Qt::Key_Up:
applyCropDelta(-kKeyPanOffsetStep, 1.0);
break;
case Qt::Key_Down:
applyCropDelta(kKeyPanOffsetStep, 1.0);
break;
case Qt::Key_Plus:
case Qt::Key_Equal:
applyCropDelta(0.0, kKeyZoomStep);
break;
case Qt::Key_Minus:
applyCropDelta(0.0, 1.0 / kKeyZoomStep);
break;
default:
QWidget::keyPressEvent(event);
return;
}
event->accept();
}
UserCardArtSettingsDialog::UserCardArtSettingsDialog(const CardArtParams &initial, QWidget *parent)
: QDialog(parent), currentParams(initial)
{
setWindowTitle(tr("Card Art Settings"));
// Legacy stored banners may carry zoom below the gesture floor or an out
// of range offset. Normalize once on open so the preview renders filled
// and Ok saves a state the gestures can reach again
currentParams.zoom = qBound(kMinGestureZoom, currentParams.zoom, kMaxZoom);
currentParams.verticalOffset = qBound(0.0, currentParams.verticalOffset, 1.0);
setMinimumWidth(500);
setupUi();
// Seed UI from initial params
if (!initial.cardName.isEmpty()) {
searchBar->setText(initial.cardName);
onCardNameChanged(initial.cardName);
if (!currentParams.cardName.isEmpty()) {
// onCardNameChanged overwrites cardProviderId with the first printing,
// so remember the stored one before it runs
const QString storedProviderId = currentParams.cardProviderId;
searchBar->setText(currentParams.cardName);
onCardNameChanged(currentParams.cardName);
// onCardNameChanged leaves the printing combo on the first printing in
// the database, which would silently change the stored banner card on
// accept. Restore the stored printing when it resolves locally.
const int storedPrintingIndex = providerComboBox->findData(initial.cardProviderId);
const int storedPrintingIndex = providerComboBox->findData(storedProviderId);
if (storedPrintingIndex != -1) {
providerComboBox->setCurrentIndex(storedPrintingIndex);
} else {
} else if (!storedProviderId.isEmpty()) {
// Stored printing not in the local database: keep it rather than
// silently substituting the first printing.
currentParams.cardProviderId = initial.cardProviderId;
currentParams.cardProviderId = storedProviderId;
reloadPreview();
}
}
marginLSpin->setValue(initial.marginPctL);
marginRSpin->setValue(initial.marginPctR);
verticalOffsetSpin->setValue(initial.verticalOffset);
zoomSpin->setValue(initial.zoom);
marginLSpin->setValue(currentParams.marginPctL);
marginRSpin->setValue(currentParams.marginPctR);
}
CardArtParams UserCardArtSettingsDialog::params() const
@ -150,7 +354,6 @@ QDoubleSpinBox *UserCardArtSettingsDialog::makeSpinBox(double min, double max, d
void UserCardArtSettingsDialog::initializeSearchBar()
{
searchBar = new QLineEdit;
searchBar->setPlaceholderText(tr("Type a card name..."));
cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this);
@ -190,29 +393,33 @@ void UserCardArtSettingsDialog::setupUi()
marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01);
marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01);
verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01);
zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05);
auto *form = new QFormLayout;
form->addRow(tr("Card name:"), searchBar);
form->addRow(tr("Card ProviderId:"), providerComboBox);
form->addRow(tr("Left margin (%):"), marginLSpin);
form->addRow(tr("Right margin (%):"), marginRSpin);
form->addRow(tr("Vertical offset:"), verticalOffsetSpin);
form->addRow(tr("Zoom:"), zoomSpin);
cardNameLabel = new QLabel;
printingLabel = new QLabel;
marginLLabel = new QLabel;
marginRLabel = new QLabel;
form->addRow(cardNameLabel, searchBar);
form->addRow(printingLabel, providerComboBox);
form->addRow(marginLLabel, marginLSpin);
form->addRow(marginRLabel, marginRSpin);
auto *controlsGroup = new QGroupBox(tr("Parameters"));
controlsGroup = new QGroupBox;
controlsGroup->setLayout(form);
preview = new CardArtPreviewWidget;
auto *previewLayout = new QVBoxLayout;
previewLayout->addWidget(preview);
auto *previewGroup = new QGroupBox(tr("Preview"));
previewCaptionLabel = new QLabel;
previewCaptionLabel->setAlignment(Qt::AlignCenter);
previewCaptionLabel->setWordWrap(true);
previewLayout->addWidget(previewCaptionLabel);
previewGroup = new QGroupBox;
previewGroup->setLayout(previewLayout);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
auto *removeBtn = new QPushButton(tr("Remove Banner Card"));
removeBtn = new QPushButton;
buttons->addButton(removeBtn, QDialogButtonBox::ResetRole);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
@ -222,16 +429,38 @@ void UserCardArtSettingsDialog::setupUi()
accept();
});
// The banner leads visually, card selection and margins support it below.
auto *root = new QVBoxLayout;
root->addWidget(controlsGroup);
root->addWidget(previewGroup);
root->addWidget(controlsGroup);
root->addWidget(buttons);
setLayout(root);
connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
// Gestures are the only editors of offset and zoom on this surface.
// Margins stay explicit numeric controls: they trim the strip's
// sides and have no natural drag mapping.
connect(preview, &CardArtPreviewWidget::paramsEdited, this,
[this](const CardArtParams &edited) { currentParams = edited; });
retranslateUi();
}
void UserCardArtSettingsDialog::retranslateUi()
{
setWindowTitle(tr("Card Art Settings"));
searchBar->setPlaceholderText(tr("Type a card name..."));
cardNameLabel->setText(tr("Card name:"));
printingLabel->setText(tr("Printing:"));
marginLLabel->setText(tr("Left margin (%):"));
marginRLabel->setText(tr("Right margin (%):"));
controlsGroup->setTitle(tr("Card"));
previewCaptionLabel->setText(
tr("Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores."));
previewGroup->setTitle(tr("Banner"));
removeBtn->setText(tr("Remove Banner Card"));
}
void UserCardArtSettingsDialog::populateProviderCombo(const QString &cardName)
@ -281,7 +510,7 @@ void UserCardArtSettingsDialog::onCardNameChanged(const QString &name)
populateProviderCombo(name);
if (providerComboBox->count() == 0) {
// No printings found for this card; nothing to preview.
// No printings found for this card, nothing to preview.
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
currentParams.cardProviderId.clear();
@ -311,7 +540,7 @@ void UserCardArtSettingsDialog::reloadPreview()
// whichever CardInfo we just asked for, so the preview catches up once
// the image actually arrives instead of staying on the placeholder.
//
// Disconnect any previous listener first -- otherwise switching cards
// Disconnect any previous listener first, otherwise switching cards
// repeatedly stacks up connections to old CardInfo objects, each of
// which would still fire reloadPreview() (harmlessly, but wastefully)
// whenever ITS art finishes loading later.
@ -321,8 +550,8 @@ void UserCardArtSettingsDialog::reloadPreview()
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
if (fullRes.isNull()) {
// Not loaded yet -- wait for the signal instead of giving up.
// card.getCardPtr() is a CardInfoPtr (QSharedPointer<CardInfo>);
// Not loaded yet, wait for the signal instead of giving up.
// card.getCardPtr() is a CardInfoPtr (QSharedPointer<CardInfo>),
// .data() gives the raw QObject* needed for connect().
CardInfo *cardInfo = card.getCardPtr().data();
if (cardInfo) {
@ -345,7 +574,5 @@ void UserCardArtSettingsDialog::onParamChanged()
{
currentParams.marginPctL = marginLSpin->value();
currentParams.marginPctR = marginRSpin->value();
currentParams.verticalOffset = verticalOffsetSpin->value();
currentParams.zoom = zoomSpin->value();
preview->setParams(currentParams);
}
}

View file

@ -8,13 +8,29 @@
#include <QPixmap>
class QCompleter;
class QFocusEvent;
class QGroupBox;
class QKeyEvent;
class QMouseEvent;
class QLineEdit;
class QDoubleSpinBox;
class QLabel;
class QPushButton;
class QWheelEvent;
class CardDatabaseModel;
class CardDatabaseDisplayModel;
class CardSearchModel;
class CardCompleterProxyModel;
/**
* @brief Interactive preview of the user list banner art.
*
* Renders the banner strip with the given CardArtParams through the same
* UserListPainter::drawCardArt() the live delegate uses, including the
* avatar placeholder and fade masks. Dragging pans the art vertically at
* output scale, the wheel zooms, arrow keys nudge, Backspace or Esc
* restores the parameters as of focus gain.
*/
class CardArtPreviewWidget : public QWidget
{
Q_OBJECT
@ -26,13 +42,31 @@ public:
void setParams(const CardArtParams &params);
void setAttribution(const QString &attribution);
signals:
/** @brief Emitted whenever direct manipulation (drag, wheel, keys) changes the parameters. */
void paramsEdited(const CardArtParams &params);
protected:
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void focusInEvent(QFocusEvent *event) override;
private:
qreal bannerTravel() const; ///< vertical travel of the art behind the strip, in output pixels
void applyCropDelta(qreal dOffset, qreal zoomFactor);
bool sameCrop(const CardArtParams &a, const CardArtParams &b) const;
void restoreSnapshot();
QPixmap sourcePixmap;
CardArtParams params;
CardArtParams paramsAtFocusIn; ///< crop as of the latest focus gain, restored by Esc or Backspace
QString attributionText;
QPoint lastDragPos; ///< widget space position of the previous mouse move while panning
bool dragging{false}; ///< true between an accepted press and its release, guards stale drag positions
};
class UserCardArtSettingsDialog : public QDialog
@ -51,6 +85,7 @@ private slots:
private:
void setupUi();
void retranslateUi();
void populateProviderCombo(const QString &cardName);
void initializeSearchBar();
QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step);
@ -66,14 +101,20 @@ private:
QMetaObject::Connection pixmapUpdatedConnection;
QLabel *cardNameLabel;
QLabel *printingLabel;
QLabel *marginLLabel;
QLabel *marginRLabel;
QGroupBox *controlsGroup;
QLabel *previewCaptionLabel;
QGroupBox *previewGroup;
QPushButton *removeBtn;
QDoubleSpinBox *marginLSpin;
QDoubleSpinBox *marginRSpin;
QDoubleSpinBox *verticalOffsetSpin;
QDoubleSpinBox *zoomSpin;
CardArtPreviewWidget *preview;
QPixmap currentPixmap;
CardArtParams currentParams;
};
#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H
#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H

View file

@ -718,11 +718,19 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
}
});
// Section dividers can be collapsed/expanded by the user. Surface those
// changes only from real user interaction. Programmatic expansion is
// applied through setSectionExpanded() / setExpandedProgrammatically().
connect(userTree, &QTreeWidget::itemExpanded, this,
[this](QTreeWidgetItem *item) { handleSectionExpansion(item, true); });
connect(userTree, &QTreeWidget::itemCollapsed, this,
[this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); });
// Hide popup when list scrolls (reference row has moved)
connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] {
showPopupTimer->stop();
hidePopup(true);
requestAvatarsForVisibleItems();
requestVisibleItemResources();
});
// Forward join requests from popup upward
@ -738,7 +746,7 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
// Keep the popup-less scroll path alive for avatar prefetch.
connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this,
[this] { requestAvatarsForVisibleItems(); });
[this] { requestVisibleItemResources(); });
}
// Section dividers can be collapsed/expanded by the user. Surface those
@ -915,7 +923,7 @@ void UserListWidget::showEvent(QShowEvent *e)
if (!userInfoPopup) {
return;
}
requestAvatarsForVisibleItems();
requestVisibleItemResources();
}
void UserListWidget::applyDisplayMode()
@ -1075,6 +1083,10 @@ void UserListWidget::showPopupForUser(UserListTWI *item)
const QString userName = QString::fromStdString(item->getUserInfo().name());
avatarProvider->requestAvatar(userName); // ensure the hovered user's avatar is fetched promptly
if (cardArtParamsMap.contains(userName)) {
const CardArtParams &params = cardArtParamsMap.value(userName);
cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId);
}
const ServerInfo_User &info = item->getUserInfo();
const bool online = item->data(0, UserListRoles::Online).toBool();
@ -1256,7 +1268,7 @@ void UserListWidget::endBulkLoad()
bulkLoading = false;
sortItems();
updateCount(); // divider counts were deferred during the bulk build
requestAvatarsForVisibleItems();
requestVisibleItemResources();
userTree->viewport()->update();
}
@ -1269,8 +1281,20 @@ bool UserListWidget::isItemNearViewport(const UserListTWI *item) const
return userTree->visualItemRect(item).intersects(nearView);
}
void UserListWidget::requestAvatarsForVisibleItems()
void UserListWidget::requestVisibleItemResources()
{
const auto requestResources = [this](UserListTWI *twi) {
if (!isItemNearViewport(twi)) {
return;
}
const QString userName = QString::fromStdString(twi->getUserInfo().name());
avatarProvider->requestAvatar(userName);
if (cardArtParamsMap.contains(userName)) {
const CardArtParams &params = cardArtParamsMap.value(userName);
cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId);
}
};
if (sectioned) {
// Top level items are dividers, user rows hang below them.
for (const Section section : sectionIds) {
@ -1279,20 +1303,14 @@ void UserListWidget::requestAvatarsForVisibleItems()
continue;
}
for (int i = 0; i < divider->childCount(); ++i) {
auto *twi = static_cast<UserListTWI *>(divider->child(i));
if (isItemNearViewport(twi)) {
avatarProvider->requestAvatar(QString::fromStdString(twi->getUserInfo().name()));
}
requestResources(static_cast<UserListTWI *>(divider->child(i)));
}
}
return;
}
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()));
}
requestResources(static_cast<UserListTWI *>(userTree->topLevelItem(i)));
}
}
@ -1523,9 +1541,9 @@ void UserListWidget::updateCount()
}
}
void UserListWidget::setShowTitle(bool showTitle)
void UserListWidget::setShowTitle(bool _showTitle)
{
this->showTitle = showTitle;
this->showTitle = _showTitle;
updateCount();
}
@ -1572,7 +1590,7 @@ void UserListWidget::applyFilter()
}
updateSectionDivider(section);
}
requestAvatarsForVisibleItems();
requestVisibleItemResources();
userTree->viewport()->update();
emit userListChanged();
return;
@ -1590,7 +1608,7 @@ void UserListWidget::applyFilter()
}
}
requestAvatarsForVisibleItems();
requestVisibleItemResources();
userTree->viewport()->update();
emit userListChanged();
}

View file

@ -189,7 +189,7 @@ private:
bool isPressInsideListUi(const QWidget *widget) const;
void clearSelectionAndClosePopup();
bool isItemNearViewport(const UserListTWI *item) const;
void requestAvatarsForVisibleItems();
void requestVisibleItemResources();
// Sectioned mode (single tree with inline dividers)
bool sectioned = false;

View file

@ -1,5 +1,6 @@
#include "card_completer_delegate.h"
#include "../../pixel_map_generator.h"
#include "../cards/additional_info/mana_cost_widget.h"
#include <QFontMetrics>
@ -84,7 +85,6 @@ QColor CardCompleterDelegate::accentForColors(const QString &colors)
CardCompleterDelegate::CardCompleterDelegate(QObject *parent) : QStyledItemDelegate(parent)
{
symbolCache.setMaxCost(64);
setCodeCache.setMaxCost(64);
}
@ -108,36 +108,16 @@ QSize CardCompleterDelegate::sizeHint(const QStyleOptionViewItem &option, const
// 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);
const QPixmap px = ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(radius * 2, radius * 2));
if (px && !px->isNull()) {
p->drawPixmap(pip, *px);
if (!px.isNull()) {
p->drawPixmap(pip, px);
return;
}

View file

@ -31,9 +31,6 @@ public:
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;
@ -47,9 +44,6 @@ private:
// 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;

View file

@ -390,6 +390,7 @@ int main(int argc, char *argv[])
PingPixmapGenerator::clear();
CountryPixmapGenerator::clear();
UserLevelPixmapGenerator::clear();
ManaSymbolPixmapGenerator::clear();
return ret;
}

View file

@ -413,6 +413,10 @@ bool DeckList::loadFromFile_Plain(QIODevice *device, const std::function<QString
bool DeckList::saveToStream_Plain(QTextStream &stream, bool prefixSideboardCards, bool slashTappedOutSplitCards) const
{
auto writeToStream = [&stream, prefixSideboardCards, slashTappedOutSplitCards](const auto node, const auto card) {
// The maybeboard is scratch space and never exported.
if (node->getName() == DECK_ZONE_MAYBEBOARD) {
return;
}
if (prefixSideboardCards && node->getName() == DECK_ZONE_SIDE) {
stream << "SB: ";
}

View file

@ -41,13 +41,19 @@ QList<const DecklistCardNode *> DecklistNodeTree::getCardNodes(const QSet<QStrin
{
QList<const DecklistCardNode *> result;
for (auto *zoneNode : getZoneNodes(restrictToZones)) {
for (auto *cardNode : *zoneNode) {
auto *cardCardNode = dynamic_cast<DecklistCardNode *>(cardNode);
if (cardCardNode) {
result.append(cardCardNode);
std::function<void(const InnerDecklistNode *)> collectCards = [&collectCards,
&result](const InnerDecklistNode *node) {
for (int i = 0; i < node->size(); i++) {
if (auto *card = dynamic_cast<const DecklistCardNode *>(node->at(i))) {
result.append(card);
} else if (auto *inner = dynamic_cast<const InnerDecklistNode *>(node->at(i))) {
collectCards(inner);
}
}
};
for (auto *zoneNode : getZoneNodes(restrictToZones)) {
collectCards(zoneNode);
}
return result;
@ -160,13 +166,22 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode
void DecklistNodeTree::forEachCard(const std::function<void(InnerDecklistNode *, DecklistCardNode *)> &func) const
{
// Support for this is only possible if the internal structure
// doesn't get more complicated.
// Cards nested in custom zones are reported with their top-level board zone
// so that callers can classify cards by board (main/side/maybeboard/tokens).
std::function<void(InnerDecklistNode *, InnerDecklistNode *)> walk = [&func, &walk](InnerDecklistNode *boardZone,
InnerDecklistNode *node) {
for (int i = 0; i < node->size(); i++) {
if (auto *card = dynamic_cast<DecklistCardNode *>(node->at(i))) {
func(boardZone, card);
} else if (auto *inner = dynamic_cast<InnerDecklistNode *>(node->at(i))) {
walk(boardZone, inner);
}
}
};
for (int i = 0; i < root->size(); i++) {
InnerDecklistNode *node = dynamic_cast<InnerDecklistNode *>(root->at(i));
for (int j = 0; j < node->size(); j++) {
DecklistCardNode *card = dynamic_cast<DecklistCardNode *>(node->at(j));
func(node, card);
if (auto *zone = dynamic_cast<InnerDecklistNode *>(root->at(i))) {
walk(zone, zone);
}
}
}

View file

@ -78,9 +78,10 @@ public:
bool deleteNode(AbstractDecklistNode *node, InnerDecklistNode *rootNode = nullptr);
/**
* @brief Apply a function to every card in the deck tree. This can modify the cards.
* @brief Applies a function to every card in the deck tree. This can modify the cards.
*
* @param func Function taking (zone node, card node).
* @param func Function taking (top-level board zone node, card node). Cards nested
* in custom zones are reported with their board zone.
*/
void forEachCard(const std::function<void(InnerDecklistNode *, DecklistCardNode *)> &func) const;

View file

@ -28,6 +28,8 @@ QString InnerDecklistNode::visibleNameFromName(const QString &_name)
return QObject::tr("Sideboard");
} else if (_name == DECK_ZONE_TOKENS) {
return QObject::tr("Tokens");
} else if (_name == DECK_ZONE_MAYBEBOARD) {
return QObject::tr("Maybeboard");
} else {
return _name;
}

View file

@ -24,6 +24,8 @@
#define DECK_ZONE_SIDE "side"
/** @brief Constant for the "tokens" zone name. */
#define DECK_ZONE_TOKENS "tokens"
/** @brief Constant for the "maybeboard" zone name. */
#define DECK_ZONE_MAYBEBOARD "maybeboard"
/**
* @class InnerDecklistNode