Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Implementing Magnetic Window Snapping in Qt Applications

Tech May 16 1

Table of Contents- Overview

  • Effect Demonstration
  • Magnetic Snapping
      1. Limiting Mouse Movement Area
      1. Correcting Window Movement Boundaries
      1. Finding Nearest Snappable Window
      • a. Snapping between window and subPanel
      • b. Snapping between window edges
  • Additional Features
  • Related Articles

一、Overview

In our previous article on componentization with tab dragging, adding/removing tabs, and widgets, we covered the basic foundation of a multi-window interface similar to Futu Niuniu. While the implementation might be rough around the edges, the core functionality was established:

  • Toolbar tab dragging
  • Tab dragging between toolbars
  • Widgets
  • Multi-tab architecture
  • Small windows

These features were covered in the previous article. Today, we'll focus on the second key functionality: magnetic snapping and some additional minor features.

二、Effect Demonstration

Magnetic snapping, as the name implies, means that when moving a window, it will be attracted to the edges of nearby windows when approaching them. The effect is demonstrated in the following image.

三、Magnetic Snapping

The project code in this article has been optimized from the previous version with clearer structure and improved readability, primarily adding magnetic snapping functionality and synchronization features.

Let's consider what magnetic snapping entails and understand our requirements:

Magnetic behavior might manifest as:

  1. Different sub-windows should snap to each other's edges when moved
  2. Tabs should remain independent
  3. Mouse movement should be restricted to the subPanel area

Aliases: Dragged window (A), Target window (B), Event handler (C)

With clear requirements, let's consider implementation. Since we need windows to snap to each other, handling events in either window A or B isn't ideal. This suggests introducing a third component C to manage events between A and B. This doesn't necessarily need to be a window, but rather something that can proxy events and perform necessary processing.

With component C in place, we'll handle window A's movement events, checking if it satisfies snapping conditions with any other window. When conditions are met, we trigger the snapping action.

Handling snapping might look like this:

Assuming we have 10 windows: A1, A2, A3, A4...A9, A10

  1. When dragging A1, all other windows are potential targets (B)
  2. When dragging A2, A1 and other windows are potential targets (B)
  3. Similarly, when dragging any A, all windows except An are potential targets (B)

When introducing the third component, we need to consider:

  • How to introduce the event handler?
  • How is it initialized?
  • What is its scope?

Considering these three questions, I immediately thought of Qt's QButtonGroup class, which manages buttons and ensures only one can be selected at a time. This is similar to our needs - managing a collection of similar controls where one control's operation affects all others.

This means: We can create a WindowManager class specifically to handle events between moving windows and other windows

This class might look like this. It provides interfaces to add and remove small windows, managing magnetic snapping for all added windows.

class WindowManager : public QObject
{
public:
    WindowManager(QObject *parent = nullptr);
    ~WindowManager();

public:
    void addWindow(SmallWindow *window);
    void removeWindow(SmallWindow *window);

    void enableMagnetic(bool enable);

    void limitCursor(bool restrict);
    void startMovement(SmallWindow *window, const QPoint &pos);
    void updatePosition(SmallWindow *window, const QPoint &offset);

protected:
    bool eventFilter(QObject *obj, QEvent *event) override;

private:
    QPoint calculateMagneticPosition(SmallWindow *window, const QRect &currentRect);

private:
    bool m_magneticEnabled;
    QPoint m_startPosition;
    QVector<SmallWindow *> m_windows;
    SmallWindow *m_activeWindow;
};

The class concept is straightforward, though implementation can be complex. I'll focus on three key aspects:

  1. Limiting mouse movement area
  2. Correcting window movement boundaries
  3. Finding the nearest snappable window

1. Limiting Mouse Movement Area

The interface for limiting mouse movement has been listed above. Based on parameters, we dynamically restrict or allow mouse movement.

void WindowManager::limitCursor(bool restrict)
{
#ifdef Q_OS_WIN
    if (restrict)
    {
        if (QWidget *subPanel = dynamic_cast<QWidget *>(parent()))
        {
            QRect panelRect = subPanel->geometry();
            QPoint globalPos = subPanel->mapToGlobal(QPoint(0, 0));

            CRect cursorRect;
            cursorRect.left = globalPos.x();
            cursorRect.top = globalPos.y();
            cursorRect.right = globalPos.x() + panelRect.width();
            cursorRect.bottom = globalPos.y() + panelRect.height();

            ClipCursor(&cursorRect);
        }
    }
    else
    {
        ClipCursor(nullptr);
    }
#endif
}

2. Correcting Window Movement Boundaries

This might seem confusing at first, but it's quite simple. The key point is that when moving small windows, they shouldn't leave the subPanel. In other words, when the subPanel is visible, all small windows should remain within it or be overlapped by others.

Of course, this depends on requirements. Initially, I implemented restrictions on all four sides, but later discovered that Futu Niuniu only restricts the top edge. I've commented out three if statements in the code, allowing you to modify accordding to your needs.

QRect adjustWindowPosition(const QRect &currentRect, const QRect &subPanel)
{
    QRect adjustedRect = currentRect;
    //if (adjustedRect.left() < subPanel.left())
    //{
    //    adjustedRect.moveLeft(subPanel.left());
    //}
    if (adjustedRect.top() < subPanel.top())
    {
        adjustedRect.moveTop(subPanel.top());
    }
    //if (adjustedRect.right() > subPanel.right())
    //{
    //    adjustedRect.moveRight(subPanel.right());
    //}
    //if (adjustedRect.bottom() > subPanel.bottom())
    //{
    //    adjustedRect.moveBottom(subPanel.bottom());
    //}

    return adjustedRect;
}

3. Finding the Nearest Snappable Window

The most complex part of magnetic snapping is likely this functionality. When moving a window, we need to check various conditions and adjust position accordingly.

Key Point 1: Magnetic snapping means when approaching another window's edge, the dragged window snaps to it. However, the actual movement distance doesn't match the mouse movement. When the mouse moves slightly away, the window should appear to "bounce" away.

Key Point 2: To achieve Key Point 1, we need to track the window's starting position and current movement offset. Based on the adjusted position, we determine if snapping is possible. If snapped, we move the window slightly more (or less) to the snapped position. However, the actual movement distance doesn't match the mouse movement. This allows us to continue checking snapping conditions on subsequent mouse movements, creating the "bouncing" effect.

The above description might be difficult to understand. Let me explain with formulas:

startMovePos: Position where mouse was pressed to start movement
offsetPos: Distance between current mouse position and startMovePos
truthPos: Position the window would move to based on mouse displacement
movePos: Actual position the window will move to after magnetic snapping (may deviate from truthPos)

These four variables show several scenarios when moving a window:

  1. No magnetic snapping - move directly to truthPos
  2. Magnetic snapping - move to the edge of the target window (with an offset value)
  3. Previously snapped, now not satisfying conditions - move to truthPos, creating a "bouncing" effect due to the previous offset

Magnetic snapping needs to handle four directional events. I'll focus on left-edge snapping; other edges follow similar logic.

The main process for handling snapping position is shown below. I've kept only left-edge handling code; other edge code has been removed but follows similar logic.

QPoint WindowManager::calculateMagneticPosition(SmallWindow *window, const QRect &currentRect)
{
    QPoint position(currentRect.topLeft());

    if (QWidget *subPanel = dynamic_cast<QWidget *>(parent()))
    {
        QRect panelRect = subPanel->rect();

        QRect adjustedRect = adjustWindowPosition(currentRect, panelRect);
        if (!m_magneticEnabled)
        {
            return adjustedRect.topLeft();
        }

        // More accurate position after adjustment
        position = adjustedRect.topLeft();

        QVector<SmallWindow *> otherWindows = m_windows;
        otherWindows.removeOne(window);

        int distance = 0;
        // Compare left edge with subPanel's left edge
        if (canSnapToPanel(Edge::Left, currentRect.left(), panelRect, distance))
        {
            position.setX(panelRect.left());
        }
        else 
        {
            // Compare left edge with other windows' right edges
            if (canSnapToWindow(Edge::Left, currentRect.left(), otherWindows, distance))
            {
                position.setX(distance);
            }
        }
        // ... (other edge handling)
    }
    return position;
}

Left-edge snapping has two scenarios:

a. Snapping between window and subPanel

Snapping rule: Window A's left edge snaps to subPanel's left edge. Similar rules apply to other edges.

bool canSnapToPanel(Edge edge, int position, const QRect &subPanel, int &distance)
{
    int targetValue;
    switch (edge)
    {
    case Edge::Left:
        targetValue = subPanel.left();
        break;
    case Edge::Top:
        targetValue = subPanel.top();
        break;
    case Edge::Right:
        targetValue = subPanel.right();
        break;
    case Edge::Bottom:
        targetValue = subPanel.bottom();
        break;
    default:
        break;
    }
    distance = qFabs(position - targetValue);
    if (distance <= MagneticThreshold)
    {
        return true;
    }
    return false;
}

b. Snapping between window A's left edge and window B's right edge

We loop through all potential target windows, finding nearest one that satisfies snapping conditions. When the function returns true, distance contains the corrected position.

Note: If multiple windows satisfy snapping conditions, we select the nearest one - the window edge closest to our dragged window's edge.

Unlike snapping to subPanel, window-to-window snapping follows different rules: Window A's left edge snaps to Window B's right edge; Window A's top edge snaps to Window B's bottom edge. The pattern is clear: left-to-right, top-to-bottom, right-to-left, and bottom-to-top.

bool canSnapToWindow(Edge edge, int movingPosition, const QVector<SmallWindow *> &allWindows, int &distance)
{
    distance = 10000;
    bool result = false;
    int minDistance = 10000;
    
    // Dynamically get window edges based on edge value
    // For example: when edge is Edge::Left, we need to compare with other windows' Edge::Right
    for (SmallWindow *window : allWindows)
    {
        int targetValue = -1; 
        switch (edge)
        {
        case Edge::Left:
            targetValue = window->geometry().right() + 2;
            break;
        case Edge::Top:
            targetValue = window->geometry().bottom() + 2;
            break;
        case Edge::Right:
            targetValue = window->geometry().left() - 1;
            break;
        case Edge::Bottom:
            targetValue = window->geometry().top() - 1;
            break;
        default:
            break;
        }
        
        if (targetValue != -1)
        {
            int tempDistance = qFabs(movingPosition - targetValue);
            if (minDistance > tempDistance)
            {
                minDistance = tempDistance;

                if (minDistance <= MagneticThreshold)
                {
                    result = true;
                    distance = targetValue;
                }
            }
        }
    }

    return result;
}

四、Additional Features

Toolbox window and toolbar button synchronization is a common feature, but worth sharing. Here, I use QAction to bind QToolButton in the toolbar with the toolbox window, enabling simple functionality synchronization without excessive signal-slot connections.

Previously, I used signal-slot connections, but later discovered this more elegant approach. It's not particularly advanced, but it makes code clearer. As business logic becomes more complex, QAction's synchronization advantages become more apparent.

Here's the code for synchronizing QToolButton and toolbox state:

// Toolbox: when closed, synchronize toolbar button state
void ToolboxDialog::bindAction(QAction *action)
{
    connect(m_toolboxAction, &QAction::triggered, action, &QAction::setChecked, Qt::UniqueConnection);
}

connect(m_title, &ToolboxTitle::closeWindow, this, [this](){
        m_toolboxAction->triggered(false);
        setVisible(false);
    });
    
// Show toolbox when toolbar button is clicked
void TemplateLayout::showToolbox(bool visible)
{
    if (m_toolbox == nullptr)
    {
        m_toolbox = new ToolboxDialog(this);
        m_toolbox->bindAction(m_toolbar->getToolboxButton());
        connect(m_toolbox, &ToolboxDialog::subWindowClicked, m_panel, &ContentPanel::createSubWindow);
    }

    if (visible)
    {
        m_toolbox->show();
    }
    else
    {
        m_toolbox->hide();
    }
}

五、Related Articles

Implementing Componentization with Tab Dragging, Adding/Removing Tabs, and Widgets

That covers the main content of this article. The magnetic snapping functionality is essentially complete and I hope it helps you.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.