Cockatrice/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp
BruebachL 8ca749c07d
[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>
2026-09-20 20:22:17 +02:00

266 lines
No EOL
8.4 KiB
C++

/**
* @file flow_widget.cpp
* @brief Implementation of FlowWidget — a QWidget hosting a FlowLayout inside an
* optional QScrollArea.
*/
#include "flow_widget.h"
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QResizeEvent>
#include <QScrollArea>
#include <QSizePolicy>
#include <QWidget>
/**
* @brief Constructs a FlowWidget.
*
* When both scroll policies are Qt::ScrollBarAlwaysOff the scroll area is
* omitted entirely and the container is placed directly in the main layout.
*
* @param parent Parent widget.
* @param _flowDirection Qt::Horizontal for row-wrapping, Qt::Vertical for column-wrapping.
* @param horizontalPolicy Horizontal scroll-bar policy.
* @param verticalPolicy Vertical scroll-bar policy.
*/
FlowWidget::FlowWidget(QWidget *parent,
const Qt::Orientation _flowDirection,
const Qt::ScrollBarPolicy horizontalPolicy,
const Qt::ScrollBarPolicy verticalPolicy)
: QWidget(parent), scrollArea(nullptr), flowDirection(_flowDirection)
{
// Top-level size policy
// Horizontal flow: expand horizontally, let height be determined by wrapping.
// Vertical flow: expand vertically, let width be determined by wrapping.
if (flowDirection == Qt::Horizontal) {
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
} else {
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
}
mainLayout = new QHBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
setLayout(mainLayout);
const bool useScrollArea = (horizontalPolicy != Qt::ScrollBarAlwaysOff || verticalPolicy != Qt::ScrollBarAlwaysOff);
// Scroll area (optional)
if (useScrollArea) {
scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setMinimumSize(0, 0);
scrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
// Set scrollbar policies
scrollArea->setHorizontalScrollBarPolicy(horizontalPolicy);
scrollArea->setVerticalScrollBarPolicy(verticalPolicy);
} else {
scrollArea = nullptr;
}
// Container widget (holds the FlowLayout)
container = new QWidget(useScrollArea ? static_cast<QWidget *>(scrollArea) : this);
// The container should be willing to grow in both axes; its actual size is
// governed by the FlowLayout's sizeHint / heightForWidth, not by a fixed policy.
container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred);
container->setMinimumSize(0, 0);
flowLayout = new FlowLayout(container, flowDirection);
container->setLayout(flowLayout);
if (useScrollArea) {
scrollArea->setWidget(container);
mainLayout->addWidget(scrollArea);
} else {
mainLayout->addWidget(container);
}
}
/**
* @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)
{
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);
update();
}
void FlowWidget::removeWidget(QWidget *widgetToRemove) const
{
flowLayout->removeWidget(widgetToRemove);
}
/**
* @brief Removes all widgets from the flow layout and deletes them.
*
* If the layout pointer has somehow been lost it is recreated before returning.
*/
void FlowWidget::clearLayout()
{
if (flowLayout) {
QLayoutItem *item;
while ((item = flowLayout->takeAt(0))) {
if (item->widget()) {
item->widget()->deleteLater();
}
delete item;
}
} else {
// Defensive fallback: recreate the layout if it was deleted externally.
flowLayout = new FlowLayout(container, flowDirection);
container->setLayout(flowLayout);
}
}
/**
* @brief Marks the flow layout as dirty so Qt recomputes item positions.
*
* We do NOT call adjustSize() or activate() here:
* - adjustSize() would freeze geometry by calling setFixedSize internally.
* - activate() called inside a resize event can cause synchronous re-entrancy.
* Qt automatically calls setGeometry on the layout after a resize, so simply
* invalidating is sufficient.
*/
void FlowWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
qCDebug(FlowWidgetSizeLog) << "resizeEvent:" << event->size();
if (flowLayout) {
flowLayout->invalidate();
}
}
void FlowWidget::setSpacing(int hSpacing, int vSpacing)
{
flowLayout->setHorizontalMargin(hSpacing);
flowLayout->setVerticalMargin(vSpacing);
flowLayout->invalidate();
}
/**
* @brief Sets every child widget's minimum size to the largest sizeHint in the layout.
*
* Useful for toolbars or button bars where all items should be the same size.
*/
void FlowWidget::setMinimumSizeToMaxSizeHint()
{
QSize maxSize(0, 0);
// Iterate over all widgets in the flow layout to find the maximum sizeHint
for (int i = 0; i < flowLayout->count(); ++i) {
QLayoutItem *item = flowLayout->itemAt(i);
if (item && item->widget()) {
maxSize = maxSize.expandedTo(item->widget()->sizeHint());
}
}
// Set the minimum size for all widgets to the max sizeHint
for (int i = 0; i < flowLayout->count(); ++i) {
QLayoutItem *item = flowLayout->itemAt(i);
if (item && item->widget()) {
item->widget()->setMinimumSize(maxSize);
}
}
}
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();
}