Rectangle icon indicating copy to clipboard operation
Rectangle copied to clipboard

Multi-Monitor Restore Resizing Bug When Switching Monitors

Open callmejaf opened this issue 10 months ago • 10 comments

Bug Report: Unsnap Restore Resizing Bug Across Monitors

Description

When using unsnap restore in a multi-monitor setup, the window resizes incorrectly based on:

  1. The monitor where unsnap restore is triggered.
  2. The monitor where the window is dropped after unsnap restore.

The bug occurs when unsnap restore is triggered on one monitor, and the window is then dropped either on the same or a different monitor. This behavior causes the window to resize incorrectly when moved back to the other monitor.


System Details

  • macOS Version: Sequoia 15.2
  • Rectangle Version: Latest GitHub pull
  • Settings:
    • Unsnap restore: Enabled
    • Cycle displays: Enabled

Steps to Reproduce

  1. Connect two monitors (e.g., Monitor A and Monitor B).
  2. Open an application (e.g., Terminal) on Monitor A.
  3. Snap the window to any position on Monitor A (e.g., Modifier + Right Arrow for the right side of Monitor A).
  4. Use the shortcut (Modifier + Right Arrow) to move the window to Monitor B.
  5. On Monitor B, trigger unsnap restore by clicking and dragging the window out of its snapped area.
  6. Drop the window:
    • Case 1: Drop it back on Monitor B.
    • Case 2: Drag it to Monitor A and drop it there.
  7. Now drag and drop the window to the other monitor (the one it was not dropped on initially after the unsnap restore).

Expected Behavior

The window should maintain its restored size and position regardless of which monitor it is dropped on.


Actual Behavior

  • Case 1: If the window is unsnapped and dropped on Monitor B, then dragged to Monitor A, it resizes incorrectly when dropped on Monitor A.
  • Case 2: If the window is unsnapped on Monitor B but dropped on Monitor A, it resizes incorrectly when dragged back to Monitor B.
  • The resizing behavior depends on:
    • Where the unsnap restore was triggered.
    • Which monitor the window was last dropped on after unsnap restore.

Edge Cases with GIFs

Case 1

Unsnap Restore on Monitor B, Dropped on Monitor B, Dragged to Monitor A:
Case 1 GIF

Case 2

Unsnap Restore on Monitor B, Dropped on Monitor A, Dragged Back to Monitor B:
Case 2 GIF


Key Observations

  1. Unsnap Restore Dependency:
    • The bug only occurs if unsnap restore is triggered (dragging the window out of its snapped area). Or if I used my modified edge case for restore with keyboard shortcuts which restores correctly on the new monitor when using keyboard shortcuts (we can call this Partial Restore) code below.
    • If the window remains snapped, no resizing issue occurs.
  2. Monitor Drop State Dependency:
    • The monitor where the window is dropped determines how it resizes when dragged back to the other monitor.
    • This behavior is consistent regardless of which monitor is on the left or right.
  3. Lack of Dynamic Restore Rect Handling:
    • The restoreRect does not properly translate when dragging and dropping between monitors after unsnap restore.


Modified Partial Restore Logic

Below is an example of my modified partial restore logic for .restore in WindowManager:

if action == .restore {
    if let restoreRect = AppDelegate.windowHistory.restoreRects[windowId] {
        // Detect the current screen of the window
        let currentScreen = screenDetection.detectScreens(using: frontmostWindowElement)?.currentScreen

        // Find the original screen manually by matching the restoreRect
        let originalScreen = NSScreen.screens.first(where: { $0.frame.contains(restoreRect.origin) })

        var adjustedRestoreRect = restoreRect

        // If the window has moved to a new screen, adjust the restoreRect for the new screen
        if let currentScreen = currentScreen, let originalScreen = originalScreen, currentScreen != originalScreen {
            print("Window is on a different screen. Adjusting restoreRect.")

            let currentVisibleFrame = currentScreen.adjustedVisibleFrame()
            let originalVisibleFrame = originalScreen.adjustedVisibleFrame()

            // Calculate the relative position of the restoreRect on the original screen
            let relativeX = (restoreRect.origin.x - originalVisibleFrame.origin.x) / originalVisibleFrame.width
            let relativeY = (restoreRect.origin.y - originalVisibleFrame.origin.y) / originalVisibleFrame.height

            // Apply the relative position to the current screen
            adjustedRestoreRect.origin.x = currentVisibleFrame.origin.x + relativeX * currentVisibleFrame.width
            adjustedRestoreRect.origin.y = currentVisibleFrame.origin.y + relativeY * currentVisibleFrame.height

            print("Adjusted restoreRect to new screen: \(adjustedRestoreRect)")
        }

        // Apply the adjusted restoreRect
        frontmostWindowElement.setFrame(adjustedRestoreRect)
        print("Restored window: \(windowId) to rect: \(adjustedRestoreRect)")
    }

    // Remove the last action for this window
    AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId)
    print("Restore action completed.")
    return
}

Suggestion: Restore as a Dedicated Action

  • Restore should be treated as a proper action and stored in lastRectangleActions, following the same pipeline as all other actions.
  • Create a dedicated RestoreCalculation to centralize restore logic. This will:
    1. Account for the monitor where the window is currently located.
    2. Translate the restore rect dynamically when crossing monitors.
    3. Handle both unsnap restore and shortcut-based restore consistently.

Restore Modes

Ideally, restore should offer two modes when triggered via keyboard shortcut:

  1. Total Restore:
    • Restores the window to its original monitor, size, and position.
  2. Partial Restore:
    • Restores the window's size and position relative to the new monitor where it is currently snapped.

Unsnap Restore

  • Unsnap Restore should utilize the Partial Restore Calculation, ensuring the window maintains its relative size and position on the monitor where it is dropped.
  • Centralizing unsnap restore logic within the RestoreCalculation will reduce edge cases and make behavior predictable.

Why this fix matters

This bug significantly disrupts multi-monitor workflows by creating unpredictable behavior during restore. Fixing this will make rectangle usable for multi monitor setups, as in this current state, I cannot use this.


Final Note

I’ve already modified the edge case where if action == .restore in WindowManager to restore on the new monitor, but the drag-and-drop unsnap restore bug persists. I’m sorry if I couldn’t contribute much—I’m new to Swift and have only had a Mac for two days! I hope my suggestions are useful.


EDIT

Upon further examination and looking at the logs, no logs are produced when this unwanted resizing occurs. Perhaps MacOS or something else is doing the resizing?

callmejaf avatar Jan 23 '25 15:01 callmejaf

Thanks for the detailed report! I'll see what I can do.

rxhanson avatar Jan 23 '25 16:01 rxhanson

Hi rxhanson! thank you for the help.

I found some more interesting behaviour that might help you locate this issue. I was trying to fix the bug with traversing displays, where it maintains the height of the previous monitor. This is consistent, not matter how tall I make the second monitor. It maintains the height of the shorter one. It seems as though, with the initial bug I mentioned when it does that odd resize after dragging to a new monitor, it also gets set to this same height! I'm not sure where the width is coming from. though.

This is consistent when you restore on the smaller monitor and drag and drop onto the bigger monitor. When you restore on the bigger monitor, and drag and drop on smaller one, I have no idea where it gets that height from.


Cycle display bug repeated action demonstration

Cycle display bug demonstration


Same height upon restore demonstration

Same height upon restore demonstration


At a glimpse it would be easy to make the assumption that it is restoring to the size of the last action before the restore. But we can see, it, still restores to this scale even when the last action was "top half" before the restore>

Top half demonstration


Investigating repeated action traverse displays bug

The logs here for snapping to right display, then snapping back

2025-01-24T02:58:33Z: AX sizing proposed: (750.0, 683.0), result: (753.0, 679.0)
2025-01-24T02:58:33Z: AX position proposed: (2560.0, 25.0), result: (2560.0, 25.0)
2025-01-24T02:58:33Z: AX sizing proposed: (750.0, 683.0), result: (753.0, 679.0)
2025-01-24T02:58:33Z: rightHalf | display: (2560.0, 640.0, 1500.0, 683.0), calculatedRect: (2560.0, 25.0, 750.0, 683.0), resultRect: (2560.0, 25.0, 753.0, 679.0), srcScreen: Jump Desktop Display 2, destScreen: Jump Desktop Display 1, resultScreen: Jump Desktop Display 1
2025-01-24T02:58:36Z: AX sizing proposed: (1280.0, 1238.0), result: (1278.0, 679.0)
2025-01-24T02:58:36Z: AX position proposed: (1280.0, 25.0), result: (1280.0, 25.0)
2025-01-24T02:58:36Z: AX sizing proposed: (1280.0, 1238.0), result: (1278.0, 679.0)
2025-01-24T02:58:36Z: leftHalf | display: (0.0, 85.0, 2560.0, 1238.0), calculatedRect: (1280.0, 25.0, 1280.0, 1238.0), resultRect: (1280.0, 25.0, 1278.0, 679.0), srcScreen: Jump Desktop Display 1, destScreen: Jump Desktop Display 2, resultScreen: Jump Desktop Display 2

It appears as though setFrame is clamping the height for some reason. Not sure if this has anything to do with the initial bug report. But it's something


EDIT: I FIXED THE REPEATED ACTION TRAVERSE DISPLAY BUG (not the bug in the initial bug report)

it seems that after it tried to snap back to bigger display, the original resize would cap it at the height of the smaller monitor. It would then change position. However, I guess the API hadn't yet updated it's information yet and hadn't completed the position change before trying to resize again causing the clamping. This is by far not the best fix at all, but it identifies the core issue.

   func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true) {
        let appElement = applicationElement
        var enhancedUI: Bool? = nil

        if let appElement = appElement {
            enhancedUI = appElement.enhancedUserInterface
            if enhancedUI == true {
                Logger.log("AXEnhancedUserInterface was enabled, will disable before resizing")
                appElement.enhancedUserInterface = false
            }
        }

        if adjustSizeFirst {
            size = frame.size
            Logger.log("Initial size set to: \(frame.size)")
        }

        // Set position first
        position = frame.origin
        Logger.log("Set initial position to: \(frame.origin)")

        let maxRetries = 10 // Maximum number of retries
        let retryInterval: UInt32 = 25_000 // 25ms sleep interval
        var retries = 0

        while retries < maxRetries {
            usleep(retryInterval)
            retries += 1
            
            // Ensure the position has updated before trying to set the new size.
            if let currentPosition = self.position, currentPosition == frame.origin {
                Logger.log("Position confirmed at: \(currentPosition) after \(retries) retries")

                // Set size after position is confirmed
                size = frame.size
                Logger.log("Final size set to: \(frame.size) after confirming position")
                break
            }
        }

        if retries == maxRetries {
            Logger.log("Failed to confirm position after \(maxRetries) retries. Proceeding with resize as fallback.")
            size = frame.size
            Logger.log("Fallback size set to: \(frame.size)")
        }

        if Defaults.enhancedUI.value == .disableEnable, let appElement = appElement, enhancedUI == true {
            appElement.enhancedUserInterface = true
        }
    }

Results

Result

callmejaf avatar Jan 24 '25 02:01 callmejaf

Thanks! That's a great starting point for this one. How many retries does it typically take to get it in place?

rxhanson avatar Jan 24 '25 07:01 rxhanson

It take's 1 attempt for me every time on my M1. 25_000 seems to be the lowest I could get it. Retries may not be necessary but I thought it'd be a good idea to account for slower machines.

as for the original bug, yeah no output logs when it does the resizing which means it's likely something external to rectangle doing the resizing.

callmejaf avatar Jan 24 '25 14:01 callmejaf

It take's 1 attempt for me every time on my M1. 25_000 seems to be the lowest I could get it. Retries may not be necessary but I thought it'd be a good idea to account for slower machines.

Good to know. I'll give this a little more thought before determining the best path forward here.

as for the original bug, yeah no output logs when it does the resizing which means it's likely something external to rectangle doing the resizing.

That's my initial reaction as well. As a sanity check, do you have tiling disabled in macOS?

rxhanson avatar Jan 24 '25 16:01 rxhanson

as for the original bug, yeah no output logs when it does the resizing which means it's likely something external to rectangle doing the resizing.

That's my initial reaction as well. As a sanity check, do you have tiling disabled in macOS?

Yes, tiling is 100% disabled in macOS. I'm thinking if it's something caused outside of rectangle during the drag and drop operation, then the best option might be to manually override macOS's default behaviour by tracking states of the window, even when unsnapped.

The only problem is, we would need to differentiate between an intentional resize vs an unintentional one performed by the OS, to know when to update the lastRestoreRects and when to perform the restore. This, I'm not sure how to do. Other than perhaps checking while dragging if their cursor is near the corners or edges. However, if they intentionally resize using macOS operations in the menu bar, then this would be registered as an "unintentional" resize, and would snap the window back to it's last "mouse" resized rectangle.

I'm not too sure how feasible this is to fix without more potential bugs if the user uses other methods to resize a window other than the mouse. I am going to look at some other tools like magnet and or BTT to see if the same behaviour is present. If so, it's probably just a quirk of MacOS that can't be circumvented. Or at least not without effort.

callmejaf avatar Jan 25 '25 01:01 callmejaf

I'm getting ready to roll in your fix for the fixable issue, and will likely make this an asynchronous retry in the WindowManager since we can narrow this down to a scenario that's only when moving across displays. (Thanks again for that!)

As for the main bug here: out of curiosity were you able to see if other tools exhibited the same behavior? I have the same feeling that fixing this would likely go down an undesirable path, but am always open to trying something out.

rxhanson avatar Jan 30 '25 06:01 rxhanson

I went ahead and rolled in the retry logic, so that will be in the next release. I still don't have any thoughts on a good workaround for your original issue here, but let me know if you find anything else out.

rxhanson avatar Feb 03 '25 01:02 rxhanson

I managed to fix it! Unfortunately, I forgot to commit my changes and accidentally discarded them (oops). Now, with too many university assignments, I don’t have the time to redo everything from scratch.

However, I remember roughly what I did:

  • I modified restoreRects to store both the rect and the screen it was last on.
  • I restructured the logic so that restoreCalculation goes through the same pipeline as all other actions.
  • Whenever a window was dropped, I checked if the target screen was different from the one stored in restoreRects. If it was, I manually triggered restoreCalculation instead of using unsnapRestore.

This approach was a bit janky, as macOS would briefly try to resize the window before my restoreCalculation took effect. However, this short graphical glitch was a small price to pay.

So yes, it is possible—but it requires manually overriding macOS’s drag-and-drop behavior and keeping track of screen changes to ensure the correct restoration behavior.

callmejaf avatar Feb 15 '25 14:02 callmejaf

Interesting, thanks for following up!

I'll dig into this for the next release.

rxhanson avatar Feb 15 '25 16:02 rxhanson

@rxhanson I have the same issue and it is driving me nuts :D

I tested with the tool "Magnet" as well and it has the same issue. This looks like MacOS is doing something weird. When using Magnet the behavior changes slightly when (un)checking the "Restore windows to original size" option but the outcome is always the same: madness. When it is checked the windows gets the wrong size, briefly switches back to the right size and ultimately ends up in the wrong size again.

I would be okay with a minor graphical glitch if that means that the window at least stays the right size. I really hope you can find a fix for this.

Please let me know if you need me to do further testing!

@callmejaf I'm almost angry at you for forgetting the commit (been there, done that) but at least that gives me hope that there could be a solution for this issue ;)

vniehues avatar May 24 '25 22:05 vniehues

@vniehues I am finished with my semester in 2 weeks. Once my exams are finished I will get right back on this. I already found a solution - I can find it again.

callmejaf avatar May 24 '25 23:05 callmejaf

@vniehues thanks for bringing this back into my view. @callmejaf I'll hold off on taking another look at this; looking forward to seeing what you come up with.

rxhanson avatar May 25 '25 05:05 rxhanson

Another data point I found in my testing: After snapping the window to another monitor, dragging it back to the main monitor usually makes it "twitch back into a wrong size" like we discussed here. However, if I first manually resize it with my mouse (even by 1 pixel) it will not exhibit this unwanted behavior at all.

vniehues avatar May 25 '25 21:05 vniehues