/* This file is part of KDDockWidgets. SPDX-FileCopyrightText: 2019-2023 Klarälvdalens Datakonsult AB, a KDAB Group company Author: Sérgio Martins SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only Contact KDAB at for commercial licensing options. */ #include "WidgetResizeHandler_p.h" #include "FloatingWindow_p.h" #include "TitleBar_p.h" #include "DragController_p.h" #include "Config.h" #include "Qt5Qt6Compat_p.h" #include "Utils_p.h" #include "DockRegistry_p.h" #include "MDILayoutWidget_p.h" #include #include #include #include #include #include #include #if defined(Q_OS_WIN) #include #include #include #include #if defined(Q_CC_MSVC) #pragma comment(lib, "Dwmapi.lib") #pragma comment(lib, "User32.lib") #endif #endif using namespace KDDockWidgets; bool WidgetResizeHandler::s_disableAllHandlers = false; WidgetResizeHandler::WidgetResizeHandler(EventFilterMode filterMode, WindowMode windowMode, QWidgetOrQuick *target) : QObject(target) , m_usesGlobalEventFilter(filterMode == EventFilterMode::Global) , m_isTopLevelWindowResizer(windowMode == WindowMode::TopLevel) { setTarget(target); } WidgetResizeHandler::~WidgetResizeHandler() { } void WidgetResizeHandler::setAllowedResizeSides(CursorPositions sides) { mAllowedResizeSides = sides; } void WidgetResizeHandler::setResizeGap(int gap) { m_resizeGap = gap; } bool WidgetResizeHandler::isMDI() const { auto frame = qobject_cast(mTarget); return frame && frame->isMDI(); } bool WidgetResizeHandler::isResizing() const { return m_resizingInProgress; } int WidgetResizeHandler::widgetResizeHandlerMargin() { return 4; // pixels } bool WidgetResizeHandler::eventFilter(QObject *o, QEvent *e) { if (s_disableAllHandlers) return false; auto widget = qobject_cast(o); if (!widget) return false; auto me = mouseEvent(e); if (!me) return false; if (m_isTopLevelWindowResizer) { // Case #1.0: Resizing FloatingWindow if (!widget->isTopLevel() || o != mTarget) { if (m_usesGlobalEventFilter) { // Case #1.1: FloatingWindows on EGLFS // EGLFS doesn't support storing mouse cursor shape per window, so we need to use global filter // do detect mouse leaving the window if (!m_resizingInProgress) { const QPoint globalPos = Qt5Qt6Compat::eventGlobalPos(me); updateCursor(cursorPosition(globalPos)); } } // Case #1.2: FloatingWindows on all other platforms // Not needed to mess with the cursor, it gets set when moving over another window. return false; } } else if (isMDI()) { // Case #2: Resizing an embedded MDI "Window" // Each Frame has a WidgetResizeHandler instance. // mTarget is the Frame we want to resize. // but 'o' might not be mTarget, because we're using a global event filter. // The global event filter is required because we allow the cursor to be outside the frame, a few pixels // so we have a nice resize margin. // Here we deal with the case where our mTarget, let's say "Frame 1" is on top of "Frame 2" but cursor // is near "Frame 2"'s margins, and would show resize cursor. // We only want to continue if the cursor is near the margins of our own frame (mTarget) auto frame = firstParentOfType(widget); if (frame && frame->isMDIWrapper()) { // We don't care about the inner Option_MDINestable helper frame frame = frame->mdiFrame(); } if (frame && frame != mTarget) { const bool areSiblings = frame->QWidgetAdapter::parentWidget() == mTarget->parentWidget(); if (areSiblings) return false; } } switch (e->type()) { case QEvent::MouseButtonPress: { if (mTarget->isMaximized()) break; auto mouseEvent = static_cast(e); auto cursorPos = cursorPosition(Qt5Qt6Compat::eventGlobalPos(mouseEvent)); updateCursor(cursorPos); if (cursorPos == CursorPosition_Undefined) return false; const int m = widgetResizeHandlerMargin(); const QRect widgetRect = mTarget->rect().marginsAdded(QMargins(m, m, m, m)); const QPoint cursorPoint = mTarget->mapFromGlobal(Qt5Qt6Compat::eventGlobalPos(mouseEvent)); if (!widgetRect.contains(cursorPoint) || mouseEvent->button() != Qt::LeftButton) return false; m_resizingInProgress = true; if (isMDI()) Q_EMIT DockRegistry::self()->frameInMDIResizeChanged(); mNewPosition = Qt5Qt6Compat::eventGlobalPos(mouseEvent); mCursorPos = cursorPos; return true; } case QEvent::MouseButtonRelease: { m_resizingInProgress = false; if (isMDI()) { Q_EMIT DockRegistry::self()->frameInMDIResizeChanged(); auto frame = static_cast(mTarget); // Usually in KDDW all geometry changes are done in the layout items, which propagate to the widgets // When resizing a MDI however, we're resizing the widget directly. So update the corresponding layout // item when we're finished. frame->mdiLayoutWidget()->setDockWidgetGeometry(frame, frame->QWidgetAdapter::geometry()); } updateCursor(CursorPosition_Undefined); auto mouseEvent = static_cast(e); if (mTarget->isMaximized() || !m_resizingInProgress || mouseEvent->button() != Qt::LeftButton) break; mTarget->releaseMouse(); mTarget->releaseKeyboard(); return true; break; } case QEvent::MouseMove: { if (mTarget->isMaximized()) break; if (isMDI()) { const Frame *frameBeingResized = DockRegistry::self()->frameInMDIResize(); const bool otherFrameBeingResized = frameBeingResized && frameBeingResized != mTarget; if (otherFrameBeingResized) { // only one at a time! return false; } } auto mouseEvent = static_cast(e); m_resizingInProgress = m_resizingInProgress && (mouseEvent->buttons() & Qt::LeftButton); const bool consumed = mouseMoveEvent(mouseEvent); return consumed; } default: break; } return false; } bool WidgetResizeHandler::mouseMoveEvent(QMouseEvent *e) { const QPoint globalPos = Qt5Qt6Compat::eventGlobalPos(e); if (!m_resizingInProgress) { const CursorPosition pos = cursorPosition(globalPos); updateCursor(pos); return pos != CursorPosition_Undefined; } const QRect oldGeometry = KDDockWidgets::globalGeometry(mTarget); QRect newGeometry = oldGeometry; QRect parentGeometry; if (!mTarget->isTopLevel()) { auto parent = KDDockWidgets::Private::parentWidget(mTarget); parentGeometry = KDDockWidgets::globalGeometry(parent); } { int deltaWidth = 0; int newWidth = 0; const int maxWidth = Layouting::Widget::widgetMaxSize(mTarget).width(); const int minWidth = Layouting::Widget::widgetMinSize(mTarget).width(); switch (mCursorPos) { case CursorPosition_TopLeft: case CursorPosition_Left: case CursorPosition_BottomLeft: { parentGeometry = parentGeometry.adjusted(0, m_resizeGap, 0, 0); deltaWidth = oldGeometry.left() - globalPos.x(); newWidth = qBound(minWidth, mTarget->width() + deltaWidth, maxWidth); deltaWidth = newWidth - mTarget->width(); if (deltaWidth != 0) { newGeometry.setLeft(newGeometry.left() - deltaWidth); } break; } case CursorPosition_TopRight: case CursorPosition_Right: case CursorPosition_BottomRight: { parentGeometry = parentGeometry.adjusted(0, 0, -m_resizeGap, 0); deltaWidth = globalPos.x() - newGeometry.right(); newWidth = qBound(minWidth, mTarget->width() + deltaWidth, maxWidth); deltaWidth = newWidth - mTarget->width(); if (deltaWidth != 0) { newGeometry.setRight(oldGeometry.right() + deltaWidth); } break; } default: break; } } { const int maxHeight = Layouting::Widget::widgetMaxSize(mTarget).height(); const int minHeight = Layouting::Widget::widgetMinSize(mTarget).height(); int deltaHeight = 0; int newHeight = 0; switch (mCursorPos) { case CursorPosition_TopLeft: case CursorPosition_Top: case CursorPosition_TopRight: { parentGeometry = parentGeometry.adjusted(0, m_resizeGap, 0, 0); deltaHeight = oldGeometry.top() - globalPos.y(); newHeight = qBound(minHeight, mTarget->height() + deltaHeight, maxHeight); deltaHeight = newHeight - mTarget->height(); if (deltaHeight != 0) { newGeometry.setTop(newGeometry.top() - deltaHeight); } break; } case CursorPosition_BottomLeft: case CursorPosition_Bottom: case CursorPosition_BottomRight: { parentGeometry = parentGeometry.adjusted(0, 0, 0, -m_resizeGap); deltaHeight = globalPos.y() - newGeometry.bottom(); newHeight = qBound(minHeight, mTarget->height() + deltaHeight, maxHeight); deltaHeight = newHeight - mTarget->height(); if (deltaHeight != 0) { newGeometry.setBottom(oldGeometry.bottom() + deltaHeight); } break; } default: break; } } if (newGeometry == mTarget->geometry()) { // Nothing to do. return true; } if (!mTarget->isTopLevel()) { // Clip to parent's geometry. newGeometry = newGeometry.intersected(parentGeometry); // Back to local. newGeometry.moveTopLeft(mTarget->mapFromGlobal(newGeometry.topLeft()) + mTarget->pos()); } mTarget->setGeometry(newGeometry); return true; } #ifdef Q_OS_WIN /// Handler to enable Aero-snap bool WidgetResizeHandler::handleWindowsNativeEvent(FloatingWindow *fw, const QByteArray &eventType, void *message, Qt5Qt6Compat::qintptr *result) { if (eventType != "windows_generic_MSG") return false; auto msg = static_cast(message); if (msg->message == WM_NCHITTEST) { if (DragController::instance()->isInClientDrag()) { // There's a non-native drag going on. *result = 0; return false; } const QRect htCaptionRect = fw->dragRect(); const bool ret = handleWindowsNativeEvent(fw->windowHandle(), msg, result, htCaptionRect); fw->setLastHitTest(*result); return ret; } else if (msg->message == WM_NCLBUTTONDBLCLK) { if ((Config::self().flags() & Config::Flag_DoubleClickMaximizes)) { return handleWindowsNativeEvent(fw->windowHandle(), msg, result, {}); } else { // Let the title bar handle it. It will re-dock the window. if (TitleBar *titleBar = fw->titleBar()) { if (titleBar->isVisible()) { // can't be invisible afaik titleBar->onDoubleClicked(); } } return true; } } return handleWindowsNativeEvent(fw->windowHandle(), msg, result, {}); } bool WidgetResizeHandler::handleWindowsNativeEvent(QWindow *w, MSG *msg, Qt5Qt6Compat::qintptr *result, const NativeFeatures &features) { if (msg->message == WM_NCCALCSIZE && features.hasShadow()) { *result = 0; return true; } else if (msg->message == WM_NCHITTEST && (features.hasResize() || features.hasDrag())) { const int borderWidth = 8; const bool hasFixedWidth = w->minimumWidth() == w->maximumWidth(); const bool hasFixedHeight = w->minimumHeight() == w->maximumHeight(); *result = 0; const int xPos = GET_X_LPARAM(msg->lParam); const int yPos = GET_Y_LPARAM(msg->lParam); RECT rect; GetWindowRect(reinterpret_cast(w->winId()), &rect); if (xPos >= rect.left && xPos <= rect.left + borderWidth && yPos <= rect.bottom && yPos >= rect.bottom - borderWidth && features.hasResize()) { *result = HTBOTTOMLEFT; } else if (xPos < rect.right && xPos >= rect.right - borderWidth && yPos <= rect.bottom && yPos >= rect.bottom - borderWidth && features.hasResize()) { *result = HTBOTTOMRIGHT; } else if (xPos >= rect.left && xPos <= rect.left + borderWidth && yPos >= rect.top && yPos <= rect.top + borderWidth && features.hasResize()) { *result = HTTOPLEFT; } else if (xPos <= rect.right && xPos >= rect.right - borderWidth && yPos >= rect.top && yPos < rect.top + borderWidth && features.hasResize()) { *result = HTTOPRIGHT; } else if (!hasFixedWidth && xPos >= rect.left && xPos <= rect.left + borderWidth && features.hasResize()) { *result = HTLEFT; } else if (!hasFixedHeight && yPos >= rect.top && yPos <= rect.top + borderWidth && features.hasResize()) { *result = HTTOP; } else if (!hasFixedHeight && yPos <= rect.bottom && yPos >= rect.bottom - borderWidth && features.hasResize()) { *result = HTBOTTOM; } else if (!hasFixedWidth && xPos <= rect.right && xPos >= rect.right - borderWidth && features.hasResize()) { *result = HTRIGHT; } else if (features.hasDrag()) { const QPoint globalPosQt = QHighDpi::fromNativePixels(QPoint(xPos, yPos), w); // htCaptionRect is the rect on which we allow for Windows to do a native drag const QRect htCaptionRect = features.htCaptionRect; if (globalPosQt.y() >= htCaptionRect.top() && globalPosQt.y() <= htCaptionRect.bottom() && globalPosQt.x() >= htCaptionRect.left() && globalPosQt.x() <= htCaptionRect.right()) { if (!KDDockWidgets::inDisallowDragWidget(globalPosQt)) { // Just makes sure the mouse isn't over the close button, we don't allow drag in that case. *result = HTCAPTION; } } } return *result != 0; } else if (msg->message == WM_NCLBUTTONDBLCLK && features.hasMaximize()) { // By returning false we accept Windows native action, a maximize. // We could also call titleBar->onDoubleClicked(); here which will maximize if Flag_DoubleClickMaximizes is set, // but there's a bug in QWidget::showMaximized() on Windows when we're covering the native title bar, the window is maximized with an offset. // So instead, use a native maximize which works well return false; } else if (msg->message == WM_GETMINMAXINFO) { // Qt doesn't work well with windows that don't have title bar but have native frames. // When maximized they go out of bounds and the title bar is clipped, so catch WM_GETMINMAXINFO // and patch the size // According to microsoft docs it only works for the primary screen, but extrapolates for the others QScreen *screen = QGuiApplication::primaryScreen(); if (!screen || w->screen() != screen) { return false; } DefWindowProc(msg->hwnd, msg->message, msg->wParam, msg->lParam); const QRect availableGeometry = screen->availableGeometry(); auto mmi = reinterpret_cast(msg->lParam); const qreal dpr = screen->devicePixelRatio(); mmi->ptMaxSize.y = int(availableGeometry.height() * dpr); mmi->ptMaxSize.x = int(availableGeometry.width() * dpr) - 1; // -1 otherwise it gets bogus size mmi->ptMaxPosition.x = availableGeometry.x(); mmi->ptMaxPosition.y = availableGeometry.y(); mmi->ptMinTrackSize.x = int(w->minimumWidth() * dpr); mmi->ptMinTrackSize.y = int(w->minimumHeight() * dpr); *result = 0; return true; } return false; } #endif void WidgetResizeHandler::setTarget(QWidgetOrQuick *w) { if (w) { mTarget = w; mTarget->setMouseTracking(true); if (m_usesGlobalEventFilter) { qApp->installEventFilter(this); } else { mTarget->installEventFilter(this); } } else { qWarning() << "Target widget is null!"; } } void WidgetResizeHandler::updateCursor(CursorPosition m) { #ifdef KDDOCKWIDGETS_QTWIDGETS // Need for updating cursor when we change child widget const QObjectList children = mTarget->children(); for (int i = 0, total = children.size(); i < total; ++i) { if (auto child = qobject_cast(children.at(i))) { if (!child->testAttribute(Qt::WA_SetCursor)) { child->setCursor(Qt::ArrowCursor); } } } #endif switch (m) { case CursorPosition_TopLeft: case CursorPosition_BottomRight: setMouseCursor(Qt::SizeFDiagCursor); break; case CursorPosition_BottomLeft: case CursorPosition_TopRight: setMouseCursor(Qt::SizeBDiagCursor); break; case CursorPosition_Top: case CursorPosition_Bottom: setMouseCursor(Qt::SizeVerCursor); break; case CursorPosition_Left: case CursorPosition_Right: setMouseCursor(Qt::SizeHorCursor); break; case CursorPosition_Undefined: restoreMouseCursor(); break; case CursorPosition_All: case CursorPosition_Horizontal: case CursorPosition_Vertical: // Doesn't happen break; } } void WidgetResizeHandler::setMouseCursor(Qt::CursorShape cursor) { if (m_usesGlobalEventFilter) qApp->setOverrideCursor(cursor); else mTarget->setCursor(cursor); } void WidgetResizeHandler::restoreMouseCursor() { if (m_usesGlobalEventFilter) qApp->restoreOverrideCursor(); else mTarget->setCursor(Qt::ArrowCursor); } CursorPosition WidgetResizeHandler::cursorPosition(QPoint globalPos) const { if (!mTarget) return CursorPosition_Undefined; #ifdef KDDOCKWIDGETS_QTQUICK if (isMDI()) { // Special case for QtQuick. The MouseAreas are driving it and know better what's the // cursor position return CursorPosition(mTarget->property("cursorPosition").toInt()); } #endif QPoint pos = mTarget->mapFromGlobal(globalPos); const int x = pos.x(); const int y = pos.y(); const int margin = widgetResizeHandlerMargin(); QFlags::Int result = CursorPosition_Undefined; if (y >= -margin && y <= mTarget->height() + margin) { if (qAbs(x) <= margin) result |= CursorPosition_Left; else if (qAbs(x - (mTarget->width() - margin)) <= margin) result |= CursorPosition_Right; } if (x >= -margin && x <= mTarget->width() + margin) { if (qAbs(y) <= margin) result |= CursorPosition_Top; else if (qAbs(y - (mTarget->height() - margin)) <= margin) result |= CursorPosition_Bottom; } // Filter out sides we don't allow result = result & mAllowedResizeSides; return static_cast(result); } /** static */ void WidgetResizeHandler::setupWindow(QWindow *window) { // Does some minor setup on our QWindow. // Like adding the drop shadow on Windows and two other workarounds. #if defined(Q_OS_WIN) if (KDDockWidgets::usesAeroSnapWithCustomDecos()) { const auto wid = HWND(window->winId()); connect(window, &QWindow::screenChanged, window, [wid] { // Qt honors our frame hijacking usually... but when screen changes we must give it a // nudge. Otherwise what Qt thinks is the client area is not what Windows knows it is. // SetWindowPos() will trigger an NCCALCSIZE message, which Qt will intercept and take // note of the margins we're using. SetWindowPos(wid, 0, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); }); const bool usesTransparentFloatingWindow = Config::self().internalFlags() & Config::InternalFlag_UseTransparentFloatingWindow; if (!usesTransparentFloatingWindow) { // This enables the native drop shadow. // Doesn't work well if the floating window has transparent round corners (shows weird white line). MARGINS margins = { 0, 0, 0, 1 }; // arbitrary, just needs to be > 0 it seems DwmExtendFrameIntoClientArea(wid, &margins); } } #else Q_UNUSED(window); #endif // Q_OS_WIN } #ifdef Q_OS_WIN bool WidgetResizeHandler::isInterestingNativeEvent(unsigned int nativeEvent) { switch (nativeEvent) { case WM_NCHITTEST: case WM_NCCALCSIZE: case WM_NCLBUTTONDBLCLK: case WM_GETMINMAXINFO: return true; default: return false; } } #endif #if defined(Q_OS_WIN) && defined(KDDOCKWIDGETS_QTWIDGETS) bool NCHITTESTEventFilter::nativeEventFilter(const QByteArray &eventType, void *message, Qt5Qt6Compat::qintptr *result) { if (eventType != "windows_generic_MSG" || !m_floatingWindow) return false; auto msg = static_cast(message); if (msg->message != WM_NCHITTEST) return false; const WId wid = WId(msg->hwnd); QWidget *child = QWidget::find(wid); if (!child || child->window() != m_floatingWindow) return false; const bool isThisWindow = child == m_floatingWindow; if (!isThisWindow) { *result = HTTRANSPARENT; return true; } return false; } #endif CustomFrameHelper::CustomFrameHelper(ShouldUseCustomFrame func, QObject *parent) : QObject(parent) , QAbstractNativeEventFilter() , m_shouldUseCustomFrameFunc(func) { #ifdef Q_OS_WIN qApp->installNativeEventFilter(this); #endif } CustomFrameHelper::~CustomFrameHelper() { m_inDtor = true; } void CustomFrameHelper::applyCustomFrame(QWindow *window) { #ifdef Q_OS_WIN WidgetResizeHandler::setupWindow(window); #else Q_UNUSED(window); qWarning() << Q_FUNC_INFO << "Not implemented on this platform"; #endif } bool CustomFrameHelper::nativeEventFilter(const QByteArray &eventType, void *message, Qt5Qt6Compat::qintptr *result) { if (m_shouldUseCustomFrameFunc == nullptr || m_recursionGuard) return false; QScopedValueRollback guard(m_recursionGuard, true); #ifdef Q_OS_WIN if (m_inDtor || !KDDockWidgets::usesAeroSnapWithCustomDecos()) return false; if (eventType != "windows_generic_MSG") return false; auto msg = static_cast(message); if (!WidgetResizeHandler::isInterestingNativeEvent(msg->message)) { // Save some CPU cycles return false; } QWindow *window = DockRegistry::self()->windowForHandle(WId(msg->hwnd)); if (!window) return false; const WidgetResizeHandler::NativeFeatures features = m_shouldUseCustomFrameFunc(window); if (!features.hasFeatures()) { // No native support for is desired for this window return false; } const char *propertyName = "kddw_customframe_setup_ran"; const bool setupRan = window->property(propertyName).toBool(); if (!setupRan) { // Add drop shadow WidgetResizeHandler::setupWindow(window); window->setProperty(propertyName, true); } return WidgetResizeHandler::handleWindowsNativeEvent(window, msg, result, features); #else Q_UNUSED(eventType); Q_UNUSED(message); Q_UNUSED(result); return false; #endif }