Hiding the layer takes the focus off whatever stood on it and leaves the window with no focus widget at all, so the world view answered no keys afterwards: Escape opened the menu once, closed it, and then did nothing. Each modal now notes what held the keyboard when it opened and gives it back on the way out -- the world view for the first of a stack, the modal beneath for one above it, which was dead the same way. The window's own focus guard now also catches the focus going nowhere, rather than only landing on the wrong widget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
242 lines
7.5 KiB
C++
242 lines
7.5 KiB
C++
#include "ModalLayer.h"
|
|
|
|
#include <cstddef>
|
|
|
|
#include <QApplication>
|
|
#include <QEventLoop>
|
|
#include <QFrame>
|
|
#include <QMetaObject>
|
|
#include <QMouseEvent>
|
|
#include <QPainter>
|
|
#include <QPoint>
|
|
#include <QScrollArea>
|
|
#include <QSize>
|
|
|
|
#include "ModalDialog.h"
|
|
|
|
ModalLayer* ModalLayer::findFor(const QWidget& widget)
|
|
{
|
|
for (QWidget* candidate = widget.parentWidget(); candidate != nullptr;
|
|
candidate = candidate->parentWidget())
|
|
{
|
|
ModalLayer* layer = qobject_cast<ModalLayer*>(candidate);
|
|
if (layer != nullptr)
|
|
{
|
|
return layer;
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
ModalLayer::ModalLayer(const QColor& dimColor, QWidget* parent)
|
|
: QWidget(parent)
|
|
, m_dimColor(dimColor)
|
|
{
|
|
hide();
|
|
}
|
|
|
|
int ModalLayer::execute(ModalDialog& content, const QRect& anchorRect)
|
|
{
|
|
// Hosted in a scroll area so a modal larger than the window is reached by scrolling
|
|
// instead of being cut off by the window edge (REQ-UI-MODAL-CHROME). When the
|
|
// content fits -- which is the normal case -- the host is exactly its size and no
|
|
// scroll bar appears, so the player sees the modal alone.
|
|
// Noted before anything here touches the focus, and given back when this modal
|
|
// closes: the game world beneath for the first modal of a stack, and the modal it was
|
|
// opened from for one above that.
|
|
QWidget* const focusBefore = QApplication::focusWidget();
|
|
|
|
QScrollArea* host = new QScrollArea(this);
|
|
host->setFrameShape(QFrame::NoFrame);
|
|
host->setWidgetResizable(false);
|
|
host->setWidget(&content);
|
|
|
|
// Resolved before the new modal joins the stack, so a null rect means the modal it
|
|
// was opened from -- the Create Blueprint dialog opens on the layout dialog beneath
|
|
// it (REQ-UI-PANEL-MODAL).
|
|
const QRect anchor = !anchorRect.isNull() ? anchorRect
|
|
: (m_stack.empty() ? rect()
|
|
: m_stack.back().host->geometry());
|
|
|
|
m_stack.push_back(HostedModal{ &content, host, focusBefore });
|
|
|
|
// Placed and shown before the layer itself is, so that the frame the layer first
|
|
// paints already carries the modal. Showing the layer first put the dim on screen a
|
|
// frame before anything stood on it, which read as a black flash
|
|
// (REQ-UI-MODAL-DIM). Placing works while the layer is hidden: its geometry is the
|
|
// window's whether or not it is shown, and a child shown under a hidden parent
|
|
// appears with it.
|
|
raise();
|
|
place(*host, content, anchor);
|
|
host->show();
|
|
content.show();
|
|
updateVisibility();
|
|
|
|
// The widget the modal names where it names one -- a name dialog puts the caret in
|
|
// its line edit -- and the modal itself otherwise, so keys reach it and not the game
|
|
// world behind (REQ-UI-MODAL-CHROME).
|
|
QWidget* focusTarget = content.getInitialFocusWidget();
|
|
if (focusTarget == nullptr)
|
|
{
|
|
focusTarget = &content;
|
|
}
|
|
focusTarget->setFocus();
|
|
|
|
// The dialog's own loop, run here rather than by QDialog::exec(), so the layer knows
|
|
// what is open and can place it, dim behind it, and take the clicks beside it.
|
|
QEventLoop loop;
|
|
const QMetaObject::Connection connection =
|
|
connect(&content, &QDialog::finished, &loop, &QEventLoop::quit);
|
|
loop.exec();
|
|
disconnect(connection);
|
|
|
|
// takeWidget() before the host goes: the scroll area owns what it is given, and
|
|
// every modal here is a local of its caller. It hands the content back parentless.
|
|
host->takeWidget();
|
|
content.hide();
|
|
delete host;
|
|
|
|
QPointer<QWidget> focusAfter;
|
|
for (std::size_t i = m_stack.size(); i > 0; --i)
|
|
{
|
|
if (m_stack[i - 1].content == &content)
|
|
{
|
|
focusAfter = m_stack[i - 1].focusBefore;
|
|
m_stack.erase(m_stack.begin() + static_cast<std::ptrdiff_t>(i - 1));
|
|
break;
|
|
}
|
|
}
|
|
updateVisibility();
|
|
|
|
// After the layer is hidden, not before: hiding a widget takes the focus off
|
|
// everything on it, which would undo this again.
|
|
if (!focusAfter.isNull())
|
|
{
|
|
focusAfter->setFocus();
|
|
}
|
|
return content.result();
|
|
}
|
|
|
|
bool ModalLayer::isActive() const
|
|
{
|
|
return !m_stack.empty();
|
|
}
|
|
|
|
void ModalLayer::addHold()
|
|
{
|
|
++m_holdCount;
|
|
updateVisibility();
|
|
}
|
|
|
|
void ModalLayer::removeHold()
|
|
{
|
|
if (m_holdCount > 0)
|
|
{
|
|
--m_holdCount;
|
|
updateVisibility();
|
|
}
|
|
}
|
|
|
|
void ModalLayer::setDimColor(const QColor& dimColor)
|
|
{
|
|
m_dimColor = dimColor;
|
|
if (isVisible())
|
|
{
|
|
update();
|
|
}
|
|
}
|
|
|
|
void ModalLayer::paintEvent(QPaintEvent* /*event*/)
|
|
{
|
|
// One dim however many modals are stacked (REQ-UI-MODAL-DIM): the modals above it
|
|
// draw their own opaque background, so nesting darkens nothing twice.
|
|
QPainter painter(this);
|
|
painter.fillRect(rect(), m_dimColor);
|
|
}
|
|
|
|
void ModalLayer::mousePressEvent(QMouseEvent* event)
|
|
{
|
|
m_pressedOutside = event->button() == Qt::LeftButton
|
|
&& isOutsideOpenModal(event->pos());
|
|
|
|
// Taken whether or not it dismisses anything: the window behind a modal receives no
|
|
// input, and closing the modal does not turn this click into one for what lies under
|
|
// it (REQ-UI-DIALOG-DISMISS).
|
|
event->accept();
|
|
}
|
|
|
|
void ModalLayer::mouseReleaseEvent(QMouseEvent* event)
|
|
{
|
|
const bool pressedOutside = m_pressedOutside;
|
|
m_pressedOutside = false;
|
|
event->accept();
|
|
|
|
if (event->button() != Qt::LeftButton || !pressedOutside
|
|
|| !isOutsideOpenModal(event->pos()) || m_stack.empty())
|
|
{
|
|
return;
|
|
}
|
|
|
|
// A drag that began inside the modal never gets here -- the widget it began on keeps
|
|
// the release -- so no gesture ends by discarding the modal it was made in.
|
|
ModalDialog* openModal = m_stack.back().content;
|
|
if (openModal->isDismissible())
|
|
{
|
|
openModal->requestDismiss();
|
|
}
|
|
}
|
|
|
|
bool ModalLayer::isOutsideOpenModal(const QPoint& position) const
|
|
{
|
|
if (m_stack.empty())
|
|
{
|
|
return true;
|
|
}
|
|
return !m_stack.back().host->geometry().contains(position);
|
|
}
|
|
|
|
void ModalLayer::place(QScrollArea& host, ModalDialog& content,
|
|
const QRect& anchorRect) const
|
|
{
|
|
// The content has never been shown, so it is still at its default size until its
|
|
// layout has run; sizing it before that would use the wrong extent.
|
|
content.adjustSize();
|
|
|
|
const QSize hostSize = content.size().boundedTo(size());
|
|
host.resize(hostSize);
|
|
|
|
QPoint topLeft(anchorRect.center().x() - hostSize.width() / 2,
|
|
anchorRect.center().y() - hostSize.height() / 2);
|
|
|
|
// Pushed back inside the layer, which is the game window (REQ-UI-PANEL-MODAL). The
|
|
// far edge is clamped first and the near edge second, which is what aligns a modal
|
|
// as large as the window with the window's top-left corner rather than pushing it
|
|
// off the opposite edge.
|
|
topLeft.setX(qMax(0, qMin(topLeft.x(), width() - hostSize.width())));
|
|
topLeft.setY(qMax(0, qMin(topLeft.y(), height() - hostSize.height())));
|
|
host.move(topLeft);
|
|
}
|
|
|
|
void ModalLayer::updateVisibility()
|
|
{
|
|
const bool shouldShow = !m_stack.empty() || m_holdCount > 0;
|
|
if (shouldShow == isVisible())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (shouldShow)
|
|
{
|
|
raise();
|
|
show();
|
|
// Painted at once rather than at the next paint event, so no undimmed frame of
|
|
// the world flashes through in between. The modal standing on the layer is
|
|
// already placed and shown by now, so this one paint puts up both (execute()).
|
|
repaint();
|
|
}
|
|
else
|
|
{
|
|
hide();
|
|
}
|
|
}
|