From 76e883a0022e7d7c4f68425d25d5cc995e35bcc4 Mon Sep 17 00:00:00 2001 From: radium Date: Sat, 22 Aug 2026 22:09:39 +0300 Subject: [PATCH 1/2] fix(ui): clamp drag preview to overlay bounds --- src/ui/drag_drop_controller.cpp | 26 +++++++++++++++--- tests/ui_tree_reconciler_test.cpp | 44 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/ui/drag_drop_controller.cpp b/src/ui/drag_drop_controller.cpp index 3db8ca26b6..7bfad05217 100644 --- a/src/ui/drag_drop_controller.cpp +++ b/src/ui/drag_drop_controller.cpp @@ -48,6 +48,18 @@ namespace { return safe; } + float + clampPreviewPosition(float position, float previewSize, float previewScale, float transformOrigin, float limit) { + const float scaledStart = transformOrigin * (1.0F - previewScale); + const float scaledEnd = scaledStart + previewSize * previewScale; + const float minimum = -scaledStart; + const float maximum = limit - scaledEnd; + if (minimum > maximum) { + return (limit - scaledStart - scaledEnd) * 0.5F; + } + return std::clamp(position, minimum, maximum); + } + } // namespace DragDropController::~DragDropController() { @@ -336,9 +348,17 @@ 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( + localX, m_preview->width(), m_preview->scaleX(), m_preview->transformOriginX(), m_overlayRoot->width() + ), + clampPreviewPosition( + localY, m_preview->height(), m_preview->scaleY(), m_preview->transformOriginY(), m_overlayRoot->height() + ) + ); } void DragDropController::clearPreview() { diff --git a/tests/ui_tree_reconciler_test.cpp b/tests/ui_tree_reconciler_test.cpp index 1d4ebf54b5..a8a08c51ee 100644 --- a/tests/ui_tree_reconciler_test.cpp +++ b/tests/ui_tree_reconciler_test.cpp @@ -1704,6 +1704,50 @@ int main() { ) && ok; + if (proxy != nullptr) { + const auto previewBounds = [proxy]() { + const float left = proxy->x() + proxy->transformOriginX() * (1.0f - proxy->scaleX()); + const float top = proxy->y() + proxy->transformOriginY() * (1.0f - proxy->scaleY()); + return LayoutRect{ + .x = left, + .y = top, + .width = proxy->width() * proxy->scaleX(), + .height = proxy->height() * proxy->scaleY(), + }; + }; + + 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; + } + source->inputArea()->dispatchPress(localX, localY, BTN_LEFT, false); ok = expect(overlay.children().empty(), "drop removes drag preview before callback rerender") && ok; ok = expect( From 289e92945ae0e9c31d6dc56455839e5e0c08e2e5 Mon Sep 17 00:00:00 2001 From: Lemmy Date: Tue, 1 Sep 2026 18:22:10 -0400 Subject: [PATCH 2/2] fix(ui): clamp drag preview to overlay bounds --- src/ui/drag_drop_controller.cpp | 49 ++++++++++++++++++++++--------- tests/ui_tree_reconciler_test.cpp | 38 +++++++++++++++++++----- 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/src/ui/drag_drop_controller.cpp b/src/ui/drag_drop_controller.cpp index 7bfad05217..f1a92ca99b 100644 --- a/src/ui/drag_drop_controller.cpp +++ b/src/ui/drag_drop_controller.cpp @@ -48,16 +48,29 @@ namespace { return safe; } - float - clampPreviewPosition(float position, float previewSize, float previewScale, float transformOrigin, float limit) { - const float scaledStart = transformOrigin * (1.0F - previewScale); - const float scaledEnd = scaledStart + previewSize * previewScale; - const float minimum = -scaledStart; - const float maximum = limit - scaledEnd; + // 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) { - return (limit - scaledStart - scaledEnd) * 0.5F; + // 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(position, minimum, maximum); + return std::clamp(axis.position, minimum, maximum); } } // namespace @@ -352,12 +365,20 @@ void DragDropController::updatePreview(float sceneX, float sceneY) { // 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( - localX, m_preview->width(), m_preview->scaleX(), m_preview->transformOriginX(), m_overlayRoot->width() - ), - clampPreviewPosition( - localY, m_preview->height(), m_preview->scaleY(), m_preview->transformOriginY(), m_overlayRoot->height() - ) + 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(), + }) ); } diff --git a/tests/ui_tree_reconciler_test.cpp b/tests/ui_tree_reconciler_test.cpp index a8a08c51ee..e2b388c839 100644 --- a/tests/ui_tree_reconciler_test.cpp +++ b/tests/ui_tree_reconciler_test.cpp @@ -1706,14 +1706,12 @@ int main() { if (proxy != nullptr) { const auto previewBounds = [proxy]() { - const float left = proxy->x() + proxy->transformOriginX() * (1.0f - proxy->scaleX()); - const float top = proxy->y() + proxy->transformOriginY() * (1.0f - proxy->scaleY()); - return LayoutRect{ - .x = left, - .y = top, - .width = proxy->width() * proxy->scaleX(), - .height = proxy->height() * proxy->scaleY(), - }; + 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); @@ -1746,6 +1744,30 @@ int main() { "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);