diff --git a/src/ui/drag_drop_controller.cpp b/src/ui/drag_drop_controller.cpp index 3db8ca26b6..f1a92ca99b 100644 --- a/src/ui/drag_drop_controller.cpp +++ b/src/ui/drag_drop_controller.cpp @@ -48,6 +48,31 @@ namespace { return safe; } + // One axis of a scaled preview. The render transform scales about + // transformOrigin, so the painted box starts before the node position + // whenever the origin is not the leading edge. + struct PreviewAxis { + float position; + float size; + float scale; + float transformOrigin; + float overlayExtent; + }; + + float clampPreviewPosition(const PreviewAxis& axis) { + const float paintedStart = axis.transformOrigin * (1.0F - axis.scale); + const float paintedEnd = paintedStart + axis.size * axis.scale; + const float minimum = -paintedStart; + const float maximum = axis.overlayExtent - paintedEnd; + if (minimum > maximum) { + // The preview is larger than the overlay, so it cannot fit inside it. + // Keep it covering the overlay instead; it still follows the pointer + // until one of the overlay edges would be uncovered. + return std::clamp(axis.position, maximum, minimum); + } + return std::clamp(axis.position, minimum, maximum); + } + } // namespace DragDropController::~DragDropController() { @@ -336,9 +361,25 @@ void DragDropController::updatePreview(float sceneX, float sceneY) { } float localX = 0.0F; float localY = 0.0F; - if (Node::mapFromScene(m_overlayRoot, sceneX - m_pointerOffsetX, sceneY - m_pointerOffsetY, localX, localY)) { - m_preview->setPosition(localX, localY); - } + // Pointer capture can deliver positions outside the overlay. mapFromScene still + // provides the transformed coordinates when its containment result is false. + (void)Node::mapFromScene(m_overlayRoot, sceneX - m_pointerOffsetX, sceneY - m_pointerOffsetY, localX, localY); + m_preview->setPosition( + clampPreviewPosition({ + .position = localX, + .size = m_preview->width(), + .scale = m_preview->scaleX(), + .transformOrigin = m_preview->transformOriginX(), + .overlayExtent = m_overlayRoot->width(), + }), + clampPreviewPosition({ + .position = localY, + .size = m_preview->height(), + .scale = m_preview->scaleY(), + .transformOrigin = m_preview->transformOriginY(), + .overlayExtent = m_overlayRoot->height(), + }) + ); } void DragDropController::clearPreview() { diff --git a/tests/ui_tree_reconciler_test.cpp b/tests/ui_tree_reconciler_test.cpp index 1d4ebf54b5..e2b388c839 100644 --- a/tests/ui_tree_reconciler_test.cpp +++ b/tests/ui_tree_reconciler_test.cpp @@ -1704,6 +1704,72 @@ int main() { ) && ok; + if (proxy != nullptr) { + const auto previewBounds = [proxy]() { + float left = 0.0f; + float top = 0.0f; + float right = 0.0f; + float bottom = 0.0f; + Node::transformedBounds(proxy, left, top, right, bottom); + return LayoutRect{.x = left, .y = top, .width = right - left, .height = bottom - top}; + }; + + source->inputArea()->dispatchMotion(-100.0f, -100.0f); + auto bounds = previewBounds(); + ok = expect(bounds.x >= -0.001f && bounds.y >= -0.001f, "drag preview stays inside the top-left edge") && ok; + + const float leftEdgeX = proxy->x(); + const float topEdgeY = proxy->y(); + source->inputArea()->dispatchMotion(-100.0f, 80.0f); + ok = expect( + proxy->x() == leftEdgeX && proxy->y() > topEdgeY, + "drag preview keeps moving vertically along a clamped left edge" + ) + && ok; + + source->inputArea()->dispatchMotion(overlay.width() + 100.0f, overlay.height() + 100.0f); + bounds = previewBounds(); + ok = expect( + bounds.x + bounds.width <= overlay.width() + 0.001f + && bounds.y + bounds.height <= overlay.height() + 0.001f, + "drag preview stays inside the bottom-right edge" + ) + && ok; + + const float rightEdgeX = proxy->x(); + const float bottomEdgeY = proxy->y(); + source->inputArea()->dispatchMotion(80.0f, overlay.height() + 100.0f); + ok = expect( + proxy->x() < rightEdgeX && proxy->y() == bottomEdgeY, + "drag preview keeps moving horizontally along a clamped bottom edge" + ) + && ok; + + // A preview larger than the overlay cannot fit inside it. It covers the + // overlay instead, and keeps tracking the pointer within that range. + overlay.setSize(60.0f, 20.0f); + source->inputArea()->dispatchMotion(0.0f, 0.0f); + bounds = previewBounds(); + const float coveringX = proxy->x(); + ok = expect( + bounds.x <= 0.001f + && bounds.y <= 0.001f + && bounds.x + bounds.width >= overlay.width() - 0.001f + && bounds.y + bounds.height >= overlay.height() - 0.001f, + "an oversized drag preview keeps covering the overlay" + ) + && ok; + + source->inputArea()->dispatchMotion(-30.0f, 0.0f); + bounds = previewBounds(); + ok = expect( + proxy->x() < coveringX && bounds.x + bounds.width >= overlay.width() - 0.001f, + "an oversized drag preview still follows the pointer while covering the overlay" + ) + && ok; + overlay.setSize(400.0f, 160.0f); + } + source->inputArea()->dispatchPress(localX, localY, BTN_LEFT, false); ok = expect(overlay.children().empty(), "drop removes drag preview before callback rerender") && ok; ok = expect(