diff --git a/README.md b/README.md index df79208fc..3d22a9ece 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Drag a window to the edge of the screen. When the mouse cursor reaches the edge | Bottom left, center, or right third | Respective third | | Bottom left or right third, then drag to bottom center | First or last two thirds, respectively | +In **Snap Areas**, enable **Animate windows (experimental)** for smooth window resizing or **Blur footprint** for a blurred snap preview. Both are off by default. Animated snapping keeps the selected edge or center aligned when an app limits its window size or aspect ratio. Dragging a snapped window starts restoring its previous size as the window begins moving, and a quick release lets the remaining restore animation continue. Far-right title-bar grabs can still show visible jumps. Title-bar double-click maximize/restore follows the same animation setting and timing as snapping. Window animation has been tested in known scenarios, but remains experimental because repeatedly updating the window's size and position during the transition may cause issues in some edge cases. + ### Tile windows in rows or columns Enable **Show additional sizes in menu** to include Rows and Columns in the **Tiling** submenu. Their keyboard shortcuts remain available when the submenu is hidden. @@ -94,7 +96,7 @@ Apple never released a public API for doing this. Rectangle Pro has next/prev Sp ### Windows overlap when using thirds or other small layouts -Some apps enforce a minimum window size that is larger than the requested layout. For example, a window with a minimum width of 600 points cannot fit a 504-point third of a display. Rectangle keeps the window on screen and briefly shows a “Window size limited” message when the app leaves it larger than the requested size. Use a larger layout, such as halves, or reduce the adjacent window manually. Rectangle cannot override an app's minimum window size. +Some apps enforce a minimum window size that is larger than the requested layout. For example, a window with a minimum width of 600 points cannot fit a 504-point third of a display. Rectangle keeps the window on screen and briefly shows a “Minimum window size reached” HUD with a window icon when the app leaves it larger than the requested size. Use a larger layout, such as halves, or reduce the adjacent window manually. Rectangle cannot override an app's minimum window size. ### Window resizing is off slightly for iTerm2 diff --git a/Rectangle.xcodeproj/project.pbxproj b/Rectangle.xcodeproj/project.pbxproj index a27f0d646..7060860e3 100644 --- a/Rectangle.xcodeproj/project.pbxproj +++ b/Rectangle.xcodeproj/project.pbxproj @@ -33,12 +33,13 @@ 666598377754D51045353CD0 /* BottomCenterRightTwelfthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A362206B9F76C269D8F469 /* BottomCenterRightTwelfthCalculation.swift */; }; 729E0A982AFF76B1006E2F48 /* CenterProminentlyCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 729E0A972AFF76B1006E2F48 /* CenterProminentlyCalculation.swift */; }; 74804F0B2E25521C009F1F7D /* CenterTwoThirdsCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74804F0A2E25521C009F1F7D /* CenterTwoThirdsCalculation.swift */; }; + 7B4C0A1F2F1A000100C0FFEE /* HalvesPreserveOtherAxisSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C0A1E2F1A000100C0FFEE /* HalvesPreserveOtherAxisSize.swift */; }; + 7B4C0A212F1B000100C0FFEE /* RepeatedMaximizeRestore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C0A202F1B000100C0FFEE /* RepeatedMaximizeRestore.swift */; }; 7BE578EF2C5BF4EE0083DAE3 /* CycleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE578EE2C5BF4ED0083DAE3 /* CycleSize.swift */; }; 7BE578F12EFA00010083DAE3 /* CooperativeCornerResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE578F02EFA00010083DAE3 /* CooperativeCornerResize.swift */; }; 7BE578F32EFE11010083DAE3 /* ActiveSideSplitRatios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE578F22EFE11010083DAE3 /* ActiveSideSplitRatios.swift */; }; 7EC9E3BAD5BC57E49A7BCEE5 /* TwelfthsRepeated.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3592F8E85CE82B8FA662A31 /* TwelfthsRepeated.swift */; }; 866661F2257D248A00A9CD2D /* RepeatedExecutionsInThirdsCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866661F1257D248A00A9CD2D /* RepeatedExecutionsInThirdsCalculation.swift */; }; - 7B4C0A1F2F1A000100C0FFEE /* HalvesPreserveOtherAxisSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C0A1E2F1A000100C0FFEE /* HalvesPreserveOtherAxisSize.swift */; }; 88763FEFFA3AECF8502604D6 /* LowerMiddleCenterLeftSixteenthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378C96D832D4CC6BCCF9D604 /* LowerMiddleCenterLeftSixteenthCalculation.swift */; }; 895C73559E1ACEFBBE8DC32D /* UpperMiddleCenterRightSixteenthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEBF3EA35F0C110A170EAD33 /* UpperMiddleCenterRightSixteenthCalculation.swift */; }; 8B1D14E3A55936EBCCC1E807 /* MiddleRightTwelfthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA84AB4DB9E7EEFCE50AA6D8 /* MiddleRightTwelfthCalculation.swift */; }; @@ -87,7 +88,6 @@ 9824703D22B13C7E0037B409 /* AccessibilityElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824703C22B13C7E0037B409 /* AccessibilityElement.swift */; }; 9824703F22B13FBC0037B409 /* ScreenDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824703E22B13FBC0037B409 /* ScreenDetection.swift */; }; 9824704122B186D00037B409 /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824704022B186D00037B409 /* WindowManager.swift */; }; - F05220000000000000000002 /* WindowSizeWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05220000000000000000001 /* WindowSizeWarning.swift */; }; 982470432EFA00010037B409 /* WindowManager+CooperativeCornerResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982470422EFA00010037B409 /* WindowManager+CooperativeCornerResize.swift */; }; 9824704B22B189250037B409 /* StandardWindowMover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824704622B189240037B409 /* StandardWindowMover.swift */; }; 9824704C22B189250037B409 /* WindowMover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824704722B189240037B409 /* WindowMover.swift */; }; @@ -95,10 +95,6 @@ 9824704F22B189250037B409 /* BestEffortWindowMover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824704A22B189250037B409 /* BestEffortWindowMover.swift */; }; 9824705122B28D7A0037B409 /* LeftRightHalfCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824705022B28D7A0037B409 /* LeftRightHalfCalculation.swift */; }; 983BBD6F253B609D000D223E /* FootprintWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983BBD6E253B609D000D223E /* FootprintWindow.swift */; }; - F1AC0DE000000000000000A2 /* StackBadgeGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1AC0DE000000000000000A1 /* StackBadgeGeometry.swift */; }; - F1AC0DE000000000000000A4 /* StackBadgeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1AC0DE000000000000000A3 /* StackBadgeManager.swift */; }; - F1AC0DE000000000000000A8 /* StackBadgeListPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1AC0DE000000000000000A9 /* StackBadgeListPanel.swift */; }; - F1AC0DE000000000000000AA /* StackBadgeRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1AC0DE000000000000000AB /* StackBadgeRowView.swift */; }; 983DD04028A844BE00BF1EEE /* SnapAreaViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983DD03F28A844BE00BF1EEE /* SnapAreaViewController.swift */; }; 983DD04428B0639E00BF1EEE /* SnapAreaModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983DD04328B0639E00BF1EEE /* SnapAreaModel.swift */; }; 984EDB0F29A42ED200D119D2 /* LaunchOnLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984EDB0E29A42ED200D119D2 /* LaunchOnLogin.swift */; }; @@ -140,7 +136,6 @@ 98C2755E231FF6A9009B9292 /* EventMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2755D231FF6A9009B9292 /* EventMonitor.swift */; }; 98C27561231FFA5F009B9292 /* SnappingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C27560231FFA5F009B9292 /* SnappingManager.swift */; }; 98C275672322E2DA009B9292 /* WindowHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C275662322E2DA009B9292 /* WindowHistory.swift */; }; - 7B4C0A212F1B000100C0FFEE /* RepeatedMaximizeRestore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C0A202F1B000100C0FFEE /* RepeatedMaximizeRestore.swift */; }; 98C6DEF023CE191700CC0C1E /* GapCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C6DEEF23CE191700CC0C1E /* GapCalculation.swift */; }; 98C97FFD25893B040061F01F /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C97FFC25893B040061F01F /* Config.swift */; }; 98D1441324560B1E0090C603 /* AlertUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D1441224560B1E0090C603 /* AlertUtil.swift */; }; @@ -169,6 +164,9 @@ AA69F83C29909A95001A81AF /* RightTodoCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA69F83B29909A95001A81AF /* RightTodoCalculation.swift */; }; AA69F8402992DCB1001A81AF /* LeftTodoCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA69F83F2992DCB1001A81AF /* LeftTodoCalculation.swift */; }; AAADE1AF28CBAB0000036331 /* WindowUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAADE1AE28CBAB0000036331 /* WindowUtil.swift */; }; + AC0100010000000000000001 /* WindowAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0100010000000000000002 /* WindowAnimator.swift */; }; + AC0200010000000000000001 /* DirectWindowAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0200010000000000000002 /* DirectWindowAnimator.swift */; }; + AC0200030000000000000001 /* WindowAnimationCurves.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0200030000000000000002 /* WindowAnimationCurves.swift */; }; B4521F932BD7CEFB00FD43CC /* ChangeWindowDimensionCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4521F922BD7CEFB00FD43CC /* ChangeWindowDimensionCalculation.swift */; }; B4780A322BD4C75900732B9E /* HalfOrDoubleDimensionCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4780A312BD4C75900732B9E /* HalfOrDoubleDimensionCalculation.swift */; }; B6366EE24FA928086846969B /* SixteenthsRepeated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16B4044DB4809116039F96EB /* SixteenthsRepeated.swift */; }; @@ -198,6 +196,11 @@ D8644560FF112B0748CE1975 /* UpperMiddleLeftSixteenthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2DBE0733F31BC159BC99C /* UpperMiddleLeftSixteenthCalculation.swift */; }; DCD3CF2993E9AFAF30820117 /* MiddleCenterRightTwelfthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCF2AC59501448BD3BAA16EC /* MiddleCenterRightTwelfthCalculation.swift */; }; E791B00A88D90C24A4C3D2D1 /* UpperMiddleCenterLeftSixteenthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8CA6901E6FE134E930135B /* UpperMiddleCenterLeftSixteenthCalculation.swift */; }; + F05220000000000000000002 /* WindowSizeWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05220000000000000000001 /* WindowSizeWarning.swift */; }; + F1AC0DE000000000000000A2 /* StackBadgeGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1AC0DE000000000000000A1 /* StackBadgeGeometry.swift */; }; + F1AC0DE000000000000000A4 /* StackBadgeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1AC0DE000000000000000A3 /* StackBadgeManager.swift */; }; + F1AC0DE000000000000000A8 /* StackBadgeListPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1AC0DE000000000000000A9 /* StackBadgeListPanel.swift */; }; + F1AC0DE000000000000000AA /* StackBadgeRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1AC0DE000000000000000AB /* StackBadgeRowView.swift */; }; F1AC0DE000000000000000C2 /* OverlapOffsetGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1AC0DE000000000000000C1 /* OverlapOffsetGeometry.swift */; }; F4EED30B47FB6C55EA937254 /* TopRightTwelfthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E8357E928530D10A806F3CD /* TopRightTwelfthCalculation.swift */; }; F51A7467767F8EAF97321A00 /* TopRightSixteenthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD222258713B13274FB651B0 /* TopRightSixteenthCalculation.swift */; }; @@ -253,13 +256,14 @@ 71FE3EBE50F91F9EE2989279 /* BottomCenterLeftTwelfthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomCenterLeftTwelfthCalculation.swift; sourceTree = ""; }; 729E0A972AFF76B1006E2F48 /* CenterProminentlyCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CenterProminentlyCalculation.swift; sourceTree = ""; }; 74804F0A2E25521C009F1F7D /* CenterTwoThirdsCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CenterTwoThirdsCalculation.swift; sourceTree = ""; }; + 7B4C0A1E2F1A000100C0FFEE /* HalvesPreserveOtherAxisSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HalvesPreserveOtherAxisSize.swift; sourceTree = ""; }; + 7B4C0A202F1B000100C0FFEE /* RepeatedMaximizeRestore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatedMaximizeRestore.swift; sourceTree = ""; }; 7BE578EE2C5BF4ED0083DAE3 /* CycleSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleSize.swift; sourceTree = ""; }; 7BE578F02EFA00010083DAE3 /* CooperativeCornerResize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CooperativeCornerResize.swift; sourceTree = ""; }; 7BE578F22EFE11010083DAE3 /* ActiveSideSplitRatios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSideSplitRatios.swift; sourceTree = ""; }; 7E8357E928530D10A806F3CD /* TopRightTwelfthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopRightTwelfthCalculation.swift; sourceTree = ""; }; 84E2DBE0733F31BC159BC99C /* UpperMiddleLeftSixteenthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpperMiddleLeftSixteenthCalculation.swift; sourceTree = ""; }; 866661F1257D248A00A9CD2D /* RepeatedExecutionsInThirdsCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatedExecutionsInThirdsCalculation.swift; sourceTree = ""; }; - 7B4C0A1E2F1A000100C0FFEE /* HalvesPreserveOtherAxisSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HalvesPreserveOtherAxisSize.swift; sourceTree = ""; }; 87EC57F37CFD790695177AC8 /* LowerMiddleRightSixteenthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LowerMiddleRightSixteenthCalculation.swift; sourceTree = ""; }; 88454ABE5C62BD610AEA3EB5 /* BottomCenterLeftSixteenthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomCenterLeftSixteenthCalculation.swift; sourceTree = ""; }; 8AE08B666CCCD1805CAB08BD /* LowerMiddleCenterRightSixteenthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LowerMiddleCenterRightSixteenthCalculation.swift; sourceTree = ""; }; @@ -320,7 +324,6 @@ 9824703C22B13C7E0037B409 /* AccessibilityElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityElement.swift; sourceTree = ""; }; 9824703E22B13FBC0037B409 /* ScreenDetection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScreenDetection.swift; sourceTree = ""; }; 9824704022B186D00037B409 /* WindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = ""; }; - F05220000000000000000001 /* WindowSizeWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowSizeWarning.swift; sourceTree = ""; }; 982470422EFA00010037B409 /* WindowManager+CooperativeCornerResize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WindowManager+CooperativeCornerResize.swift"; sourceTree = ""; }; 9824704622B189240037B409 /* StandardWindowMover.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StandardWindowMover.swift; sourceTree = ""; }; 9824704722B189240037B409 /* WindowMover.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WindowMover.swift; sourceTree = ""; }; @@ -328,10 +331,6 @@ 9824704A22B189250037B409 /* BestEffortWindowMover.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BestEffortWindowMover.swift; sourceTree = ""; }; 9824705022B28D7A0037B409 /* LeftRightHalfCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftRightHalfCalculation.swift; sourceTree = ""; }; 983BBD6E253B609D000D223E /* FootprintWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FootprintWindow.swift; sourceTree = ""; }; - F1AC0DE000000000000000A1 /* StackBadgeGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackBadgeGeometry.swift; sourceTree = ""; }; - F1AC0DE000000000000000A3 /* StackBadgeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackBadgeManager.swift; sourceTree = ""; }; - F1AC0DE000000000000000A9 /* StackBadgeListPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackBadgeListPanel.swift; sourceTree = ""; }; - F1AC0DE000000000000000AB /* StackBadgeRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackBadgeRowView.swift; sourceTree = ""; }; 983DD03F28A844BE00BF1EEE /* SnapAreaViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapAreaViewController.swift; sourceTree = ""; }; 983DD04328B0639E00BF1EEE /* SnapAreaModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapAreaModel.swift; sourceTree = ""; }; 984EDB0E29A42ED200D119D2 /* LaunchOnLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchOnLogin.swift; sourceTree = ""; }; @@ -371,7 +370,6 @@ 98C2755D231FF6A9009B9292 /* EventMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventMonitor.swift; sourceTree = ""; }; 98C27560231FFA5F009B9292 /* SnappingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnappingManager.swift; sourceTree = ""; }; 98C275662322E2DA009B9292 /* WindowHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowHistory.swift; sourceTree = ""; }; - 7B4C0A202F1B000100C0FFEE /* RepeatedMaximizeRestore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatedMaximizeRestore.swift; sourceTree = ""; }; 98C6DEEF23CE191700CC0C1E /* GapCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GapCalculation.swift; sourceTree = ""; }; 98C97FFC25893B040061F01F /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; 98D1441224560B1E0090C603 /* AlertUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertUtil.swift; sourceTree = ""; }; @@ -398,6 +396,9 @@ AA69F83F2992DCB1001A81AF /* LeftTodoCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftTodoCalculation.swift; sourceTree = ""; }; AA84AB4DB9E7EEFCE50AA6D8 /* MiddleRightTwelfthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiddleRightTwelfthCalculation.swift; sourceTree = ""; }; AAADE1AE28CBAB0000036331 /* WindowUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtil.swift; sourceTree = ""; }; + AC0100010000000000000002 /* WindowAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAnimator.swift; sourceTree = ""; }; + AC0200010000000000000002 /* DirectWindowAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectWindowAnimator.swift; sourceTree = ""; }; + AC0200030000000000000002 /* WindowAnimationCurves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAnimationCurves.swift; sourceTree = ""; }; B4521F922BD7CEFB00FD43CC /* ChangeWindowDimensionCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeWindowDimensionCalculation.swift; sourceTree = ""; }; B4780A312BD4C75900732B9E /* HalfOrDoubleDimensionCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HalfOrDoubleDimensionCalculation.swift; sourceTree = ""; }; BB0000000000000000000002 /* QuartersRepeated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuartersRepeated.swift; sourceTree = ""; }; @@ -429,6 +430,11 @@ D7D038C68560DC11F14496F6 /* BottomRightTwelfthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomRightTwelfthCalculation.swift; sourceTree = ""; }; D9FDCA1333C1BF6930A8D7F3 /* TopLeftTwelfthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopLeftTwelfthCalculation.swift; sourceTree = ""; }; EEABF10E66D648DDB37DABBA /* MiddleLeftTwelfthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiddleLeftTwelfthCalculation.swift; sourceTree = ""; }; + F05220000000000000000001 /* WindowSizeWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowSizeWarning.swift; sourceTree = ""; }; + F1AC0DE000000000000000A1 /* StackBadgeGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackBadgeGeometry.swift; sourceTree = ""; }; + F1AC0DE000000000000000A3 /* StackBadgeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackBadgeManager.swift; sourceTree = ""; }; + F1AC0DE000000000000000A9 /* StackBadgeListPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackBadgeListPanel.swift; sourceTree = ""; }; + F1AC0DE000000000000000AB /* StackBadgeRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackBadgeRowView.swift; sourceTree = ""; }; F1AC0DE000000000000000C1 /* OverlapOffsetGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlapOffsetGeometry.swift; sourceTree = ""; }; F3592F8E85CE82B8FA662A31 /* TwelfthsRepeated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwelfthsRepeated.swift; sourceTree = ""; }; FA34D0FE2F7E896E00EEBEF1 /* SpecificDisplayCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecificDisplayCalculation.swift; sourceTree = ""; }; @@ -631,6 +637,9 @@ children = ( 9824704A22B189250037B409 /* BestEffortWindowMover.swift */, 9824704622B189240037B409 /* StandardWindowMover.swift */, + AC0100010000000000000002 /* WindowAnimator.swift */, + AC0200010000000000000002 /* DirectWindowAnimator.swift */, + AC0200030000000000000002 /* WindowAnimationCurves.swift */, 9824704722B189240037B409 /* WindowMover.swift */, 98B3559723CE025700E410E0 /* FixedSizeWindowMover.swift */, ); @@ -1062,6 +1071,9 @@ 9821403122B38A0500ABFB3F /* TopHalfCalculation.swift in Sources */, 9818E01428B5A4FD004AA524 /* SixthsCompoundCalculation.swift in Sources */, 9824704B22B189250037B409 /* StandardWindowMover.swift in Sources */, + AC0100010000000000000001 /* WindowAnimator.swift in Sources */, + AC0200010000000000000001 /* DirectWindowAnimator.swift in Sources */, + AC0200030000000000000001 /* WindowAnimationCurves.swift in Sources */, 9821402722B3888100ABFB3F /* ChangeSizeCalculation.swift in Sources */, 6490B39927BF97BB0056C220 /* TopCenterRightEighthCalculation.swift in Sources */, 98B3559823CE025700E410E0 /* FixedSizeWindowMover.swift in Sources */, diff --git a/Rectangle/AccessibilityAuthorization/AccessibilityWindowController.swift b/Rectangle/AccessibilityAuthorization/AccessibilityWindowController.swift index 56fa1a25d..d90291509 100644 --- a/Rectangle/AccessibilityAuthorization/AccessibilityWindowController.swift +++ b/Rectangle/AccessibilityAuthorization/AccessibilityWindowController.swift @@ -24,13 +24,21 @@ class AccessibilityViewController: NSViewController { @IBOutlet weak var padlockField: NSTextField! override func viewDidLoad() { - if #available(OSX 13, *) { - sysPrefsPathField.stringValue = NSLocalizedString( + super.viewDidLoad() + if #available(macOS 27, *) { + sysPrefsPathField.stringValue = NSLocalizedString( + "Go to System Settings → Privacy & Security → Device Control and Data Access", tableName: "Main", value: "", comment: "") + } else if #available(macOS 13, *) { + sysPrefsPathField.stringValue = NSLocalizedString( "Go to System Settings → Privacy & Security → Accessibility", tableName: "Main", value: "", comment: "") + } + if #available(macOS 13, *) { openSysPrefsButton.title = NSLocalizedString( "Open System Settings", tableName: "Main", value: "", comment: "") padlockField.isHidden = true } + sysPrefsPathField.preferredMaxLayoutWidth = 250 + sysPrefsPathField.maximumNumberOfLines = 0 } @IBAction func openSystemPrefs(_ sender: Any) { diff --git a/Rectangle/AccessibilityElement.swift b/Rectangle/AccessibilityElement.swift index 9a1087d30..aa53debc9 100644 --- a/Rectangle/AccessibilityElement.swift +++ b/Rectangle/AccessibilityElement.swift @@ -72,6 +72,28 @@ class AccessibilityElement { guard let role = role else { return nil } return role == .staticText } + + func isTitleBarSpacer(in titleBar: CGRect) -> Bool { + guard role == .splitter else { return false } + var settable: DarwinBoolean = false + let status = AXUIElementIsAttributeSettable(wrappedElement, kAXValueAttribute as CFString, &settable) + let value = wrappedElement.getValue(.value) + return Self.isTitleBarSpacer(role: role, parentRole: getElementValue(.parent)?.role, + frame: frame, titleBar: titleBar, childCount: childElements?.count, + hasValue: value != nil && (value as? String) != "", + hasOrientation: wrappedElement.getValue(.orientation) != nil, + valueSettable: status == .success ? settable.boolValue : nil) + } + + /// Some toolkits expose empty titlebar spacing as a splitter. A real + /// adjustable divider, or spacing below the titlebar, keeps its own clicks. + static func isTitleBarSpacer(role: NSAccessibility.Role?, parentRole: NSAccessibility.Role?, + frame: CGRect, titleBar: CGRect, childCount: Int?, + hasValue: Bool, hasOrientation: Bool, valueSettable: Bool?) -> Bool { + role == .splitter && parentRole == .toolbar && childCount == 0 + && !hasValue && !hasOrientation && valueSettable == false + && !frame.isEmpty && !frame.isInfinite && titleBar.contains(frame) + } private var subrole: NSAccessibility.Subrole? { guard let value = wrappedElement.getValue(.subrole) as? String else { return nil } @@ -131,7 +153,7 @@ class AccessibilityElement { /// The Accessebility API only allows size & position adjustments individually. /// To handle moving to different displays, we have to adjust the size then the position, then the size again since macOS will enforce sizes that fit on the current display. /// When windows take a long time to adjust size & position, there is some visual stutter with doing each of these actions. The stutter can be slightly reduced by removing the initial size adjustment, which can make unsnap restore appear smoother. - func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true) { + func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) { let appElement = applicationElement let builtInAssistiveTechnologyEnabled = NSWorkspace.shared.isVoiceOverEnabled || NSWorkspace.shared.isSwitchControlEnabled @@ -149,11 +171,90 @@ class AccessibilityElement { if adjustSizeFirst { size = frame.size } - position = frame.origin + if adjustPosition { position = frame.origin } size = frame.size } ) } + + /// Holds the Enhanced UI policy for the transition; returns its cleanup closure. + func beginAnimatedAdjustment() -> () -> Void { + let appElement = applicationElement + let restore = Defaults.enhancedUI.value.beginWindowAdjustment( + bundleIdentifier: appElement?.bundleIdentifier, + builtInAssistiveTechnologyEnabled: NSWorkspace.shared.isVoiceOverEnabled + || NSWorkspace.shared.isSwitchControlEnabled, + readEnhancedUI: { appElement?.enhancedUserInterface }, + writeEnhancedUI: { appElement?.enhancedUserInterface = $0 } + ) + // Bound AX calls so an unresponsive app cannot stall the animation. + setMessagingTimeout(0.05) + return { [self] in + setMessagingTimeout(0) + restore() + } + } + + /// Writes one frame without readback; completion handles the final placement. + func setAnimationFrame(_ frame: CGRect, resizeOnly: Bool = false) -> Bool { + var size = frame.size + var position = frame.origin + guard let sizeValue = AXValueCreate(.cgSize, &size), + let positionValue = AXValueCreate(.cgPoint, &position) else { return false } + guard AXUIElementSetAttributeValue(wrappedElement, kAXSizeAttribute as CFString, sizeValue) == .success else { return false } + // Native dragging owns position during size restoration. + if !resizeOnly { + guard AXUIElementSetAttributeValue(wrappedElement, kAXPositionAttribute as CFString, positionValue) == .success else { return false } + } + return true + } + + func setConstrainedAnimationFrame(_ frame: CGRect, placement: WindowAnimationPlacement, + origin: CGRect, progress: CGFloat, previousFrame: CGRect? = nil, + maximumCorrection: CGFloat = 0) -> CGRect? { + var preparedPosition: CGPoint? + if let previousFrame, + let position = placement.positionBeforeGrowing(from: previousFrame, to: frame), + writeAnimationPosition(position) == .success { + preparedPosition = position + } + // Resize before moving on shrinking axes: a refused shrink must not carry the wider window + // to the narrower frame's origin and leave it behind the Dock until completion. + let sizeUnchanged = progress < 1 && previousFrame?.size == frame.size + let resized = sizeUnchanged || writeAnimationSize(frame.size) == .success + let actualSize = size.flatMap { size -> CGSize? in + guard size.width.isFinite, size.height.isFinite, + size.width > 0, size.height > 0 else { return nil } + return size + } + // Finish positioning with the size the app reports. Shrinking axes must + // not move to the requested origin and then move back after a delayed + // Chromium readback. Continue moving even if resizing failed; + // native display settlement must not stall every intermediate position. + var resolved = placement.frame(for: frame, actualSize: actualSize ?? frame.size, + origin: origin, progress: progress) + if progress < 1, let previousFrame { + let previous = CGRect(origin: preparedPosition ?? previousFrame.origin, size: previousFrame.size) + resolved = placement.intermediateFrame(resolved, requested: frame, previous: previous, maximumCorrection: maximumCorrection) + } + if (preparedPosition ?? previousFrame?.origin) != resolved.origin { + guard writeAnimationPosition(resolved.origin) == .success else { return nil } + } + guard resized, actualSize != nil else { return nil } + return resolved + } + + func writeAnimationPosition(_ position: CGPoint) -> AXError { + var position = position + guard let value = AXValueCreate(.cgPoint, &position) else { return .failure } + return AXUIElementSetAttributeValue(wrappedElement, kAXPositionAttribute as CFString, value) + } + + func writeAnimationSize(_ size: CGSize) -> AXError { + var size = size + guard let value = AXValueCreate(.cgSize, &size) else { return .failure } + return AXUIElementSetAttributeValue(wrappedElement, kAXSizeAttribute as CFString, value) + } private var childElements: [AccessibilityElement]? { getElementsValue(.children) @@ -354,16 +455,31 @@ extension AccessibilityElement { return nil } - private static func getWindowInfo(_ location: CGPoint) -> WindowInfo? { - WindowUtil.getWindowList().first(where: {windowInfo in + private static func getWindowInfo(_ location: CGPoint, eventWindowID: CGWindowID? = nil) -> WindowInfo? { + // Ignore Rectangle preview surfaces when selecting an application window. + let ignoredWindows = Set(NSApp.windows.compactMap { window in + window.ignoresMouseEvents ? CGWindowID(exactly: window.windowNumber) : nil + }) + // A new click can follow a focus change or reveal within the list's + // 100 ms cache lifetime. Select from the current stacking order. + return windowInfoUnderCursor(at: location, windows: WindowUtil.getWindowList(ids: eventWindowID.map { [$0] }, forceRefresh: true), + eventWindowID: eventWindowID, ignoring: ignoredWindows) + } + + static func windowInfoUnderCursor(at location: CGPoint, windows: [WindowInfo], + eventWindowID: CGWindowID? = nil, + ignoring ignoredWindows: Set = []) -> WindowInfo? { + windows.first(where: { windowInfo in windowInfo.level < 21 // 21 is the level of the Notification Center + && windowInfo.alpha > 0 + && !ignoredWindows.contains(windowInfo.id) && !["Dock", "WindowManager"].contains(windowInfo.processName) - && windowInfo.frame.contains(location) + && (eventWindowID.map { windowInfo.id == $0 } ?? windowInfo.frame.contains(location)) }) } - static func getWindowElementUnderCursor() -> AccessibilityElement? { - let position = NSEvent.mouseLocation.screenFlipped + static func getWindowElementUnderCursor(at mouseDown: CGPoint? = nil, eventWindowID: CGWindowID? = nil) -> AccessibilityElement? { + let position = mouseDown ?? NSEvent.mouseLocation.screenFlipped var systemWideFirst = Defaults.systemWideMouseDown.userEnabled if Defaults.systemWideMouseDown.notSet, let frontAppId = ApplicationToggle.frontAppId { @@ -372,11 +488,12 @@ extension AccessibilityElement { if systemWideFirst, let element = AccessibilityElement(position), - let windowElement = element.windowElement { + let windowElement = element.windowElement, + eventWindowID == nil || windowElement.windowId == eventWindowID { return windowElement } - if let info = getWindowInfo(position) { + if let info = getWindowInfo(position, eventWindowID: eventWindowID) { if !Defaults.dragFromStage.userDisabled { if StageUtil.stageCapable && StageUtil.stageEnabled, let group = StageUtil.getStageStripWindowGroup(info.id), @@ -390,7 +507,7 @@ extension AccessibilityElement { if let windowElement = (windowElements.first { $0.windowId == info.id }) { return windowElement } - if let windowElement = (windowElements.first { $0.frame == info.frame }) { + if eventWindowID == nil, let windowElement = (windowElements.first { $0.frame == info.frame }) { return windowElement } } @@ -398,7 +515,8 @@ extension AccessibilityElement { if !systemWideFirst, let element = AccessibilityElement(position), - let windowElement = element.windowElement { + let windowElement = element.windowElement, + eventWindowID == nil || windowElement.windowId == eventWindowID { if Logger.logging, let pid = windowElement.pid { let appName = NSRunningApplication(processIdentifier: pid)?.localizedName ?? "" @@ -411,6 +529,9 @@ extension AccessibilityElement { // the frontmost app's own accessibility windows don't depend on it. if let frontAppElement = getFrontApplicationElement(), let windowElements = frontAppElement.windowElements { + if let eventWindowID { + return windowElements.first { $0.windowId == eventWindowID } + } let windowElement = windowElements .map { (element: $0, frame: $0.frame) } .filter { !$0.frame.isNull && $0.frame.contains(position) } @@ -544,22 +665,34 @@ enum EnhancedUI: Int { bundleIdentifier: String?, builtInAssistiveTechnologyEnabled: Bool, readEnhancedUI: () -> Bool?, - writeEnhancedUI: (Bool) -> Void, + writeEnhancedUI: @escaping (Bool) -> Void, adjustment: () -> Void ) { + let restore = beginWindowAdjustment(bundleIdentifier: bundleIdentifier, + builtInAssistiveTechnologyEnabled: builtInAssistiveTechnologyEnabled, + readEnhancedUI: readEnhancedUI, + writeEnhancedUI: writeEnhancedUI) + adjustment() + restore() + } + + func beginWindowAdjustment( + bundleIdentifier: String?, + builtInAssistiveTechnologyEnabled: Bool, + readEnhancedUI: () -> Bool?, + writeEnhancedUI: @escaping (Bool) -> Void + ) -> () -> Void { let enhancedUIWasEnabled = readEnhancedUI() if enhancedUIWasEnabled == true { writeEnhancedUI(false) } - adjustment() - - if enhancedUIWasEnabled == true, - restoresEnhancedUI( + let shouldRestore = enhancedUIWasEnabled == true && restoresEnhancedUI( bundleIdentifier: bundleIdentifier, builtInAssistiveTechnologyEnabled: builtInAssistiveTechnologyEnabled - ) { - writeEnhancedUI(true) + ) + return { + if shouldRestore { writeEnhancedUI(true) } } } } diff --git a/Rectangle/AppDelegate.swift b/Rectangle/AppDelegate.swift index 11825ef09..90c64aa57 100644 --- a/Rectangle/AppDelegate.swift +++ b/Rectangle/AppDelegate.swift @@ -5,7 +5,7 @@ import Sparkle import ServiceManagement import os.log -@NSApplicationMain +@main class AppDelegate: NSObject, NSApplicationDelegate { static let launcherAppId = "com.knollsoft.RectangleLauncher" diff --git a/Rectangle/Base.lproj/Main.storyboard b/Rectangle/Base.lproj/Main.storyboard index 3d9f0bfcf..eec478932 100644 --- a/Rectangle/Base.lproj/Main.storyboard +++ b/Rectangle/Base.lproj/Main.storyboard @@ -3912,6 +3912,19 @@ DQ + + + + + + - + - - @@ -4494,6 +4524,8 @@ DQ + + diff --git a/Rectangle/Defaults.swift b/Rectangle/Defaults.swift index 00dae42ec..26403a82b 100644 --- a/Rectangle/Defaults.swift +++ b/Rectangle/Defaults.swift @@ -12,6 +12,7 @@ class Defaults { static let cycleSizesIsChanged = BoolDefault(key: "cycleSizesIsChanged") static let cornerCycleExpansionAxis = IntEnumDefault(key: "cornerCycleExpansionAxis", defaultValue: .horizontal) static let cooperativeCornerResize = BoolDefault(key: "cooperativeCornerResize") + static let experimentalWindowAnimations = BoolDefault(key: "experimentalWindowAnimations") static let allowAnyShortcut = BoolDefault(key: "allowAnyShortcut") static let windowSnapping = OptionalBoolDefault(key: "windowSnapping") static let almostMaximizeHeight = FloatDefault(key: "almostMaximizeHeight") @@ -51,10 +52,19 @@ class Defaults { static let showAllActionsInMenu = OptionalBoolDefault(key: "showAllActionsInMenu") static let showAdditionalSizesInMenu = OptionalBoolDefault(key: "showAdditionalSizesInMenu") static var SUHasLaunchedBefore: Bool { UserDefaults.standard.bool(forKey: "SUHasLaunchedBefore") } - static let footprintAlpha = FloatDefault(key: "footprintAlpha", defaultValue: 0.3) + static let footprintAlpha = DoubleDefault(key: "footprintAlpha") + static var effectiveFootprintAlpha: Double { + if UserDefaults.standard.object(forKey: footprintAlpha.key) == nil { + return footprintBlur.enabled ? 0 : 0.3 + } + return footprintAlpha.value + } static let footprintBorderWidth = FloatDefault(key: "footprintBorderWidth", defaultValue: 2) static let footprintFade = OptionalBoolDefault(key: "footprintFade") static let footprintColor = JSONDefault(key: "footprintColor") + static let footprintBlur = BoolDefault(key: "footprintBlur") + static let blurAppearance = IntEnumDefault(key: "blurAppearance", defaultValue: .system) + static let SUEnableAutomaticChecks = BoolDefault(key: "SUEnableAutomaticChecks") static let todo = OptionalBoolDefault(key: "todo") static let todoMode = BoolDefault(key: "todoMode") @@ -107,6 +117,7 @@ class Defaults { static let ignoreDragSnapToo = OptionalBoolDefault(key: "ignoreDragSnapToo") static let systemWideMouseDown = OptionalBoolDefault(key: "systemWideMouseDown") static let systemWideMouseDownApps = JSONDefault>(key:"systemWideMouseDownApps", defaultValue: Set(["org.languagetool.desktop", "com.microsoft.teams2"])) + static let directAnimationNativeResizeApps = JSONDefault>(key: "directAnimationNativeResizeApps", defaultValue: Set(["com.colliderli.iina"])) static let internalTilingNotified = BoolDefault(key: "internalTilingNotified") static let screensOrderedByX = IntEnumDefault(key: "screensOrderedByX", defaultValue: .yThenMinX) static let combinedDisplayMode = OptionalBoolDefault(key: "combinedDisplayMode") @@ -121,6 +132,7 @@ class Defaults { cycleSizesIsChanged, cornerCycleExpansionAxis, cooperativeCornerResize, + experimentalWindowAnimations, allowAnyShortcut, windowSnapping, almostMaximizeHeight, @@ -158,6 +170,8 @@ class Defaults { footprintBorderWidth, footprintFade, footprintColor, + footprintBlur, + blurAppearance, SUEnableAutomaticChecks, todo, todoMode, @@ -201,6 +215,7 @@ class Defaults { ignoreDragSnapToo, systemWideMouseDown, systemWideMouseDownApps, + directAnimationNativeResizeApps, screensOrderedByX, showAdditionalSizesInMenu, cyclingOverlapOffset, @@ -557,3 +572,18 @@ struct CodableColor : Codable { self.alpha = nsColor.alphaComponent } } + +enum BlurAppearance: Int, CaseIterable { + case system = 0 + case light = 1 + case dark = 2 + + var appearance: NSAppearance? { + switch self { + case .system: return nil + case .light: return NSAppearance(named: .aqua) + case .dark: return NSAppearance(named: .darkAqua) + } + } +} + diff --git a/Rectangle/PrefsWindow/Config.swift b/Rectangle/PrefsWindow/Config.swift index f02f1de2a..fc31f9ff5 100644 --- a/Rectangle/PrefsWindow/Config.swift +++ b/Rectangle/PrefsWindow/Config.swift @@ -28,6 +28,7 @@ extension Defaults { for exportableDefault in Defaults.array { codableDefaults[exportableDefault.key] = exportableDefault.toCodable() } + codableDefaults[footprintAlpha.key] = CodableDefault(double: effectiveFootprintAlpha) let config = Config(bundleId: "com.knollsoft.Rectangle", version: version, diff --git a/Rectangle/PrefsWindow/SnapAreaViewController.swift b/Rectangle/PrefsWindow/SnapAreaViewController.swift index a141db0cd..2b65a509f 100644 --- a/Rectangle/PrefsWindow/SnapAreaViewController.swift +++ b/Rectangle/PrefsWindow/SnapAreaViewController.swift @@ -7,8 +7,11 @@ class SnapAreaViewController: NSViewController { @IBOutlet weak var windowSnappingCheckbox: NSButton! @IBOutlet weak var unsnapRestoreButton: NSButton! @IBOutlet weak var animateFootprintCheckbox: NSButton! + @IBOutlet weak var blurFootprintCheckbox: NSButton! + @IBOutlet weak var experimentalWindowAnimationsCheckbox: NSButton! @IBOutlet weak var hapticFeedbackCheckbox: NSButton! @IBOutlet weak var missionControlDraggingCheckbox: NSButton! + private let blurAppearanceSelect = NSPopUpButton(frame: .zero, pullsDown: false) @IBOutlet weak var topLeftLandscapeSelect: NSPopUpButton! @IBOutlet weak var topLandscapeSelect: NSPopUpButton! @@ -48,6 +51,49 @@ class SnapAreaViewController: NSViewController { let newSetting: Float = sender.state == .on ? 0.75 : 0 Defaults.footprintAnimationDurationMultiplier.value = newSetting } + + @IBAction func toggleBlurFootprint(_ sender: NSButton) { + Defaults.footprintBlur.enabled = sender.state == .on + refreshWindowAnimationPreferences() + } + + @IBAction func toggleExperimentalWindowAnimations(_ sender: NSButton) { + Defaults.experimentalWindowAnimations.enabled = sender.state == .on + refreshWindowAnimationPreferences() + Notification.Name.windowAnimationPreferencesChanged.post() + } + + private func refreshWindowAnimationPreferences() { + blurFootprintCheckbox.state = Defaults.footprintBlur.enabled ? .on : .off + experimentalWindowAnimationsCheckbox.state = Defaults.experimentalWindowAnimations.enabled ? .on : .off + blurAppearanceSelect.selectItem(withTag: Defaults.blurAppearance.value.rawValue) + blurAppearanceSelect.isEnabled = Defaults.footprintBlur.enabled + } + + private func configureBlurAppearance() { + guard let stack = blurFootprintCheckbox.superview as? NSStackView else { return } + let label = NSTextField(labelWithString: NSLocalizedString("Blur appearance", tableName: "Main", comment: "Preview blur appearance setting")) + let titles = [NSLocalizedString("Follow System", tableName: "Main", comment: "Use the system appearance"), + NSLocalizedString("Light", tableName: "Main", comment: "Light preview blur"), + NSLocalizedString("Dark", tableName: "Main", comment: "Dark preview blur")] + for (mode, title) in zip(BlurAppearance.allCases, titles) { + blurAppearanceSelect.addItem(withTitle: title) + blurAppearanceSelect.lastItem?.tag = mode.rawValue + } + blurAppearanceSelect.setAccessibilityLabel(label.stringValue) + blurAppearanceSelect.target = self + blurAppearanceSelect.action = #selector(setBlurAppearance(_:)) + let row = NSStackView(views: [label, blurAppearanceSelect]) + row.orientation = .horizontal + row.alignment = .centerY + row.spacing = 8 + stack.addArrangedSubview(row) + } + + @objc private func setBlurAppearance(_ sender: NSPopUpButton) { + guard let appearance = BlurAppearance(rawValue: sender.selectedTag()) else { return } + Defaults.blurAppearance.value = appearance + } @IBAction func toggleHapticFeedback(_ sender: NSButton) { let newSetting: Bool = sender.state == .on @@ -81,15 +127,18 @@ class SnapAreaViewController: NSViewController { } override func viewDidLoad() { + configureBlurAppearance() windowSnappingCheckbox.state = Defaults.windowSnapping.userDisabled ? .off : .on unsnapRestoreButton.state = Defaults.unsnapRestore.userDisabled ? .off : .on animateFootprintCheckbox.state = Defaults.footprintAnimationDurationMultiplier.value > 0 ? .on : .off + refreshWindowAnimationPreferences() hapticFeedbackCheckbox.state = Defaults.hapticFeedbackOnSnap.userEnabled ? .on : .off missionControlDraggingCheckbox.state = Defaults.missionControlDragging.userDisabled ? .on : .off missionControlDraggingCheckbox.isHidden = !Defaults.missionControlDragging.userDisabled showHidePortrait() Notification.Name.configImported.onPost(using: { [weak self] _ in + self?.refreshWindowAnimationPreferences() self?.loadSnapAreas() }) Notification.Name.defaultSnapAreas.onPost(using: { [weak self] _ in @@ -113,6 +162,8 @@ class SnapAreaViewController: NSViewController { // Only load the selects when the view appears, to fix a performance issue where switching to this tab was taking a long time to load var selectsLoaded = false override func viewWillAppear() { + refreshWindowAnimationPreferences() + animateFootprintCheckbox.state = Defaults.footprintAnimationDurationMultiplier.value > 0 ? .on : .off if !selectsLoaded { loadSnapAreas() selectsLoaded = true diff --git a/Rectangle/ScreenDetection.swift b/Rectangle/ScreenDetection.swift index 727c7ffc8..d687d492c 100644 --- a/Rectangle/ScreenDetection.swift +++ b/Rectangle/ScreenDetection.swift @@ -4,8 +4,17 @@ import Cocoa class ScreenDetection { + private let availableScreens: () -> [NSScreen] + private let logicalFrame: (AccessibilityElement) -> CGRect? + + init(screens: @escaping () -> [NSScreen] = { NSScreen.screens }, + logicalFrame: @escaping (AccessibilityElement) -> CGRect? = { WindowAnimator.shared.logicalFrame(for: $0) }) { + availableScreens = screens + self.logicalFrame = logicalFrame + } + func detectScreens(using frontmostWindowElement: AccessibilityElement?) -> UsableScreens? { - let screens = NSScreen.screens + let screens = availableScreens() guard let firstScreen = screens.first else { return nil } if screens.count == 1 { @@ -17,7 +26,9 @@ class ScreenDetection { } let screensOrdered = order(screens: screens) - guard let sourceScreen: NSScreen = screenContaining(frontmostWindowElement?.frame ?? CGRect.zero, screens: screensOrdered) else { + // Route repeated actions using the destination instead of an intermediate animation frame. + let windowFrame = frontmostWindowElement.map { logicalFrame($0) ?? $0.frame } ?? .zero + guard let sourceScreen: NSScreen = screenContaining(windowFrame, screens: screensOrdered) else { let adjacentScreens = AdjacentScreens(prev: firstScreen, next: firstScreen) return UsableScreens(currentScreen: firstScreen, adjacentScreens: adjacentScreens, numScreens: screens.count, screensOrdered: screensOrdered) } @@ -28,7 +39,7 @@ class ScreenDetection { } func detectScreensAtCursor() -> UsableScreens? { - let screens = NSScreen.screens + let screens = availableScreens() if screens.count == 1 { return detectScreens(using: nil) } diff --git a/Rectangle/ShortcutManager.swift b/Rectangle/ShortcutManager.swift index 566d32166..9a193d23a 100644 --- a/Rectangle/ShortcutManager.swift +++ b/Rectangle/ShortcutManager.swift @@ -34,6 +34,7 @@ typealias ShortcutRebindScheduler = (@escaping () -> Void) -> Void class ShortcutManager { let windowManager: WindowManager + private let screenDetection: ScreenDetection private let bindingStore: ShortcutBindingStore private let notificationCenter: NotificationCenter private let workspaceNotificationCenter: NotificationCenter @@ -51,6 +52,7 @@ class ShortcutManager { init( windowManager: WindowManager, + screenDetection: ScreenDetection = ScreenDetection(), bindingStore: ShortcutBindingStore = MASShortcutBindingStore(), notificationCenter: NotificationCenter = .default, workspaceNotificationCenter: NotificationCenter = NSWorkspace.shared.notificationCenter, @@ -69,6 +71,7 @@ class ShortcutManager { } ) { self.windowManager = windowManager + self.screenDetection = screenDetection self.bindingStore = bindingStore self.notificationCenter = notificationCenter self.workspaceNotificationCenter = workspaceNotificationCenter @@ -213,6 +216,7 @@ class ShortcutManager { // Check if repeat cycles displays if Defaults.subsequentExecutionMode.value == .cycleMonitor, + parameters.source != .titleBar, parameters.action.classification != .size, parameters.action.classification != .display { guard let windowElement = parameters.windowElement ?? AccessibilityElement.getFrontWindowElement(), @@ -223,9 +227,9 @@ class ShortcutManager { } if isRepeatAction(parameters: parameters, windowElement: windowElement, windowId: windowId), - RepeatedMaximizeRestore.restoreRect(for: parameters.action, windowId: windowId, windowRect: windowElement.frame) == nil { - if let screen = ScreenDetection().detectScreens(using: windowElement)?.adjacentScreens?.next{ - parameters = ExecutionParameters(parameters.action, updateRestoreRect: parameters.updateRestoreRect, screen: screen, windowElement: windowElement, windowId: windowId) + RepeatedMaximizeRestore.restoreRect(for: parameters.action, windowId: windowId, windowRect: windowManager.logicalFrame(for: windowElement)) == nil { + if let screen = screenDetection.detectScreens(using: windowElement)?.adjacentScreens?.next{ + parameters = ExecutionParameters(parameters.action, updateRestoreRect: parameters.updateRestoreRect, screen: screen, windowElement: windowElement, windowId: windowId, source: parameters.source) // Bypass any other subsequent action by removing the last action AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) } @@ -243,15 +247,16 @@ class ShortcutManager { return } + let logicalFrame = windowManager.logicalFrame(for: windowElement) let lastAction = AppDelegate.windowHistory.lastRectangleActions[windowId] - if ShortcutCycle.isStale(lastAction: lastAction, currentWindowRect: windowElement.frame) { + if ShortcutCycle.isStale(lastAction: lastAction, currentWindowRect: logicalFrame) { AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) } let selectedAction = ShortcutCycle.action( in: group, lastAction: AppDelegate.windowHistory.lastRectangleActions[windowId], - currentWindowRect: windowElement.frame + currentWindowRect: logicalFrame ) execute(ExecutionParameters(selectedAction, windowElement: windowElement, windowId: windowId)) } @@ -306,7 +311,7 @@ class ShortcutManager { private func isRepeatAction(parameters: ExecutionParameters, windowElement: AccessibilityElement, windowId: CGWindowID) -> Bool { if parameters.action == .maximize { - if ScreenDetection().detectScreens(using: windowElement)?.currentScreen.visibleFrame.size == windowElement.frame.size { + if screenDetection.detectScreens(using: windowElement)?.currentScreen.visibleFrame.size == windowManager.logicalFrame(for: windowElement).size { return true } } diff --git a/Rectangle/Snapping/FootprintWindow.swift b/Rectangle/Snapping/FootprintWindow.swift index 0862a5e0f..1f382f95f 100644 --- a/Rectangle/Snapping/FootprintWindow.swift +++ b/Rectangle/Snapping/FootprintWindow.swift @@ -2,22 +2,221 @@ import Cocoa +struct FootprintAccessibility { + var reduceMotion: Bool + var reduceTransparency: Bool + + static var current: Self { + Self(reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, + reduceTransparency: NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency) + } +} + +struct FootprintPresentation { + let usesBlur: Bool + let alpha: CGFloat + let fades: Bool + let animates: Bool + + init(blurRequested: Bool, alpha: CGFloat, fadeRequested: Bool, + animationRequested: Bool, accessibility: FootprintAccessibility) { + usesBlur = blurRequested && !accessibility.reduceTransparency + // AppKit blur needs full window opacity; configured alpha controls its tint. + self.alpha = usesBlur || accessibility.reduceTransparency ? 1 : min(1, max(0, alpha)) + fades = fadeRequested && !accessibility.reduceMotion && !accessibility.reduceTransparency + animates = animationRequested && !accessibility.reduceMotion + } +} + +enum FootprintAnimationGeometry { + static func initialFrame(in destination: CGRect, from origin: CGPoint) -> CGRect { + // A zero-sized frame on a shared edge can attach to the neighboring + // display's Space. Keep the first visible pixel inside the destination. + let width = min(1, destination.width) + let height = min(1, destination.height) + return CGRect(x: min(max(origin.x, destination.minX), destination.maxX - width), + y: min(max(origin.y, destination.minY), destination.maxY - height), + width: width, height: height) + } +} + +private final class FootprintContentView: NSView { + var appearanceDidChange: (() -> Void)? + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + appearanceDidChange?() + } +} + +enum FootprintStyle { + static let previewLevel = NSWindow.Level.modalPanel + static let cornerRadius: CGFloat = { + // Use macOS 27's uniform window radius on both Liquid Glass releases. + if #available(macOS 26.0, *) { return 16 } + if #available(macOS 11.0, *) { return 10 } + return 5 + }() + static let shadowPadding: CGFloat = 96 + static let shadowRadius: CGFloat = 32 + static let shadowOffset = CGSize(width: 0, height: -8) + + static func shadowOpacity(isDark: Bool) -> Float { isDark ? 0.65 : 0.42 } + static func borderColor(isDark: Bool) -> NSColor { + let gray: CGFloat = isDark ? 0.7 : 0.35 + return NSColor(srgbRed: gray, green: gray, blue: gray, alpha: 0.35) + } + static var borderWidth: CGFloat { + UserDefaults.standard.object(forKey: Defaults.footprintBorderWidth.key) == nil + ? 1 : max(0, CGFloat(Defaults.footprintBorderWidth.value)) + } +} + +/// Draw only the shadow outside the glass, keeping its transparent interior clear. +final class FootprintShadow { + let container = CALayer() + let shape = CALayer() + let cutout = CAShapeLayer() + let cornerRadius: CGFloat + var layers: [CALayer] { [container, shape, cutout] } + private var size: CGSize? + + init(cornerRadius: CGFloat = FootprintStyle.cornerRadius) { + self.cornerRadius = cornerRadius + for layer in layers { + layer.anchorPoint = .zero + layer.position = .zero + layer.contentsFormat = .RGBA8Uint + if #available(macOS 26, *) { layer.preferredDynamicRange = .standard } + else if #available(macOS 14, *) { layer.wantsExtendedDynamicRangeContent = false } + } + let padding = FootprintStyle.shadowPadding + container.position = CGPoint(x: -padding, y: -padding) + container.addSublayer(shape) + container.mask = cutout + cutout.fillRule = .evenOdd + shape.shadowColor = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 1).cgColor + shape.shadowRadius = FootprintStyle.shadowRadius + shape.shadowOffset = FootprintStyle.shadowOffset + } + + private func geometry(size: CGSize) -> (bounds: CGRect, outline: CGPath, cutout: CGPath) { + let padding = FootprintStyle.shadowPadding + let bounds = CGRect(x: 0, y: 0, width: size.width + 2 * padding, height: size.height + 2 * padding) + let panel = CGRect(origin: CGPoint(x: padding, y: padding), size: size) + let radius = min(cornerRadius, min(size.width, size.height) / 2) + let outline = CGPath(roundedRect: panel, cornerWidth: radius, cornerHeight: radius, transform: nil) + let cutout = CGMutablePath() + cutout.addRect(bounds) + cutout.addPath(outline) + return (bounds, outline, cutout) + } + + func setSize(_ size: CGSize) { + guard self.size != size else { return } + self.size = size + let geometry = geometry(size: size) + CATransaction.begin(); CATransaction.setDisableActions(true) + layers.forEach { $0.bounds = geometry.bounds } + shape.shadowPath = geometry.outline + cutout.path = geometry.cutout + CATransaction.commit() + } + +} + +private final class FootprintShadowWindow: NSWindow { + private let shadow: FootprintShadow + let cornerRadius: CGFloat + private let padding = FootprintStyle.shadowPadding + + init(cornerRadius: CGFloat) { + self.cornerRadius = cornerRadius + shadow = FootprintShadow(cornerRadius: cornerRadius) + super.init(contentRect: .zero, styleMask: .borderless, backing: .buffered, defer: false) + colorSpace = .sRGB + isOpaque = false + backgroundColor = .clear + hasShadow = false + ignoresMouseEvents = true + isReleasedWhenClosed = false + animationBehavior = .none + level = FootprintStyle.previewLevel + collectionBehavior = [.transient, .ignoresCycle] + + let view = NSView() + view.wantsLayer = true + shadow.container.position = .zero + view.layer?.addSublayer(shadow.container) + contentView = view + } + + override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { + // Allow shadow padding beyond screen edges without shifting the preview. + frameRect + } + + func update(around rect: CGRect, isDark: Bool) { + setFrame(rect.insetBy(dx: -padding, dy: -padding), display: false) + CATransaction.begin(); CATransaction.setDisableActions(true) + shadow.setSize(rect.size) + shadow.shape.shadowColor = NSColor.black.cgColor + shadow.shape.shadowRadius = FootprintStyle.shadowRadius + shadow.shape.shadowOffset = FootprintStyle.shadowOffset + shadow.shape.shadowOpacity = FootprintStyle.shadowOpacity(isDark: isDark) + CATransaction.commit() + } +} + class FootprintWindow: NSWindow { - private var orderOutCanceled = false - - init() { - let initialRect = NSRect(x: 0, y: 0, width: 0, height: 0) - super.init(contentRect: initialRect, styleMask: .titled, backing: .buffered, defer: false) + private let boxView = NSBox() + private let effectView = NSVisualEffectView() + private var shadowWindow: FootprintShadowWindow? + private var blurMaskRadius: CGFloat? + private let accessibility: () -> FootprintAccessibility + private let clock: () -> TimeInterval + private var accessibilityObserver: NSObjectProtocol? + private var showing = false + private var frameAnimation: WindowFrameAnimation? + private var fade: Fade? + private var timer: Timer? + private struct Fade { + let from: CGFloat + let to: CGFloat + let start: TimeInterval + let duration: TimeInterval + } + + var presentation: FootprintPresentation { + FootprintPresentation(blurRequested: Defaults.footprintBlur.enabled, + alpha: CGFloat(Defaults.effectiveFootprintAlpha), + fadeRequested: !Defaults.footprintFade.userDisabled, + animationRequested: Defaults.footprintAnimationDurationMultiplier.value > 0, + accessibility: accessibility()) + } + + + private var cornerRadius: CGFloat { + Defaults.footprintBlur.enabled ? 12 : FootprintStyle.cornerRadius + } + + init(initialFrame: CGRect = .zero, + accessibility: @escaping () -> FootprintAccessibility = { .current }, + clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { + self.accessibility = accessibility + self.clock = clock + super.init(contentRect: initialFrame, styleMask: .titled, backing: .buffered, defer: false) title = "Rectangle" + colorSpace = .sRGB isOpaque = false - level = .modalPanel + backgroundColor = .clear + level = FootprintStyle.previewLevel hasShadow = false + ignoresMouseEvents = true isReleasedWhenClosed = false - alphaValue = Defaults.footprintFade.userDisabled - ? CGFloat(Defaults.footprintAlpha.value) - : 0 - + alphaValue = 0 + styleMask.insert(.fullSizeContentView) titleVisibility = .hidden titlebarAppearsTransparent = true @@ -26,63 +225,295 @@ class FootprintWindow: NSWindow { standardWindowButton(.miniaturizeButton)?.isHidden = true standardWindowButton(.zoomButton)?.isHidden = true standardWindowButton(.toolbarButton)?.isHidden = true - - let boxView = NSBox() + + let container = FootprintContentView(frame: .zero) + container.wantsLayer = true + let radius = cornerRadius + container.layer?.cornerRadius = radius + container.layer?.masksToBounds = true + effectView.material = .fullScreenUI + effectView.blendingMode = .behindWindow + effectView.state = .active + effectView.autoresizingMask = [.width, .height] + container.addSubview(effectView) boxView.boxType = .custom - boxView.borderColor = .lightGray + boxView.cornerRadius = radius + boxView.wantsLayer = true + boxView.autoresizingMask = [.width, .height] + container.addSubview(boxView) + // Keep custom preview layers in SDR. + for view in [container, effectView, boxView] { + view.wantsLayer = true + view.layer?.contentsFormat = .RGBA8Uint + if #available(macOS 26, *) { + view.layer?.preferredDynamicRange = .standard + } else if #available(macOS 14, *) { + view.layer?.wantsExtendedDynamicRangeContent = false + } + } + contentView = container + container.appearanceDidChange = { [weak self] in self?.updateAppearance() } + updateAppearance() + accessibilityObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, + object: nil, queue: .main + ) { [weak self] _ in + self?.refreshAccessibility() + } + } + + deinit { + timer?.invalidate() + if let shadowWindow { + removeChildWindow(shadowWindow) + shadowWindow.close() + } + if let accessibilityObserver { + NSWorkspace.shared.notificationCenter.removeObserver(accessibilityObserver) + } + } + + private func updateAppearance() { + let style = presentation + let requestedAppearance = Defaults.footprintBlur.enabled ? Defaults.blurAppearance.value.appearance : nil + if appearance?.name != requestedAppearance?.name { + appearance = requestedAppearance + } + let windowStyle: NSWindow.StyleMask = Defaults.footprintBlur.enabled ? .borderless : [.titled, .fullSizeContentView] + if styleMask != windowStyle { + styleMask = windowStyle + titleVisibility = .hidden + titlebarAppearsTransparent = true + for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton, .toolbarButton] { + standardWindowButton(button)?.isHidden = true + } + } + let isDark = (contentView?.effectiveAppearance ?? effectiveAppearance) + .bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let radius = cornerRadius + contentView?.layer?.cornerRadius = radius + boxView.cornerRadius = radius + if style.usesBlur, blurMaskRadius != radius { + // Clip the material itself to prevent bright corners outside the tint mask. + let mask = NSImage(size: NSSize(width: radius * 2 + 1, height: radius * 2 + 1), flipped: false) { rect in + NSColor.white.setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() + return true + } + mask.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius) + mask.resizingMode = .stretch + effectView.maskImage = mask + blurMaskRadius = radius + } + effectView.isHidden = !style.usesBlur + effectView.alphaValue = 1 + boxView.borderColor = style.usesBlur + ? FootprintStyle.borderColor(isDark: isDark) + : .lightGray boxView.borderWidth = CGFloat(Defaults.footprintBorderWidth.value) - - if #available(macOS 26.0, *) { - boxView.cornerRadius = 16 - } else if #available(macOS 11.0, *) { - boxView.cornerRadius = 10 + if style.usesBlur { + boxView.borderWidth = FootprintStyle.borderWidth + } + let customColor = Defaults.footprintColor.typedValue?.nsColor + let defaultTint: NSColor = Defaults.footprintBlur.enabled && !isDark ? .white : .black + let color = customColor ?? defaultTint + boxView.alphaValue = 1 + if accessibility().reduceTransparency { + boxView.fillColor = color.withAlphaComponent(1) + } else if style.usesBlur { + let tintAlpha = min(1, max(0, CGFloat(Defaults.effectiveFootprintAlpha))) + boxView.fillColor = color.withAlphaComponent(tintAlpha) } else { - boxView.cornerRadius = 5 + boxView.fillColor = color } - boxView.wantsLayer = true - boxView.fillColor = Defaults.footprintColor.typedValue?.nsColor ?? NSColor.black - - contentView = boxView + updateShadow() + } + + + + private func updateShadow() { + guard presentation.usesBlur, super.isVisible, !frame.isEmpty else { + shadowWindow?.orderOut(nil) + return + } + if let previous = shadowWindow, previous.cornerRadius != cornerRadius { + removeChildWindow(previous) + previous.close() + shadowWindow = nil + } + let shadow = shadowWindow ?? FootprintShadowWindow(cornerRadius: cornerRadius) + shadowWindow = shadow + let isDark = (contentView?.effectiveAppearance ?? effectiveAppearance) + .bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + shadow.update(around: frame, isDark: isDark) + shadow.alphaValue = alphaValue + if shadow.parent != self { + addChildWindow(shadow, ordered: .below) + } + if !shadow.isVisible { + shadow.order(.below, relativeTo: windowNumber) + } + } + + override func setFrame(_ frameRect: NSRect, display flag: Bool) { + super.setFrame(frameRect, display: flag) + updateShadow() + } + + override var alphaValue: CGFloat { + get { super.alphaValue } + set { + super.alphaValue = newValue + shadowWindow?.alphaValue = newValue + } + } + + private func hidePreview() { + shadowWindow?.orderOut(nil) + super.orderOut(nil) + tracePresentation("hidden") + } + + private func tracePresentation(_ event: String, target: CGRect? = nil) { + let rect = (target ?? frame).screenFlipped + WindowAnimationDiagnostics.event("footprint." + event, fields: ["windowID": windowNumber, + "frame": [rect.minX, rect.minY, rect.width, rect.height], + "alpha": alphaValue, "showing": showing, "visible": super.isVisible]) + } + + func refreshAccessibility() { + updateAppearance() + if !presentation.animates { frameAnimation?.finish() } + // Apply accessibility changes immediately, including during an active fade. + fade = nil + alphaValue = showing ? presentation.alpha : 0 + if !showing { + frameAnimation?.cancel() + hidePreview() + } + stopTimerIfIdle() } - + override var isVisible: Bool { - // Workaround for footprint getting pushed off of Stage Manager + // Workaround for footprint getting pushed off of Stage Manager. if StageUtil.stageCapable && StageUtil.stageEnabled && StageUtil.stageStripShow { return true } return realIsVisible } - - var realIsVisible: Bool { - if Defaults.footprintFade.userDisabled { - return super.isVisible - } else { - return alphaValue == Defaults.footprintAlpha.cgFloat + + var realIsVisible: Bool { showing && super.isVisible } + + func showPreview(in rect: CGRect, from origin: CGPoint?, duration: TimeInterval) { + tracePresentation("target", target: rect) + frameAnimation?.cancel() + updateAppearance() + if !super.isVisible || alphaValue == 0 { + let initial = presentation.animates ? origin.map { FootprintAnimationGeometry.initialFrame(in: rect, from: $0) } : nil + setFrame(initial ?? rect, display: false) } + orderFront(nil) + movePreview(to: rect, duration: duration) } - + + func movePreview(to rect: CGRect, duration: TimeInterval) { + frameAnimation?.cancel() + guard presentation.animates, duration > 0, frame != rect else { + setFrame(rect, display: true) + stopTimerIfIdle() + return + } + frameAnimation = WindowFrameAnimation(from: frame, to: rect, startTime: clock(), duration: duration, + curve: WindowPreviewDeceleration.value, + write: { [weak self] frame, _ in + self?.setFrame(frame, display: true) + return true + }, cleanup: { [weak self] in + self?.frameAnimation = nil + }, completion: { [weak self] frame in + self?.setFrame(frame, display: true) + self?.tracePresentation("arrived") + }) + startTimer() + } + override func orderFront(_ sender: Any?) { - if Defaults.footprintFade.userDisabled { + updateAppearance() + showing = true + if presentation.fades { super.orderFront(sender) + startFade(to: presentation.alpha, duration: 0.18) } else { - orderOutCanceled = true + fade = nil + alphaValue = presentation.alpha super.orderFront(sender) - animator().alphaValue = Defaults.footprintAlpha.cgFloat } + updateShadow() + tracePresentation("ordered") } - + override func orderOut(_ sender: Any?) { - if Defaults.footprintFade.userDisabled { - super.orderOut(nil) + showing = false + tracePresentation("dismiss") + frameAnimation?.cancel() + if presentation.fades && super.isVisible { + startFade(to: 0, duration: 0.12) } else { - orderOutCanceled = false - NSAnimationContext.runAnimationGroup { changes in - animator().alphaValue = 0.0 - } completionHandler: { - if !self.orderOutCanceled { - super.orderOut(nil) - } + fade = nil + alphaValue = 0 + hidePreview() + stopTimerIfIdle() + } + } + + override func close() { + showing = false + fade = nil + frameAnimation?.cancel() + timer?.invalidate() + timer = nil + shadowWindow?.orderOut(nil) + super.close() + } + + private func startFade(to alpha: CGFloat, duration: TimeInterval) { + guard alphaValue != alpha else { + fade = nil + if !showing { hidePreview() } + stopTimerIfIdle() + return + } + fade = Fade(from: alphaValue, to: alpha, start: clock(), duration: duration) + startTimer() + } + + private func startTimer() { + guard timer == nil else { return } + let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in + guard let self else { return } + self.advanceAnimations(at: self.clock()) + } + self.timer = timer + RunLoop.main.add(timer, forMode: .common) + } + + func advanceAnimations(at time: TimeInterval) { + frameAnimation?.tick(at: time) + if let fade { + let progress = min(1, max(0, (time - fade.start) / fade.duration)) + alphaValue = fade.from + (fade.to - fade.from) * WindowAnimationCurve.value(at: progress) + if progress >= 1 { + self.fade = nil + if !showing { hidePreview() } } } + stopTimerIfIdle() + } + + private func stopTimerIfIdle() { + if frameAnimation == nil && fade == nil { + timer?.invalidate() + timer = nil + } } } diff --git a/Rectangle/Snapping/SnappingManager.swift b/Rectangle/Snapping/SnappingManager.swift index e2bac436b..dff415395 100644 --- a/Rectangle/Snapping/SnappingManager.swift +++ b/Rectangle/Snapping/SnappingManager.swift @@ -8,6 +8,83 @@ struct SnapArea: Equatable { let action: WindowAction } +struct NativeSnapGesture { + private(set) var generation = UUID() + private(set) var held = false + private(set) var cancelled = false + private(set) var observedDrag = false + + mutating func begin() { generation = UUID(); held = true; cancelled = false; observedDrag = false } + mutating func drag() { if held { observedDrag = true } } + mutating func cancel() { if held { cancelled = true } } + mutating func end() { held = false } +} + +struct WindowDragGeometry { + let initialFrame: CGRect? + let currentFrame: CGRect + + init?(initialFrame: CGRect?, initialServerFrame: CGRect?, serverFrame: CGRect?, accessibilityFrame: () -> CGRect?) { + // Compare frames from the same source; AX and WindowServer can disagree during a drag. + if let initialServerFrame, !initialServerFrame.isNull, !initialServerFrame.isEmpty, + let serverFrame, !serverFrame.isNull, !serverFrame.isEmpty { + self.initialFrame = initialServerFrame + currentFrame = serverFrame + } else { + guard let frame = accessibilityFrame(), !frame.isNull else { return nil } + self.initialFrame = initialFrame + currentFrame = frame + } + } + + var isResizing: Bool { + guard let initialFrame else { return true } + return currentFrame.size != initialFrame.size && currentFrame.numSharedEdges(withRect: initialFrame) >= 2 + } + + var isMoving: Bool { + guard let initialFrame else { return false } + return !isResizing && currentFrame.origin != initialFrame.origin + } + + var movedWithoutResizing: Bool { + initialFrame?.size == currentFrame.size && initialFrame?.origin != currentFrame.origin + } + + static func releasedMovement(initialAX: CGRect, initialServer: CGRect, ax: CGRect, server: CGRect?) -> Bool { + let serverGeometry = WindowDragGeometry(initialFrame: initialAX, initialServerFrame: initialServer, + serverFrame: server, accessibilityFrame: { nil }) + if serverGeometry?.movedWithoutResizing == true { return true } + // The two mouse-down reads may straddle the first native movement. + // AX catching up still proves movement against its own baseline, but + // a persistent difference between coordinate sources does not. + guard let server, WindowAnimationGeometry.near(ax, server, tolerance: 1), + !WindowAnimationGeometry.near(initialAX, ax, tolerance: 1) else { return false } + return WindowDragGeometry(initialFrame: initialAX, initialServerFrame: nil, serverFrame: nil, + accessibilityFrame: { ax })?.movedWithoutResizing == true + } +} + +enum DragRestorePlacement { + static func referenceCursor(current: CGRect, initial: CGRect?, mouseDown: CGPoint?, fallback: CGPoint) -> CGPoint { + guard let initial, let mouseDown else { return fallback } + return CGPoint(x: current.minX + mouseDown.x - initial.minX, + y: current.minY + mouseDown.y - initial.minY) + } + + static func frame(from current: CGRect, size: CGSize, cursor: CGPoint?) -> CGRect { + var restored = CGRect(origin: current.origin, size: size) + if let cursor { + // Move only as far as the grab point needs. Keeping the old right edge would + // abruptly shift by the entire width difference when the cursor crosses the cutoff. + let inset = min(32, size.width / 2) + let neededShift = cursor.x - current.minX - (size.width - inset) + restored.origin.x += min(max(0, neededShift), max(0, current.width - size.width)) + } + return restored + } +} + class SnappingManager { private let fullIgnoreIds: [String] = Defaults.fullIgnoreBundleIds.typedValue ?? ["com.install4j", @@ -26,6 +103,17 @@ class SnappingManager { var isFullScreen: Bool = false var allowListening: Bool = true var initialWindowRect: CGRect? + private var initialWindowServerRect: CGRect? + private var initialCursorLocation: CGPoint? + private var initialEventWindowID: CGWindowID? + private var nativeGesture = NativeSnapGesture() + private var pendingReleasedRestore: UUID? + private struct NativeSizeRestore { + let windowID: CGWindowID + let size: CGSize + var lastAttemptOrigin: CGPoint + } + private var nativeSizeRestore: NativeSizeRestore? var currentSnapArea: SnapArea? var dragPrevY: Double? var dragRestrictionExpirationTimestamp: UInt64 = 0 @@ -145,12 +233,13 @@ class SnappingManager { } private func startEventMonitor() { - let mask: NSEvent.EventTypeMask = [.leftMouseDown, .leftMouseUp, .leftMouseDragged] + let mask: NSEvent.EventTypeMask = [.leftMouseDown, .leftMouseUp, .leftMouseDragged, .keyDown] eventMonitor = Defaults.missionControlDragging.userDisabled ? ActiveEventMonitor(mask: mask, filterer: filter, handler: handle) : PassiveEventMonitor(mask: mask, handler: handle) eventMonitor?.start() } private func stopEventMonitor() { + pendingReleasedRestore = nil eventMonitor?.stop() eventMonitor = nil } @@ -194,31 +283,56 @@ class SnappingManager { func handle(event: NSEvent) { switch event.type { + case .keyDown: + guard event.keyCode == 53, nativeGesture.held else { return } + nativeGesture.cancel() + currentSnapArea = nil + box?.orderOut(nil) case .leftMouseDown: + beginNativeDrag() + WindowAnimator.shared.finishForNewDrag() + initialCursorLocation = event.cgEvent?.location + // The main queue can handle this after the pointer and window have + // already moved. Keep the event's original target for all retries. + initialEventWindowID = event.cgEvent.flatMap { + let id = $0.getIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent) + return id > 0 ? CGWindowID(exactly: id) : nil + } if !Defaults.obtainWindowOnClick.userDisabled { - windowElement = AccessibilityElement.getWindowElementUnderCursor() + windowElement = AccessibilityElement.getWindowElementUnderCursor(at: initialCursorLocation, eventWindowID: initialEventWindowID) windowId = windowElement?.getWindowId() initialWindowRect = windowElement?.frame + initialWindowServerRect = windowId.flatMap { WindowUtil.getWindowFrame(id: $0) } } + traceNativeInput(event, phase: "down") case .leftMouseUp: + nativeGesture.end() + traceNativeInput(event, phase: "up") + if windowMoving, currentSnapArea != nil { WindowAnimator.shared.finish() } + if currentSnapArea != nil { + // A coalesced final drag can leave the preview on the previous screen. + // Commit the release event's position, including withdrawal from an edge. + currentSnapArea = snapAreaForNativeRelease(event) + if currentSnapArea == nil { box?.orderOut(nil) } + } if let currentSnapArea = self.currentSnapArea { - box?.orderOut(nil) + nativeSizeRestore = nil + dismissSnapPreviewForCommit() currentSnapArea.action.postSnap(windowElement: windowElement, windowId: windowId, screen: currentSnapArea.screen) self.currentSnapArea = nil } else { // it's possible that the window has moved, but the mouse dragged events are not getting the updated window position // this typically only happens if the user is dragging and dropping windows really quickly // in this scenario, the footprint doesn't display but the snap will still occur, as long as the window position is updated as of mouse up. - if let currentRect = windowElement?.frame, - currentRect.size == initialWindowRect?.size, - currentRect.origin != initialWindowRect?.origin { + if !nativeGesture.cancelled, let geometry = dragGeometry(), geometry.movedWithoutResizing { - if let windowId { - unsnapRestore(windowId: windowId, currentRect: currentRect, cursorLoc: event.cgEvent?.location) + // Displayed bounds may still have the old size just after finish(). + if !windowMoving, let windowId { + unsnapRestore(windowId: windowId, currentRect: geometry.currentFrame, cursorLoc: event.cgEvent?.location) } - if let snapArea = snapAreaContainingCursor(priorSnapArea: currentSnapArea) { - box?.orderOut(nil) + if let snapArea = snapAreaContainingCursor(priorSnapArea: currentSnapArea, event: event) { + dismissSnapPreviewForCommit() if canSnap(event) { snapArea.action.postSnap(windowElement: windowElement, windowId: windowId, screen: snapArea.screen) } @@ -226,13 +340,20 @@ class SnappingManager { } } } + finishNativeSizeRestore() windowElement = nil windowId = nil windowMoving = false initialWindowRect = nil + initialWindowServerRect = nil + initialCursorLocation = nil + initialEventWindowID = nil windowIdAttempt = 0 lastWindowIdAttempt = nil case .leftMouseDragged: + nativeGesture.drag() + + if nativeGesture.cancelled { return } if windowId == nil, windowIdAttempt < 20 { if let lastWindowIdAttempt = lastWindowIdAttempt { if event.timestamp - lastWindowIdAttempt < 0.1 { @@ -240,30 +361,30 @@ class SnappingManager { } } if windowElement == nil { - windowElement = AccessibilityElement.getWindowElementUnderCursor() + windowElement = AccessibilityElement.getWindowElementUnderCursor(at: initialCursorLocation, eventWindowID: initialEventWindowID) } windowId = windowElement?.getWindowId() initialWindowRect = windowElement?.frame + initialWindowServerRect = windowId.flatMap { WindowUtil.getWindowFrame(id: $0) } windowIdAttempt += 1 lastWindowIdAttempt = event.timestamp } - guard let currentRect = windowElement?.frame - else { return } - + var currentRect: CGRect? if !windowMoving { - if let initialWindowRect, (currentRect.size == initialWindowRect.size || currentRect.numSharedEdges(withRect: initialWindowRect) < 2) { - if currentRect.origin != initialWindowRect.origin { - windowMoving = true - if let windowId { - unsnapRestore(windowId: windowId, currentRect: currentRect, cursorLoc: event.cgEvent?.location) - } + guard let geometry = dragGeometry() else { return } + currentRect = geometry.currentFrame + if geometry.isMoving { + windowMoving = true + if let windowId { + unsnapRestore(windowId: windowId, currentRect: geometry.currentFrame, cursorLoc: event.cgEvent?.location) } } - else if let windowId { + else if geometry.isResizing, let windowId { AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) } } if windowMoving { + retryNativeSizeRestore(cursor: event.cgEvent?.location) if !canSnap(event) { if currentSnapArea != nil { box?.orderOut(nil) @@ -272,10 +393,12 @@ class SnappingManager { return } - if let snapArea = snapAreaContainingCursor(priorSnapArea: currentSnapArea) { + if let snapArea = snapAreaContainingCursor(priorSnapArea: currentSnapArea, event: event) { if snapArea == currentSnapArea { return } + + guard let currentRect = currentRect ?? dragGeometry()?.currentFrame else { return } if Defaults.hapticFeedbackOnSnap.userEnabled { NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) @@ -284,24 +407,7 @@ class SnappingManager { let currentWindow = Window(id: windowId, rect: currentRect) if let newBoxRect = getBoxRect(hotSpot: snapArea, currentWindow: currentWindow) { - if box == nil { - box = FootprintWindow() - } - if Defaults.footprintAnimationDurationMultiplier.value > 0 { - if !box!.realIsVisible, let origin = getFootprintAnimationOrigin(snapArea, newBoxRect) { - let frame = CGRect(origin: origin, size: .zero) - box!.setFrame(frame, display: false) - } - } else { - box!.setFrame(newBoxRect, display: true) - } - box!.orderFront(nil) - if Defaults.footprintAnimationDurationMultiplier.value > 0 { - NSAnimationContext.runAnimationGroup { changes in - changes.duration = getFootprintAnimationDuration(box!, newBoxRect) - box!.animator().setFrame(newBoxRect, display: true) - } - } + showSnapPreview(in: newBoxRect, snapArea: snapArea) } currentSnapArea = snapArea @@ -317,6 +423,30 @@ class SnappingManager { } } + private func dragGeometry() -> WindowDragGeometry? { + WindowDragGeometry(initialFrame: initialWindowRect, initialServerFrame: initialWindowServerRect, + serverFrame: windowId.flatMap { WindowUtil.getWindowFrame(id: $0) }, + accessibilityFrame: { windowElement?.frame }) + } + + private func traceNativeInput(_ event: NSEvent, phase: String) { + guard WindowAnimationDiagnostics.enabled else { return } + func fields(_ frame: CGRect?) -> [CGFloat] { + guard let frame else { return [] } + return [frame.minX, frame.minY, frame.width, frame.height] + } + WindowAnimationDiagnostics.event("native-input-" + phase, fields: ["windowID": windowId ?? 0, + "initialAX": fields(initialWindowRect), "initialServer": fields(initialWindowServerRect), + "history": fields(windowId.flatMap { AppDelegate.windowHistory.lastRectangleActions[$0]?.rect }), + "moving": windowMoving, "dragged": nativeGesture.observedDrag, "cancelled": nativeGesture.cancelled, + "targetPID": event.cgEvent?.getIntegerValueField(.eventTargetUnixProcessID) ?? 0, + "pointerWindow": event.cgEvent?.getIntegerValueField(.mouseEventWindowUnderMousePointer) ?? 0, + "handlingWindow": event.cgEvent?.getIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent) ?? 0, + "eventTimestamp": event.cgEvent?.timestamp ?? 0, + "eventCursor": event.cgEvent.map { [$0.location.x, $0.location.y] } ?? [], + "globalCursor": [NSEvent.mouseLocation.screenFlipped.x, NSEvent.mouseLocation.screenFlipped.y]]) + } + func unsnapRestore(windowId: CGWindowID, currentRect: CGRect, cursorLoc: CGPoint?) { guard !Defaults.unsnapRestore.userDisabled else { return } guard !isRestoreSuppressedBySizeChange(windowId: windowId) else { return } @@ -326,18 +456,25 @@ class SnappingManager { if let windowElement = windowElement { if #available(macOS 12, *) { // earlier versions of macOS would stutter the reposition when dragging the window - var newRect = currentRect - newRect.size = restoreRect.size - if let cursorLoc = cursorLoc { - if !newRect.contains(cursorLoc) { // keep the same maxX if possible - newRect.origin = CGPoint(x: currentRect.maxX - newRect.width, y: newRect.minY) - - if !newRect.contains(cursorLoc) { // still doesn't contain cursor - newRect.origin = CGPoint(x: cursorLoc.x - (newRect.width / 2), y: newRect.minY) - } - } + // Pair the displayed frame with its native grab point, not a newer mouse sample. + let initialCursor = DragRestorePlacement.referenceCursor(current: currentRect, initial: initialWindowRect, + mouseDown: initialCursorLocation, + fallback: cursorLoc ?? NSEvent.mouseLocation.screenFlipped) + let newRect = DragRestorePlacement.frame(from: currentRect, size: restoreRect.size, cursor: initialCursor) + // Native drag restoration applies the saved size immediately. + windowElement.setFrame(newRect, adjustSizeFirst: false, + adjustPosition: newRect.origin != currentRect.origin) + // AX can report success while the Dock limits only the width. + // Keep the unachieved size after consuming snap history so the + // native drag can make room for it on a later event. + let achieved = windowElement.frame + if needsNativeSizeRestore(achieved.size, to: restoreRect.size) { + nativeSizeRestore = NativeSizeRestore(windowID: windowId, size: restoreRect.size, + lastAttemptOrigin: currentRect.origin) } - windowElement.setFrame(newRect, adjustSizeFirst: false) + WindowAnimationDiagnostics.event("native-size-restore", fields: ["windowID": windowId, + "requested": [restoreRect.width, restoreRect.height], + "achieved": [achieved.width, achieved.height], "pending": nativeSizeRestore != nil]) } else { windowElement.size = restoreRect.size } @@ -348,17 +485,106 @@ class SnappingManager { AppDelegate.windowHistory.restoreRects[windowId] = initialWindowRect } } + + private func needsNativeSizeRestore(_ actual: CGSize, to requested: CGSize) -> Bool { + // Native tracking can return a size larger as well as smaller than the + // saved frame. Keep either mismatch pending for the existing retry path. + abs(actual.width - requested.width) > 1 || abs(actual.height - requested.height) > 1 + } + + private func retryNativeSizeRestore(cursor: CGPoint?) { + guard var pending = nativeSizeRestore, pending.windowID == windowId, + let windowElement, !Defaults.unsnapRestore.userDisabled else { return } + let current = windowElement.frame + guard WindowAnimationGeometry.valid(current) else { return } + guard needsNativeSizeRestore(current.size, to: pending.size) else { + nativeSizeRestore = nil + return + } + guard current.origin != pending.lastAttemptOrigin else { return } + let point = cursor ?? current.centerPoint + if let screen = NSScreen.screens.first(where: { $0.frame.screenFlipped.contains(point) }) { + let bounds = screen.visibleFrame.screenFlipped + // Do not repeatedly ask for growth that still cannot fit at the + // native drag's current origin. Position remains owned by the app. + if current.width < pending.size.width - 1 && current.minX + pending.size.width > bounds.maxX { return } + if current.height < pending.size.height - 1 && current.minY + pending.size.height > bounds.maxY { return } + } + pending.lastAttemptOrigin = current.origin + nativeSizeRestore = pending + windowElement.setFrame(CGRect(origin: current.origin, size: pending.size), + adjustSizeFirst: false, adjustPosition: false) + let achieved = windowElement.frame + if !needsNativeSizeRestore(achieved.size, to: pending.size) { nativeSizeRestore = nil } + WindowAnimationDiagnostics.event("native-size-restore-retry", fields: ["windowID": pending.windowID, + "requested": [pending.size.width, pending.size.height], + "achieved": [achieved.width, achieved.height], "pending": nativeSizeRestore != nil]) + } + + private func finishNativeSizeRestore() { + guard let pending = nativeSizeRestore, let element = windowElement, + AppDelegate.windowHistory.lastRectangleActions[pending.windowID] == nil else { + nativeSizeRestore = nil + return + } + nativeSizeRestore = nil + let operation = UUID() + pendingReleasedRestore = operation + // Wait until mouse-up has left native tracking before moving a short + // release far enough inside its display to accept the full saved size. + DispatchQueue.main.async { [weak self] in + guard let self, self.pendingReleasedRestore == operation, !self.nativeGesture.held, + !Defaults.unsnapRestore.userDisabled, + AppDelegate.windowHistory.lastRectangleActions[pending.windowID] == nil else { return } + self.pendingReleasedRestore = nil + let current = element.frame + guard WindowAnimationGeometry.valid(current), self.needsNativeSizeRestore(current.size, to: pending.size) else { return } + var target = CGRect(origin: current.origin, size: pending.size) + if let screen = NSScreen.screens.first(where: { $0.frame.screenFlipped.contains(current.origin) }) { + target = WindowFrameBounds.constrained(target, to: screen.visibleFrame.screenFlipped, gap: 0) + } + element.setFrame(target, adjustSizeFirst: false) + let achieved = element.frame + WindowAnimationDiagnostics.event("native-size-restore-released", fields: ["windowID": pending.windowID, + "requested": [pending.size.width, pending.size.height], + "achieved": [achieved.width, achieved.height]]) + } + } + + @discardableResult + func beginNativeDrag() -> UUID { + pendingReleasedRestore = nil + nativeGesture.begin() + resetNativeDragState() + return nativeGesture.generation + } + + private func resetNativeDragState() { + nativeSizeRestore = nil + if box?.realIsVisible == true { box?.orderOut(nil) } + currentSnapArea = nil + windowElement = nil + windowId = nil + windowMoving = false + initialWindowRect = nil + initialWindowServerRect = nil + initialCursorLocation = nil + initialEventWindowID = nil + windowIdAttempt = 0 + lastWindowIdAttempt = nil + } private func isRestoreSuppressedBySizeChange(windowId: CGWindowID) -> Bool { guard Defaults.unsnapRestoreFromSizeChange.userDisabled, let lastAction = AppDelegate.windowHistory.lastRectangleActions[windowId], lastAction.rect == initialWindowRect else { return false } - + return lastAction.action.category == .size } - + private func getRestoreRect(windowId: CGWindowID) -> CGRect? { + guard !isRestoreSuppressedBySizeChange(windowId: windowId) else { return nil } guard let lastAction = AppDelegate.windowHistory.lastRectangleActions[windowId], lastAction.rect == initialWindowRect else { return nil } @@ -366,8 +592,24 @@ class SnappingManager { return AppDelegate.windowHistory.restoreRects[windowId] } - func getFootprintAnimationDuration(_ box: FootprintWindow, _ boxRect: CGRect) -> Double { - return box.animationResizeTime(boxRect) * Double(Defaults.footprintAnimationDurationMultiplier.value) + private func dismissSnapPreviewForCommit() { + box?.orderOut(nil) + } + + private func showSnapPreview(in rect: CGRect, snapArea: SnapArea) { + // A transient window can retain its old display's Space. Construct it + // with a real frame on the destination display before first ordering. + if box == nil || box?.frame.isEmpty == true || box?.screen != snapArea.screen { + box?.close() + box = FootprintWindow(initialFrame: rect) + } + box?.showPreview(in: rect, from: getFootprintAnimationOrigin(snapArea, rect), + duration: getFootprintAnimationDuration()) + } + + func getFootprintAnimationDuration() -> Double { + // The checkbox uses 0.75; normalize it to the shared preview duration. + return WindowPreviewDeceleration.duration * Double(Defaults.footprintAnimationDurationMultiplier.value) / 0.75 } func getFootprintAnimationOrigin(_ snapArea: SnapArea, _ boxRect: CGRect) -> CGPoint? { @@ -413,9 +655,23 @@ class SnappingManager { return nil } + func snapAreaForNativeRelease(_ event: NSEvent) -> SnapArea? { + guard !nativeGesture.cancelled, canSnap(event) else { return nil } + return snapAreaContainingCursor(priorSnapArea: currentSnapArea, event: event) + } + + func snapAreaContainingCursor(priorSnapArea: SnapArea?, event: NSEvent) -> SnapArea? { + guard let location = event.cgEvent?.location else { + return snapAreaContainingCursor(priorSnapArea: priorSnapArea) + } + return snapAreaContainingCursor(priorSnapArea: priorSnapArea, at: location.screenFlipped) + } + func snapAreaContainingCursor(priorSnapArea: SnapArea?) -> SnapArea? { - let loc = NSEvent.mouseLocation - + snapAreaContainingCursor(priorSnapArea: priorSnapArea, at: NSEvent.mouseLocation) + } + + func snapAreaContainingCursor(priorSnapArea: SnapArea?, at loc: CGPoint) -> SnapArea? { for screen in NSScreen.screens { guard let directional = directionalLocationOfCursor(loc: loc, screen: screen) else { continue } diff --git a/Rectangle/TitleBarManager.swift b/Rectangle/TitleBarManager.swift index fc76e2970..cab7e57cf 100644 --- a/Rectangle/TitleBarManager.swift +++ b/Rectangle/TitleBarManager.swift @@ -40,6 +40,7 @@ class TitleBarManager { return } lastEventNumber = event.eventNumber + let isTitleBarSpacer = element.isTitleBarSpacer(in: titleBarFrame) var bundleIdentifier: String? if let pid = element.pid { @@ -57,7 +58,7 @@ class TitleBarManager { } guard titleBarFrame.contains(location), - element.isWindow == true || element.isToolbar == true || element.isGroup == true || element.isTabGroup == true || element.isStaticText == true + element.isWindow == true || element.isToolbar == true || element.isGroup == true || element.isTabGroup == true || element.isStaticText == true || isTitleBarSpacer else { return } @@ -66,17 +67,24 @@ class TitleBarManager { ignoredApps.contains(bundleIdentifier) { return } - if Defaults.doubleClickTitleBarRestore.enabled != false, - let windowId = windowElement.windowId, - case let windowFrame = windowElement.frame, - windowFrame != .null, - let historyAction = AppDelegate.windowHistory.lastRectangleActions[windowId], - historyAction.action == action, - historyAction.rect == windowFrame { - WindowAction.restore.postTitleBar(windowElement: windowElement) - return - } - action.postTitleBar(windowElement: windowElement) + let historyAction = windowElement.windowId.flatMap { AppDelegate.windowHistory.lastRectangleActions[$0] } + let resolvedAction = Self.resolveAction(action, + restoreEnabled: Defaults.doubleClickTitleBarRestore.enabled != false, + windowFrame: windowElement.frame, + pendingFrame: WindowAnimator.shared.logicalFrame(for: windowElement), + lastAction: historyAction) + resolvedAction.postTitleBar(windowElement: windowElement) + } + + static func resolveAction(_ action: WindowAction, restoreEnabled: Bool, + windowFrame: CGRect, pendingFrame: CGRect?, + lastAction: RectangleAction?) -> WindowAction { + // During an animation the real window may not have reached its target. + let frame = pendingFrame ?? windowFrame + guard restoreEnabled, frame != .null, + let lastAction, lastAction.action == action, lastAction.rect == frame + else { return action } + return .restore } } diff --git a/Rectangle/Utilities/NotificationExtension.swift b/Rectangle/Utilities/NotificationExtension.swift index 92c8eb435..99b0c4a04 100644 --- a/Rectangle/Utilities/NotificationExtension.swift +++ b/Rectangle/Utilities/NotificationExtension.swift @@ -5,6 +5,8 @@ import Cocoa extension Notification.Name { static let configImported = Notification.Name("configImported") + static let windowAnimationPreferencesChanged = Notification.Name("windowAnimationPreferencesChanged") + static let windowActionCompleted = Notification.Name("windowActionCompleted") static let windowSnapping = Notification.Name("windowSnapping") static let frontAppChanged = Notification.Name("frontAppChanged") static let allowAnyShortcut = Notification.Name("allowAnyShortcutToggle") diff --git a/Rectangle/Utilities/WindowUtil.swift b/Rectangle/Utilities/WindowUtil.swift index ff5057384..df2456002 100644 --- a/Rectangle/Utilities/WindowUtil.swift +++ b/Rectangle/Utilities/WindowUtil.swift @@ -4,6 +4,16 @@ import Foundation class WindowUtil { private static var windowListCache = TimeoutCache<[CGWindowID]?, [WindowInfo]>(timeout: 100) + + /// Drag tracking needs current geometry rather than the window list's 100 ms cache. + static func getWindowFrame(id: CGWindowID) -> CGRect? { + guard let infos = CGWindowListCopyWindowInfo(.optionIncludingWindow, id) as? [[String: Any]], + let info = infos.first(where: { ($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == id }), + let bounds = info[kCGWindowBounds as String] as? [String: Any], + let frame = CGRect(dictionaryRepresentation: bounds as CFDictionary), + !frame.isNull, !frame.isEmpty else { return nil } + return frame + } static func getWindowList(ids: [CGWindowID]? = nil, all: Bool = false, forceRefresh: Bool = false) -> [WindowInfo] { if !forceRefresh, let infos = windowListCache[ids] { @@ -13,6 +23,7 @@ class WindowUtil { var rawInfos: CFArray? if let ids { let values = UnsafeMutablePointer.allocate(capacity: ids.count) + defer { values.deallocate() } for (i, id) in ids.enumerated() { values[i] = UnsafeRawPointer(bitPattern: UInt(id)) } @@ -40,7 +51,8 @@ class WindowUtil { if let rawProcessName { processName = String(rawProcessName) } - let info = WindowInfo(id: id, level: level, frame: frame, pid: pid, processName: processName) + let alpha = (rawInfo.getValue(kCGWindowAlpha) as CFNumber?).map { CGFloat(truncating: $0) } ?? 1 + let info = WindowInfo(id: id, level: level, frame: frame, pid: pid, processName: processName, alpha: alpha) infos.append(info) } } @@ -55,4 +67,5 @@ struct WindowInfo { let frame: CGRect let pid: pid_t let processName: String? + var alpha: CGFloat = 1 } diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index 3de8107ea..fc0dc0159 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -7,11 +7,15 @@ class WindowManager { private let screenDetection: ScreenDetection private let standardWindowMoverChain: [WindowMover] private let fixedSizeWindowMoverChain: [WindowMover] + private let windowAnimator: WindowAnimator private var windowSizeWarning: WindowSizeWarning? private var executionID = 0 - init(screenDetection: ScreenDetection = ScreenDetection()) { + init(screenDetection: ScreenDetection = ScreenDetection(), + windowAnimator: WindowAnimator = WindowAnimator.shared) { + self.screenDetection = screenDetection + self.windowAnimator = windowAnimator standardWindowMoverChain = [ StandardWindowMover(), EdgeAlignmentWindowMover(), @@ -24,6 +28,10 @@ class WindowManager { ] } + func logicalFrame(for element: AccessibilityElement) -> CGRect { + windowAnimator.destination(for: element) ?? element.frame + } + func recordAction(windowId: CGWindowID?, resultingRect: CGRect, action: WindowAction, @@ -47,8 +55,6 @@ class WindowManager { } func execute(_ parameters: ExecutionParameters) { - executionID &+= 1 - let currentExecutionID = executionID hideSizeConstraintWarning() guard let frontmostWindowElement = parameters.windowElement ?? AccessibilityElement.getFrontWindowElement() @@ -56,7 +62,7 @@ class WindowManager { NSSound.beep() return } - + // The window id can be unavailable when macOS stops vending window info // after a session transition (#640). Actions still execute; only // window-id-keyed history is skipped. @@ -70,7 +76,18 @@ class WindowManager { return } if let restoreRect = AppDelegate.windowHistory.restoreRects[windowId] { - frontmostWindowElement.setFrame(restoreRect) + executionID &+= 1 + let currentExecutionID = executionID + if WindowAnimator.enabled, frontmostWindowElement.isResizable() { + windowAnimator.animate(frontmostWindowElement, to: restoreRect, profile: parameters.source == .keyboardShortcut ? .keyboard : .standard) { [weak self] frame in + guard let self, self.executionID == currentExecutionID else { return } + // A completed animation has already placed the real window. + if frame.isNull { frontmostWindowElement.setFrame(restoreRect) } + } + } else { + WindowAnimator.shared.cancel(for: frontmostWindowElement) + frontmostWindowElement.setFrame(restoreRect) + } } AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) return @@ -94,7 +111,8 @@ class WindowManager { return } - let currentWindowRect: CGRect = frontmostWindowElement.frame + let pendingDestination = windowAnimator.destination(for: frontmostWindowElement) + let currentWindowRect = pendingDestination ?? frontmostWindowElement.frame var lastRectangleAction = windowId.flatMap { AppDelegate.windowHistory.lastRectangleActions[$0] } @@ -176,7 +194,7 @@ class WindowManager { } if let cooperativeCornerPlan { - if !cooperativeCornerPlan.needsApplication(focusedCurrentFrame: currentNormalizedRect) { + if pendingDestination == nil && !cooperativeCornerPlan.needsApplication(focusedCurrentFrame: currentNormalizedRect) { ActiveSideSplitRatios.shared.recordAchievedCooperativeAction(cooperativeCornerPlan.action, achievedFrame: currentNormalizedRect, screenFrame: cooperativeCornerPlan.screenFrame, @@ -185,7 +203,7 @@ class WindowManager { recordAction(windowId: windowId, resultingRect: currentWindowRect, action: calcResult.resultingAction, subAction: calcResult.resultingSubAction) return } - } else if currentNormalizedRect.equalTo(calcResult.rect) { + } else if pendingDestination == nil && currentNormalizedRect.equalTo(calcResult.rect) { Logger.log("Current frame is equal to new frame") recordAction(windowId: windowId, resultingRect: currentWindowRect, action: calcResult.resultingAction, subAction: calcResult.resultingSubAction) @@ -193,6 +211,12 @@ class WindowManager { return } + // Only an accepted move supersedes the prior completion. A rejected or + // already-achieved request must not discard an animation's needed fallback. + // A matching logical target is still pending, so it continues through here. + executionID &+= 1 + let currentExecutionID = executionID + let resultParameters = ResultParameters(windowId: windowId, action: action, windowElement: frontmostWindowElement, @@ -202,53 +226,82 @@ class WindowManager { source: parameters.source, isFixedSize: isFixedSize) - var resultingRect: CGRect - if let cooperativeCornerPlan { - resultingRect = applyCooperativeCornerResize(result: resultParameters, - plan: cooperativeCornerPlan) - } else { - resultingRect = apply(result: resultParameters) - } - - if let cooperativeCornerPlan { - // AX can enforce a minimum size that was not reported before the settling pass. - ActiveSideSplitRatios.shared.recordAchievedCooperativeAction(cooperativeCornerPlan.action, - achievedFrame: resultingRect.screenFlipped, - screenFrame: cooperativeCornerPlan.screenFrame, - gapSize: cooperativeCornerPlan.gapSize) - } + let animated = WindowAnimator.enabled && !isFixedSize + && (!isMovedAcrossDisplays || parameters.source == .dragToSnap) + && !Defaults.cooperativeCornerResize.enabled - if isMovedAcrossDisplays { - if calcResult.rect.size != resultingRect.size { - Logger.log("Window size wasn't applied perfectly across displays. Trying again.") + let completeMove = { [self] (animationHandledPlacement: Bool) in + guard executionID == currentExecutionID else { return } + var resultingRect: CGRect + if let cooperativeCornerPlan { + resultingRect = applyCooperativeCornerResize(result: resultParameters, + plan: cooperativeCornerPlan) + } else if animationHandledPlacement { + resultingRect = frontmostWindowElement.frame + } else { resultingRect = apply(result: resultParameters) - + } + + if let cooperativeCornerPlan { + // AX can enforce a minimum size that was not reported before the settling pass. + ActiveSideSplitRatios.shared.recordAchievedCooperativeAction(cooperativeCornerPlan.action, + achievedFrame: resultingRect.screenFlipped, + screenFrame: cooperativeCornerPlan.screenFrame, + gapSize: cooperativeCornerPlan.gapSize) + } + + if isMovedAcrossDisplays { if calcResult.rect.size != resultingRect.size { - Logger.log("Final attempt to adjust across displays.") - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) { [weak self] in - guard let self, self.executionID == currentExecutionID else { return } - let finalRect = self.apply(result: resultParameters) - self.windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: finalRect) - self.postProcess(result: resultParameters, resultingRect: finalRect) + Logger.log("Window size wasn't applied perfectly across displays. Trying again.") + resultingRect = apply(result: resultParameters) + + if calcResult.rect.size != resultingRect.size { + Logger.log("Final attempt to adjust across displays.") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) { [weak self] in + guard let self, self.executionID == currentExecutionID else { return } + let finalRect = self.apply(result: resultParameters) + self.windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: finalRect) + self.postProcess(result: resultParameters, resultingRect: finalRect, incrementCount: !animated) + } + return } - return } + windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: resultingRect) + } + + if !isMovedAcrossDisplays { + applyCooperativeCornerCleanupIfNeeded(focusedWindowId: windowId, + source: parameters.source, + oldFocusedFrame: currentNormalizedRect, + newFocusedFrame: resultingRect.screenFlipped, + screenFrame: sourceScreens.currentScreen.adjustedVisibleFrame(ignoreTodo), + currentAction: action, + lastRectangleAction: lastRectangleAction) + resultingRect = frontmostWindowElement.frame } - windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: resultingRect) - } - if !isMovedAcrossDisplays { - applyCooperativeCornerCleanupIfNeeded(focusedWindowId: windowId, - source: parameters.source, - oldFocusedFrame: currentNormalizedRect, - newFocusedFrame: resultingRect.screenFlipped, - screenFrame: sourceScreens.currentScreen.adjustedVisibleFrame(ignoreTodo), - currentAction: action, - lastRectangleAction: lastRectangleAction) - resultingRect = frontmostWindowElement.frame + postProcess(result: resultParameters, resultingRect: resultingRect, incrementCount: !animated) } - postProcess(result: resultParameters, resultingRect: resultingRect) + if animated { + recordAction(windowId: windowId, resultingRect: calcResult.rect.screenFlipped, + action: calcResult.resultingAction, subAction: calcResult.resultingSubAction) + let placement = WindowAnimationPlacement( + screenFrame: visibleFrameOfDestinationScreen.screenFlipped, + sharedEdges: action.resizes ? Defaults.moveFixedSizeToEdge.value.alignmentEdges( + for: calcResult.initialRect.screenFlipped, in: visibleFrameOfDestinationScreen.screenFlipped) : nil, + constrainToScreen: !(action.allowedToExtendOutsideCurrentScreenArea && !NSScreen.screensHaveSeparateSpaces), + gap: CGFloat(Defaults.gapSize.value)) + windowAnimator.animate(frontmostWindowElement, + to: calcResult.rect.screenFlipped, + releasedSnap: parameters.source == .dragToSnap, placement: placement, + profile: parameters.source == .keyboardShortcut ? .keyboard : .standard) { frame in + completeMove(!frame.isNull) + } + } else { + windowAnimator.cancel(for: frontmostWindowElement) + completeMove(false) + } } /// Move/resize a window based on the calculation results. @@ -278,7 +331,7 @@ class WindowManager { } } - func postProcess(result: ResultParameters, resultingRect: CGRect) { + func postProcess(result: ResultParameters, resultingRect: CGRect, incrementCount: Bool = true) { let calcResult = result.calcResult if WindowSizeConstraint.isExceeded(requested: calcResult.rect, actual: resultingRect, action: result.action) { @@ -289,7 +342,20 @@ class WindowManager { CGWarpMouseCursorPosition(resultingRect.centerPoint) } - recordAction(windowId: result.windowId, resultingRect: resultingRect, action: calcResult.resultingAction, subAction: calcResult.resultingSubAction) + recordAction(windowId: result.windowId, resultingRect: resultingRect, action: calcResult.resultingAction, subAction: calcResult.resultingSubAction, incrementCount: incrementCount) + + let requestedRect = calcResult.rect.screenFlipped + var evidence: [String: Any] = ["action": calcResult.resultingAction.name, + "achieved": [resultingRect.minX, resultingRect.minY, resultingRect.width, resultingRect.height], + "requested": [requestedRect.minX, requestedRect.minY, requestedRect.width, requestedRect.height]] + if let windowId = result.windowId { + evidence["windowID"] = windowId + if let restore = AppDelegate.windowHistory.restoreRects[windowId] { + evidence["restore"] = [restore.minX, restore.minY, restore.width, restore.height] + } + } + WindowAnimationDiagnostics.event("window-action-achieved", fields: evidence) + Notification.Name.windowActionCompleted.post() if Logger.logging { var logItems = ["\(result.action.name)", diff --git a/Rectangle/WindowMover/BestEffortWindowMover.swift b/Rectangle/WindowMover/BestEffortWindowMover.swift index ef917a010..d505e1c53 100644 --- a/Rectangle/WindowMover/BestEffortWindowMover.swift +++ b/Rectangle/WindowMover/BestEffortWindowMover.swift @@ -2,6 +2,26 @@ import Foundation +enum WindowFrameBounds { + static func constrained(_ frame: CGRect, to screenFrame: CGRect, gap: CGFloat) -> CGRect { + guard !frame.isNull, !frame.isInfinite, !screenFrame.isNull, !screenFrame.isInfinite else { return frame } + var result = frame + if result.minX < screenFrame.minX { + result.origin.x = screenFrame.minX + } else if result.maxX > screenFrame.maxX { + result.origin.x = screenFrame.maxX - result.width - gap + } + + // Window coordinates grow downwards; preserve the normal mover's bottom-first correction. + if result.maxY > screenFrame.maxY { + result.origin.y = screenFrame.maxY - result.height + } else if result.minY < screenFrame.minY { + result.origin.y = screenFrame.minY + gap + } + return result + } +} + /** * After a window has been moved and resized, if the window could not be resized small enough to fit the intended size, then some of the window may appear off the screen. The BestEffortWindowMover will move the window so that it fits entirely on the screen. */ @@ -15,28 +35,9 @@ class BestEffortWindowMover: WindowMover { if action.allowedToExtendOutsideCurrentScreenArea == true && !NSScreen.screensHaveSeparateSpaces { return } - var adjustedWindowRect: CGRect = currentWindowRect - - if adjustedWindowRect.minX < visibleFrameOfScreen.minX { - - adjustedWindowRect.origin.x = visibleFrameOfScreen.minX - - } else if adjustedWindowRect.minX + adjustedWindowRect.width > visibleFrameOfScreen.minX + visibleFrameOfScreen.width { - - adjustedWindowRect.origin.x = visibleFrameOfScreen.minX + visibleFrameOfScreen.width - (adjustedWindowRect.width) - CGFloat(Defaults.gapSize.value) - } - - adjustedWindowRect = adjustedWindowRect.screenFlipped - if adjustedWindowRect.minY < visibleFrameOfScreen.minY { - - adjustedWindowRect.origin.y = visibleFrameOfScreen.minY - - } else if adjustedWindowRect.minY + adjustedWindowRect.height > visibleFrameOfScreen.minY + visibleFrameOfScreen.height { - - adjustedWindowRect.origin.y = visibleFrameOfScreen.minY + visibleFrameOfScreen.height - (adjustedWindowRect.height) - CGFloat(Defaults.gapSize.value) - } - - adjustedWindowRect = adjustedWindowRect.screenFlipped + let adjustedWindowRect = WindowFrameBounds.constrained(currentWindowRect, + to: visibleFrameOfScreen.screenFlipped, + gap: CGFloat(Defaults.gapSize.value)) if !currentWindowRect.equalTo(adjustedWindowRect) { windowElement.setFrame(adjustedWindowRect) } diff --git a/Rectangle/WindowMover/DirectWindowAnimator.swift b/Rectangle/WindowMover/DirectWindowAnimator.swift new file mode 100644 index 000000000..3c30cc3fa --- /dev/null +++ b/Rectangle/WindowMover/DirectWindowAnimator.swift @@ -0,0 +1,633 @@ +/// DirectWindowAnimator.swift + +import Cocoa +import QuartzCore + +/// Animates the actual window through Accessibility frame writes. +final class DirectWindowAnimator { + private struct PendingRelease { + let destination: CGRect + var stability: WindowReleasedSnapStability + let start: (CGRect) -> Void + let fallback: () -> Void + } + + private struct PendingSettlement { + let destination: CGRect + let origin: CGRect + let placement: WindowAnimationPlacement + var stability: WindowAnimationSettlement + let cleanup: () -> Void + var completion: (CGRect) -> Void + } + + private final class KeyboardSession { + var motion: WindowKeyboardMotion + var placement: WindowAnimationPlacement + var completion: (CGRect) -> Void + let cleanup: () -> Void + var previousFrame: CGRect + var lastSampledFrame: CGRect? + var lastWriteTime: TimeInterval + var sizeFeedback = WindowAnimationSizeFeedback() + var recentWrites: [(time: TimeInterval, values: [CGFloat], velocity: [CGFloat])] = [] + + init(origin: CGRect, destination: CGRect, placement: WindowAnimationPlacement, at time: TimeInterval, + cleanup: @escaping () -> Void, completion: @escaping (CGRect) -> Void) { + motion = WindowKeyboardMotion(from: origin, to: destination, at: time) + self.placement = placement + self.cleanup = cleanup + self.completion = completion + previousFrame = origin + lastWriteTime = time + } + } + + private var keyboardSession: KeyboardSession? + private var settlementIsKeyboard = false + private var window: AccessibilityElement? + private var animation: WindowFrameAnimation? + private var pendingRelease: PendingRelease? + private var pendingSettlement: PendingSettlement? + private var timer: Timer? + private var displayLinkCleanup: (() -> Void)? + private var screenObserver: NSObjectProtocol? + private var lastDrivenAt: TimeInterval? + private var drivingInterval: TimeInterval = 1.0 / 60 + private var advancing = false + private var mouseMonitor: Any? + private var intent = UUID() + private let enabled: () -> Bool + private let clock: () -> TimeInterval + private let automaticallyAdvances: Bool + private let environmentIsSafe: () -> Bool + private let serverFrame: (AccessibilityElement) -> CGRect? + private let crossesDisplays: (CGRect, CGRect) -> Bool + private let isNativeResizeApp: (String?) -> Bool + private var lastEnvironmentCheck: TimeInterval = 0 + + init(enabled: @escaping () -> Bool = { WindowAnimator.enabled }, + clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + automaticallyAdvances: Bool = true, + environmentIsSafe: @escaping () -> Bool = { !WindowAnimationInterruptionPolicy.missionControlActive }, + serverFrame: @escaping (AccessibilityElement) -> CGRect? = { element in + element.windowId.flatMap { WindowUtil.getWindowFrame(id: $0) } + }, + crossesDisplays: @escaping (CGRect, CGRect) -> Bool = { WindowAnimator.crossesDisplays(from: $0, to: $1) }, + isNativeResizeApp: @escaping (String?) -> Bool = { id in + guard let id else { return false } + return Defaults.directAnimationNativeResizeApps.typedValue?.contains(id) == true + }) { + self.enabled = enabled + self.clock = clock + self.automaticallyAdvances = automaticallyAdvances + self.environmentIsSafe = environmentIsSafe + self.serverFrame = serverFrame + self.crossesDisplays = crossesDisplays + self.isNativeResizeApp = isNativeResizeApp + } + + func destination(for element: AccessibilityElement) -> CGRect? { + window == element ? (pendingRelease?.destination ?? pendingSettlement?.destination ?? keyboardSession?.motion.destination ?? animation?.destination) : nil + } + + func cancel(for element: AccessibilityElement) { + if window == element { cancel() } + } + + func cancel() { + if let session = keyboardSession { + keyboardSession = nil + window = nil + stopDriving() + session.cleanup() + } + if pendingRelease != nil { clearPendingRelease() } + if pendingSettlement != nil { clearSettlement() } + animation?.cancel() + } + func cancelIfTargetDiffers(from pid: pid_t?) { + if let pid, let targetPID = window?.pid, pid != targetPID { cancel() } + } + func finish() { + if keyboardSession != nil { + finishKeyboard() + if pendingSettlement != nil { completeSettlement(.null) } + return + } + if let pending = pendingRelease { + clearPendingRelease() + pending.fallback() + } else if pendingSettlement != nil { + completeSettlement(.null) + } else { + animation?.finish() + // An explicit finish must not leave a newly created settlement + // attached to the next window's animation. + if pendingSettlement != nil { completeSettlement(.null) } + } + } + + func mouseDown() { + // A new grab supersedes a released snap that has not started writing. + if keyboardSession != nil || pendingRelease != nil || pendingSettlement != nil { cancel() } + else { finish() } + } + + func advance(at time: TimeInterval) { + guard !advancing else { return } + advancing = true + defer { advancing = false } + if time - lastEnvironmentCheck >= 0.1 { + lastEnvironmentCheck = time + guard environmentIsSafe() else { cancel(); return } + } + guard enabled() else { finish(); return } + if var pending = pendingSettlement, let window { + let decision = pending.stability.observe(ax: window.frame, server: serverFrame(window), + destination: pending.destination, placement: pending.placement, origin: pending.origin, at: clock()) + pendingSettlement = pending + switch decision { + case .waiting: break + case .retrySize: + if window.writeAnimationSize(pending.destination.size) != .success { completeSettlement(.null) } + case .retrySizeAt(let position): + // Keep the retry position, size and final alignment in one turn. + // Waiting between them exposes the temporary position at the edge. + guard window.writeAnimationPosition(position) == .success, + window.setConstrainedAnimationFrame(pending.destination, placement: pending.placement, + origin: pending.origin, progress: 1) != nil else { + completeSettlement(.null) + return + } + case .align(let frame): + if window.writeAnimationPosition(frame.origin) != .success { completeSettlement(.null) } + case .complete(let frame): completeSettlement(frame) + case .failed: completeSettlement(.null) + } + return + } + if var pending = pendingRelease, let window { + let ax = window.frame + let server = serverFrame(window) + let decision = pending.stability.observe(ax: ax, server: server, at: time) + if decision == .waiting { + pendingRelease = pending + return + } + clearPendingRelease() + WindowAnimationDiagnostics.event("direct-released-snap-settled", fields: ["windowID": window.windowId ?? 0, + "ready": decision == .ready, "milliseconds": (time - pending.stability.startedAt) * 1000]) + if decision == .ready { pending.start(ax) } + else { pending.fallback() } + return + } + if keyboardSession != nil { advanceKeyboard(at: time) } + else { animation?.tick(at: time) } + } + + func animate(_ element: AccessibilityElement, from startingFrame: CGRect? = nil, to destination: CGRect, + duration: TimeInterval = WindowAnimationCurve.duration, resizeOnly: Bool, + releasedSnap: Bool = false, + placement: WindowAnimationPlacement?, profile: WindowAnimationProfile = .standard, + offset: @escaping () -> CGPoint, + curve: @escaping (Double) -> CGFloat = WindowAnimationCurve.value, completion: @escaping (CGRect) -> Void) { + let generation = UUID() + intent = generation + if profile == .keyboard, !releasedSnap, !resizeOnly, !isNativeResizeApp(element.bundleIdentifier) { + animateKeyboard(element, to: destination, placement: placement, generation: generation, completion: completion) + return + } + if window == element { cancel() } else { finish() } + // Finishing the previous window can synchronously submit a newer request. + guard intent == generation else { return } + if releasedSnap && !resizeOnly { + window = element + lastEnvironmentCheck = clock() + pendingRelease = PendingRelease(destination: destination, + stability: WindowReleasedSnapStability(startedAt: clock()), start: { [weak self] origin in + guard let self, self.intent == generation else { return } + // Native display adjustments can replay old geometry during + // direct motion. Let the caller place a cross-display snap + // normally, without issuing intermediate animation frames. + if self.crossesDisplays(origin, destination) { + WindowAnimationDiagnostics.event("direct-cross-display-snap-immediate", fields: [ + "windowID": element.windowId ?? 0, + "source": [origin.minX, origin.minY, origin.width, origin.height], + "destination": [destination.minX, destination.minY, destination.width, destination.height]]) + completion(.null) + return + } + self.startAnimation(element, from: origin, to: destination, duration: duration, + resizeOnly: resizeOnly, placement: placement, offset: offset, + curve: curve, completion: completion) + }, fallback: { completion(.null) }) + WindowAnimationDiagnostics.event("direct-released-snap-wait", fields: ["windowID": element.windowId ?? 0]) + startDriving() + advance(at: clock()) + return + } + startAnimation(element, from: startingFrame, to: destination, duration: duration, + resizeOnly: resizeOnly, placement: placement, offset: offset, curve: curve, completion: completion) + } + + private func animateKeyboard(_ element: AccessibilityElement, to destination: CGRect, + placement: WindowAnimationPlacement?, generation: UUID, + completion: @escaping (CGRect) -> Void) { + guard enabled(), environmentIsSafe(), element.isFullScreen != true, + WindowAnimationGeometry.valid(destination) else { completion(.null); return } + let placement = placement ?? WindowAnimationPlacement(screenFrame: .zero, sharedEdges: nil, + constrainToScreen: false, gap: 0) + let now = clock() + if window == element, let session = keyboardSession { + session.completion = completion + if session.motion.destination == destination, session.placement == placement { return } + let ax = element.frame + let visible = serverFrame(element).flatMap { WindowAnimationGeometry.valid($0) ? $0 : nil } + // Continue after the latest acknowledged write; replaying a delayed + // compositor frame would move the window back along the old path. + let actual = WindowAnimationGeometry.valid(ax) ? ax : (visible ?? ax) + guard WindowAnimationGeometry.valid(actual) else { cancel(); completion(.null); return } + let observed = [actual.minX, actual.minY, actual.width, actual.height] + let corroborating = visible ?? actual + let accessible = [corroborating.minX, corroborating.minY, corroborating.width, corroborating.height] + // WindowServer may still show an earlier successful AX write. Match + // recent geometry before treating that delay as an external change. + let recent = session.recentWrites.filter { now - $0.time <= 0.06 } + var velocity = [CGFloat](repeating: 0, count: 4) + for index in velocity.indices { + if recent.contains(where: { abs($0.values[index] - accessible[index]) <= 2 }), + let write = recent.last(where: { abs($0.values[index] - observed[index]) <= 2 }) { + velocity[index] = write.velocity[index] + } + } + var driftLimits: [CGFloat] = [2, 2, 2, 2] + if placement.constrainToScreen { + let bounds = placement.screenFrame.insetBy(dx: placement.gap, dy: placement.gap) + driftLimits[0] = min(2, max(0, velocity[0] > 0 ? bounds.maxX - actual.maxX : actual.minX - bounds.minX)) + driftLimits[1] = min(2, max(0, velocity[1] > 0 ? bounds.maxY - actual.maxY : actual.minY - bounds.minY)) + if velocity[2] > 0 { driftLimits[2] = min(2, max(0, bounds.maxX - actual.maxX)) } + if velocity[3] > 0 { driftLimits[3] = min(2, max(0, bounds.maxY - actual.maxY)) } + } + let screenChanged = session.placement.screenFrame != placement.screenFrame + session.motion = WindowKeyboardMotion(from: actual, to: destination, velocity: velocity, at: now, driftLimits: driftLimits) + session.placement = placement + session.previousFrame = actual + session.lastSampledFrame = nil + session.lastWriteTime = now + session.sizeFeedback = WindowAnimationSizeFeedback() + session.recentWrites.removeAll(keepingCapacity: true) + if screenChanged { startDriving() } + WindowAnimationDiagnostics.event("keyboard-animation-retarget", fields: ["windowID": element.windowId ?? 0, + "source": [actual.minX, actual.minY, actual.width, actual.height], + "destination": [destination.minX, destination.minY, destination.width, destination.height], "velocity": velocity]) + return + } + + var restoreAccessibility: (() -> Void)? + var rebindDriver = true + if window == element, settlementIsKeyboard, var pending = pendingSettlement { + if pending.destination == destination, pending.placement == placement { + pending.completion = completion + pendingSettlement = pending + return + } + restoreAccessibility = pending.cleanup + rebindDriver = pending.placement.screenFrame != placement.screenFrame + pendingSettlement = nil + settlementIsKeyboard = false + } else { + if window == element { cancel() } else { finish() } + } + guard intent == generation else { restoreAccessibility?(); return } + let ax = element.frame + let origin = serverFrame(element).flatMap { WindowAnimationGeometry.valid($0) ? $0 : nil } ?? ax + guard WindowAnimationGeometry.valid(origin) else { + restoreAccessibility?() + window = nil + stopDriving() + completion(.null) + return + } + let cleanup = restoreAccessibility ?? element.beginAnimatedAdjustment() + keyboardSession = KeyboardSession(origin: origin, destination: destination, placement: placement, + at: now, cleanup: cleanup, completion: completion) + window = element + lastEnvironmentCheck = now + if rebindDriver { startDriving() } + WindowAnimationDiagnostics.event("keyboard-animation-start", fields: ["windowID": element.windowId ?? 0, + "source": [origin.minX, origin.minY, origin.width, origin.height], + "destination": [destination.minX, destination.minY, destination.width, destination.height]]) + } + + private func advanceKeyboard(at time: TimeInterval) { + guard let session = keyboardSession, let window else { return } + let sample = session.motion.sample(at: time) + if sample.progress >= 1 { finishKeyboard(); return } + let frame = sample.frame + let sampled = CGRect(x: frame.minX.rounded(), y: frame.minY.rounded(), + width: frame.width.rounded(), height: frame.height.rounded()) + if sampled != session.lastSampledFrame || session.previousFrame.size != sampled.size { + var requested = sampled + let predicted = session.sizeFeedback.size(for: sampled.size) + if predicted != sampled.size { + requested = session.placement.frame(for: sampled, actualSize: predicted, + origin: session.motion.origin, progress: sample.progress) + } + let correction = min(1, CGFloat(max(0, time - session.lastWriteTime)) * 60) + if let achieved = window.setConstrainedAnimationFrame(requested, placement: session.placement, + origin: session.motion.origin, progress: sample.progress, previousFrame: session.previousFrame, + maximumCorrection: correction) { + let values = [achieved.minX, achieved.minY, achieved.width, achieved.height] + let requestedValues = [sampled.minX, sampled.minY, sampled.width, sampled.height] + let velocity = sample.velocity.enumerated().map { index, value in + abs(values[index] - requestedValues[index]) <= 2 ? value : 0 + } + session.recentWrites.removeAll { time - $0.time > 0.06 } + session.recentWrites.append((time, values, velocity)) + session.previousFrame = achieved + session.lastSampledFrame = sampled + session.lastWriteTime = time + if achieved.width < sampled.width - 1 || achieved.height < sampled.height - 1 { + session.sizeFeedback.observe(requested: sampled.size, actual: window.frame, server: serverFrame(window), at: time) + } else { + session.sizeFeedback = WindowAnimationSizeFeedback() + } + } + } + if sampled == session.motion.destination, + WindowAnimationGeometry.near(window.frame, sampled, tolerance: 0.001), + serverFrame(window).map({ WindowAnimationGeometry.near($0, sampled, tolerance: 0.001) }) == true { + finishKeyboard() + } + } + + private func finishKeyboard() { + guard let session = keyboardSession, let window else { return } + keyboardSession = nil + let destination = session.motion.destination + let actual = window.frame + guard (WindowAnimationGeometry.valid(actual) && actual.size == destination.size) + || window.writeAnimationSize(destination.size) == .success else { + self.window = nil + stopDriving() + session.cleanup() + session.completion(.null) + return + } + let resized = window.frame + if WindowAnimationGeometry.valid(resized), resized.size == destination.size, resized.origin != destination.origin { + guard window.writeAnimationPosition(destination.origin) == .success else { + self.window = nil + stopDriving() + session.cleanup() + session.completion(.null) + return + } + } + let placed = window.frame + let verified = WindowAnimationGeometry.near(placed, destination, tolerance: 0.001) + && serverFrame(window).map { WindowAnimationGeometry.near($0, destination, tolerance: 0.001) } == true + pendingSettlement = PendingSettlement(destination: destination, origin: session.motion.origin, + placement: session.placement, stability: WindowAnimationSettlement(startedAt: clock(), verifiedFrame: verified ? placed : nil, alignmentTolerance: 0.001), + cleanup: session.cleanup, completion: session.completion) + settlementIsKeyboard = true + } + + private func startAnimation(_ element: AccessibilityElement, from startingFrame: CGRect?, to destination: CGRect, + duration: TimeInterval, resizeOnly: Bool, placement: WindowAnimationPlacement?, + offset: @escaping () -> CGPoint, curve: @escaping (Double) -> CGFloat, + completion: @escaping (CGRect) -> Void) { + let origin = startingFrame ?? element.frame + guard enabled(), environmentIsSafe(), element.isFullScreen != true, + !origin.isNull, !destination.isNull, !origin.isEmpty, !destination.isEmpty, + [origin.minX, origin.minY, origin.width, origin.height, + destination.minX, destination.minY, destination.width, destination.height].allSatisfy(\.isFinite), + origin != destination else { completion(.null); return } + // Certain apps (such as IINA) apply their aspect ratio asynchronously. Resize once, then align + // the settled size at completion without triggering another size change. + let nativeResize = origin.size != destination.size && isNativeResizeApp(element.bundleIdentifier) + var nativeResizeAccepted = false + let restoreAccessibility = element.beginAnimatedAdjustment() + var finalFrame: CGRect? + var previousFrame = origin + var lastSampledFrame: CGRect? + var sizeFeedback = WindowAnimationSizeFeedback() + var lastWriteTime = clock() + var reachedDestination = false + var verifiedFinalFrame: CGRect? + let readServer = serverFrame + let animationClock = clock + var finalized = false + let needsSettlement = placement != nil && !nativeResize + var finalResizeAccepted = false + lastEnvironmentCheck = clock() + window = element + WindowAnimationDiagnostics.event("direct-animation-start", fields: ["windowID": element.windowId ?? 0, + "source": [origin.minX, origin.minY, origin.width, origin.height], + "destination": [destination.minX, destination.minY, destination.width, destination.height], + "resizeOnly": resizeOnly, "duration": duration, "nativeResize": nativeResize]) + animation = WindowFrameAnimation(from: origin, to: destination, startTime: clock(), duration: duration, + offset: offset, curve: curve, write: { frame, progress in + if nativeResize { return true } + if let placement { + // Intermediate AX frames use whole points. Once motion rounds to + // the same frame, leave it alone until the next distinct sample. + // Final placement still uses the exact destination below. + let sampledFrame = CGRect(x: frame.minX.rounded(), y: frame.minY.rounded(), + width: frame.width.rounded(), height: frame.height.rounded()) + let now = animationClock() + if sampledFrame != lastSampledFrame || previousFrame.size != sampledFrame.size { + var requested = sampledFrame + let predictedSize = sizeFeedback.size(for: sampledFrame.size) + if predictedSize != sampledFrame.size { + requested = placement.frame(for: sampledFrame, actualSize: predictedSize, origin: origin, progress: progress) + } + let correction = min(1, CGFloat(max(0, now - lastWriteTime)) * 60) + if let achieved = element.setConstrainedAnimationFrame(requested, placement: placement, origin: origin, + progress: progress, previousFrame: previousFrame, maximumCorrection: correction) { + previousFrame = achieved + lastSampledFrame = sampledFrame + lastWriteTime = now + if achieved.width < sampledFrame.width - 1 || achieved.height < sampledFrame.height - 1 { + sizeFeedback.observe(requested: sampledFrame.size, actual: element.frame, + server: readServer(element), at: now) + } else { + sizeFeedback.observe(requested: sampledFrame.size, actual: achieved, server: achieved, at: now) + } + } + } + if sampledFrame == destination { + let actual = element.frame + reachedDestination = WindowAnimationGeometry.near(actual, destination, tolerance: 0.001) + && readServer(element).map { WindowAnimationGeometry.near($0, destination, tolerance: 0.001) } == true + } + return true + } + return element.setAnimationFrame(frame, resizeOnly: resizeOnly) + }, finishedEarly: { reachedDestination }, finalize: { frame in + finalized = true + if nativeResize { + guard nativeResizeAccepted else { return } + let actual = element.frame + guard WindowAnimationGeometry.valid(actual) else { return } + // Restore requests carry a previously achieved size. If the native resize app + // rejected that growth, let the ordinary mover retry it. + if placement?.sharedEdges == nil, + abs(actual.width - frame.width) > 1 || abs(actual.height - frame.height) > 1 { return } + let aligned = placement?.frame(for: frame, actualSize: actual.size, origin: origin, progress: 1) + ?? CGRect(origin: resizeOnly ? actual.origin : frame.origin, size: actual.size) + if aligned.origin != actual.origin { + guard element.writeAnimationPosition(aligned.origin) == .success else { return } + } + let achieved = element.frame + if WindowAnimationGeometry.valid(achieved), + abs(achieved.minX - aligned.minX) <= 1, abs(achieved.minY - aligned.minY) <= 1, + abs(achieved.width - aligned.width) <= 1, abs(achieved.height - aligned.height) <= 1 { + finalFrame = achieved + } + } else if placement != nil { + // Do not align a possibly stale size and immediately report success. + // The settlement phase verifies the achieved frame on later ticks. + let actual = element.frame + finalResizeAccepted = (WindowAnimationGeometry.valid(actual) && actual.size == frame.size) + || element.writeAnimationSize(frame.size) == .success + if finalResizeAccepted { + let placed = element.frame + if WindowAnimationGeometry.near(placed, frame, tolerance: 0.001), + readServer(element).map({ WindowAnimationGeometry.near($0, frame, tolerance: 0.001) }) == true { + verifiedFinalFrame = placed + } + } + } + }, cleanup: { [weak self] in + self?.stopDriving() + self?.animation = nil + self?.window = nil + if !finalized || !needsSettlement { restoreAccessibility() } + if !finalized { WindowAnimationDiagnostics.event("direct-animation-cancel", fields: ["windowID": element.windowId ?? 0]) } + }, completion: { [weak self] requested in + if needsSettlement, let placement { + guard let self, finalResizeAccepted else { + restoreAccessibility() + completion(.null) + return + } + self.window = element + self.pendingSettlement = PendingSettlement(destination: requested, origin: origin, + placement: placement, stability: WindowAnimationSettlement(startedAt: self.clock(), verifiedFrame: verifiedFinalFrame), + cleanup: restoreAccessibility, completion: completion) + self.startDriving() + return + } + if placement == nil && !nativeResize { + // Restore the normal AX timeout before settling native display adjustments. + element.setFrame(requested, adjustSizeFirst: false, adjustPosition: !resizeOnly) + let achieved = element.frame + let sizeMatches = abs(achieved.width - requested.width) <= 1 + && abs(achieved.height - requested.height) <= 1 + let positionMatches = resizeOnly || (abs(achieved.minX - requested.minX) <= 1 + && abs(achieved.minY - requested.minY) <= 1) + if !achieved.isNull, sizeMatches, positionMatches { finalFrame = achieved } + } + let frame = finalFrame ?? .null + WindowAnimationDiagnostics.event("direct-animation-complete", fields: ["windowID": element.windowId ?? 0, + "placed": !frame.isNull]) + completion(frame) + }) + if nativeResize { + if let placement { + nativeResizeAccepted = element.setConstrainedAnimationFrame(destination, placement: placement, + origin: origin, progress: 1, previousFrame: origin) != nil + } else { + nativeResizeAccepted = element.setAnimationFrame(destination, resizeOnly: resizeOnly) + } + WindowAnimationDiagnostics.event("direct-native-resize-settlement", fields: [ + "windowID": element.windowId ?? 0, "accepted": nativeResizeAccepted]) + } + startDriving() + } + + private func clearPendingRelease() { + pendingRelease = nil + window = nil + stopDriving() + } + + private func clearSettlement() { + let pending = pendingSettlement + pendingSettlement = nil + settlementIsKeyboard = false + window = nil + stopDriving() + pending?.cleanup() + } + + private func completeSettlement(_ frame: CGRect) { + guard let pending = pendingSettlement else { return } + WindowAnimationDiagnostics.event("direct-animation-complete", fields: [ + "windowID": window?.windowId ?? 0, "placed": !frame.isNull, + "settlementMilliseconds": (clock() - pending.stability.startedAt) * 1000]) + clearSettlement() + pending.completion(frame) + } + + private func stopDriving() { + displayLinkCleanup?() + displayLinkCleanup = nil + if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) } + screenObserver = nil + lastDrivenAt = nil + timer?.invalidate() + timer = nil + if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) } + mouseMonitor = nil + } + + private func drive() { + let now = clock() + if let lastDrivenAt, now - lastDrivenAt < drivingInterval * 0.9 { return } + lastDrivenAt = now + advance(at: now) + } + + private func startDriving() { + guard automaticallyAdvances else { return } + stopDriving() + let destination = pendingRelease?.destination ?? pendingSettlement?.destination ?? keyboardSession?.motion.destination ?? animation?.destination + let screen = NSScreen.screens.max { first, second in + func area(_ screen: NSScreen) -> CGFloat { + guard let destination else { return 0 } + let intersection = screen.frame.screenFlipped.intersection(destination) + return intersection.isNull ? 0 : intersection.width * intersection.height + } + return area(first) < area(second) + } + var frameRate = 60 + if #available(macOS 12.0, *), let screen, screen.maximumFramesPerSecond > 0 { + frameRate = min(120, screen.maximumFramesPerSecond) + } + drivingInterval = 1.0 / Double(frameRate) + if #available(macOS 14.0, *), let screen { + let target = WindowAnimationDisplayLinkTarget { [weak self] in self?.drive() } + let link = screen.displayLink(target: target, selector: #selector(WindowAnimationDisplayLinkTarget.tick(_:))) + link.preferredFrameRateRange = CAFrameRateRange(minimum: Float(frameRate), maximum: Float(frameRate), preferred: Float(frameRate)) + link.add(to: .main, forMode: .common) + displayLinkCleanup = { link.invalidate(); _ = target } + } else { + let timer = Timer(timeInterval: drivingInterval, repeats: true) { [weak self] _ in self?.drive() } + self.timer = timer + RunLoop.main.add(timer, forMode: .common) + } + screenObserver = NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification, + object: nil, queue: .main) { [weak self] _ in self?.finish() } + mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { [weak self] _ in self?.mouseDown() } + } +} + +@available(macOS 14.0, *) +private final class WindowAnimationDisplayLinkTarget: NSObject { + private let onFrame: () -> Void + init(onFrame: @escaping () -> Void) { self.onFrame = onFrame } + @objc func tick(_ link: CADisplayLink) { onFrame() } +} diff --git a/Rectangle/WindowMover/WindowAnimationCurves.swift b/Rectangle/WindowMover/WindowAnimationCurves.swift new file mode 100644 index 000000000..72a2d7e55 --- /dev/null +++ b/Rectangle/WindowMover/WindowAnimationCurves.swift @@ -0,0 +1,144 @@ +/// WindowAnimationCurves.swift + +import Cocoa + +enum WindowDisplayTransition { + static func display(containing frame: CGRect, displays: [CGRect]) -> CGRect? { + displays.filter { $0.intersects(frame) }.max { + let a = $0.intersection(frame), b = $1.intersection(frame) + return a.width * a.height < b.width * b.height + } + } + +} + +/// Shared geometry timing for target-zone previews and window enlargement. +enum WindowPreviewDeceleration { + static let duration: TimeInterval = 0.26 + + static var timingFunction: CAMediaTimingFunction { + CAMediaTimingFunction(controlPoints: 0.1, 0.9, 0.2, 1) + } + + static func value(at time: Double) -> CGFloat { + let x = min(1, max(0, time)) + if x == 0 || x == 1 { return CGFloat(x) } + func bezier(_ t: Double, _ a: Double, _ b: Double) -> Double { + let u = 1 - t + return 3 * u * u * t * a + 3 * u * t * t * b + t * t * t + } + var lower = 0.0, upper = 1.0 + for _ in 0..<40 { + let t = (lower + upper) / 2 + if bezier(t, 0.1, 0.2) < x { lower = t } else { upper = t } + } + return CGFloat(bezier((lower + upper) / 2, 0.9, 1)) + } + +} + +/// Timing for direct resizing and drag restoration. +enum WindowAnimationCurve { + static let duration: TimeInterval = 0.3 + static let unsnapPlaybackRate: Double = 1.2 + static let unsnapDuration: TimeInterval = 0.18 / unsnapPlaybackRate + + static func unsnapValue(at progress: Double) -> CGFloat { + let t = min(1, max(0, progress)) + // Quintic smoothstep: zero velocity and acceleration at both endpoints. + return CGFloat(t * t * t * (10 + t * (-15 + 6 * t))) + } + + static func value(at progress: Double) -> CGFloat { + let t = min(1, max(0, progress)) + // Cubic deceleration reaches rest without an extended near-stationary tail. + return CGFloat(t * (3 + t * (t - 3))) + } +} + +/// Input-specific timing shares the same window placement and verification code. +enum WindowAnimationProfile { + case standard, keyboard +} + +/// A finite trajectory can be replaced without estimating velocity from rounded AX frames. +struct WindowKeyboardMotion { + static let duration: TimeInterval = 0.22 + + struct Sample { + let frame: CGRect + let velocity: [CGFloat] + let progress: CGFloat + } + + struct Axis { + let origin: CGFloat + let destination: CGFloat + let initialVelocity: CGFloat + let brakingDuration: TimeInterval + let duration: TimeInterval + + init(origin: CGFloat, destination: CGFloat, velocity: CGFloat, duration: TimeInterval, + maximumDrift: CGFloat = 2) { + self.origin = origin + self.destination = destination + self.duration = duration + let delta = destination - origin + let velocity = velocity.isFinite && delta != 0 ? velocity : 0 + if velocity * delta < 0, maximumDrift > 0 { + initialVelocity = velocity + brakingDuration = min(0.03, Double(3 * maximumDrift / abs(velocity))) + } else { + // A short remaining distance may not accommodate the incoming speed. + // Prefer a monotonic path to crossing the new destination. + let limit = 3 * abs(delta) / CGFloat(duration) + initialVelocity = velocity * delta > 0 ? min(abs(velocity), limit) * (delta < 0 ? -1 : 1) : 0 + brakingDuration = 0 + } + } + + func sample(at elapsed: TimeInterval) -> (position: CGFloat, velocity: CGFloat) { + if elapsed >= duration { return (destination, 0) } + let elapsed = max(0, elapsed) + if brakingDuration > 0, elapsed < brakingDuration { + let u = CGFloat(elapsed / brakingDuration) + let displacement = initialVelocity * CGFloat(brakingDuration) * (u - u * u + u * u * u / 3) + return (origin + displacement, initialVelocity * (1 - u) * (1 - u)) + } + let start = origin + initialVelocity * CGFloat(brakingDuration) / 3 + let seconds = CGFloat(duration - brakingDuration) + let u = CGFloat((elapsed - brakingDuration) / (duration - brakingDuration)) + let velocity = brakingDuration > 0 ? 0 : initialVelocity + let delta = destination - start + let position = start + delta * u * u * (3 - 2 * u) + seconds * velocity * u * (1 - u) * (1 - u) + let derivative = delta * 6 * u * (1 - u) / seconds + velocity * (1 - 4 * u + 3 * u * u) + return (position, derivative) + } + } + + let origin: CGRect + let destination: CGRect + let startedAt: TimeInterval + private let axes: [Axis] + + init(from origin: CGRect, to destination: CGRect, velocity: [CGFloat] = [0, 0, 0, 0], + at time: TimeInterval, driftLimits: [CGFloat] = [2, 2, 2, 2]) { + self.origin = origin + self.destination = destination + startedAt = time + let starts = [origin.minX, origin.minY, origin.width, origin.height] + let ends = [destination.minX, destination.minY, destination.width, destination.height] + axes = starts.indices.map { + Axis(origin: starts[$0], destination: ends[$0], velocity: velocity[$0], + duration: Self.duration, maximumDrift: driftLimits[$0]) + } + } + + func sample(at time: TimeInterval) -> Sample { + let elapsed = max(0, time - startedAt) + let values = axes.map { $0.sample(at: elapsed) } + return Sample(frame: CGRect(x: values[0].position, y: values[1].position, + width: values[2].position, height: values[3].position), + velocity: values.map(\.velocity), progress: CGFloat(min(1, elapsed / Self.duration))) + } +} diff --git a/Rectangle/WindowMover/WindowAnimator.swift b/Rectangle/WindowMover/WindowAnimator.swift new file mode 100644 index 000000000..8dc6e2ed5 --- /dev/null +++ b/Rectangle/WindowMover/WindowAnimator.swift @@ -0,0 +1,434 @@ +/// WindowAnimator.swift + +import Cocoa + +struct WindowAnimationPlacement: Equatable { + let screenFrame: CGRect + let sharedEdges: Edge? + let constrainToScreen: Bool + let gap: CGFloat + + func positionBeforeGrowing(from previous: CGRect, to requested: CGRect) -> CGPoint? { + guard constrainToScreen else { return nil } + var position = previous.origin + // Make room on an expanding axis before asking AX to resize. Otherwise + // macOS can clip the new size at the old origin even though the next + // animation frame fits, producing a stop/start edge during the resize. + if requested.width > previous.width + 1, + previous.minX + requested.width > screenFrame.maxX + 1, + requested.minX < previous.minX { + position.x = requested.minX + } + if requested.height > previous.height + 1, + previous.minY + requested.height > screenFrame.maxY + 1, + requested.minY < previous.minY { + position.y = requested.minY + } + return position == previous.origin ? nil : position + } + + func frame(for requested: CGRect, actualSize: CGSize, origin: CGRect, progress: CGFloat) -> CGRect { + var frame = CGRect(origin: requested.origin, size: actualSize) + if let sharedEdges { + frame = ClampedWindowAligner.aligned(window: frame, inZone: requested, sharedEdges: sharedEdges) + } + guard constrainToScreen else { return frame } + + // Bring an initially out-of-bounds window back gradually instead of clipping its first frame. + let initialBounds = screenFrame.union(origin) + let progress = min(1, max(0, progress)) + let bounds = CGRect(x: initialBounds.minX + (screenFrame.minX - initialBounds.minX) * progress, + y: initialBounds.minY + (screenFrame.minY - initialBounds.minY) * progress, + width: initialBounds.width + (screenFrame.width - initialBounds.width) * progress, + height: initialBounds.height + (screenFrame.height - initialBounds.height) * progress) + return WindowFrameBounds.constrained(frame, to: bounds, gap: gap) + } + + /// A transient resize response must not send an intermediate position past + /// its requested trajectory and then back when the size catches up. + func intermediateFrame(_ resolved: CGRect, requested: CGRect, previous: CGRect, maximumCorrection: CGFloat = 0) -> CGRect { + var result = resolved + result.origin.x = min(max(resolved.minX, min(previous.minX, requested.minX)), + max(previous.minX, requested.minX)) + result.origin.y = min(max(resolved.minY, min(previous.minY, requested.minY)), + max(previous.minY, requested.minY)) + // Consume constraint changes gradually, including necessary movement + // against the nominal trajectory near a screen edge. + let limit = max(0, maximumCorrection) + result.origin.x += min(limit, max(-limit, resolved.minX - result.minX)) + result.origin.y += min(limit, max(-limit, resolved.minY - result.minY)) + return result + } +} + +/// Geometry evidence is scoped to one animation. Oversized shrink responses +/// remain eligible for retry rather than becoming inferred minimum sizes. +struct WindowAnimationSizeFeedback { + private struct Observation { + let requested: CGSize + let actual: CGSize + let time: TimeInterval + } + private var observations: [Observation] = [] + private(set) var fixedWidth: CGFloat? + private(set) var fixedHeight: CGFloat? + private(set) var aspectRatio: CGFloat? + + mutating func observe(requested: CGSize, actual: CGRect, server: CGRect?, at time: TimeInterval) { + guard let server, WindowAnimationGeometry.valid(actual), + WindowAnimationGeometry.near(actual, server, tolerance: 1), + actual.width <= requested.width + 1, actual.height <= requested.height + 1 else { + reset() + return + } + guard abs(actual.width - requested.width) > 1 || abs(actual.height - requested.height) > 1 else { + reset() + return + } + if let last = observations.last, + abs(last.requested.width - requested.width) <= 1, + abs(last.requested.height - requested.height) <= 1 { return } + observations.append(Observation(requested: requested, actual: actual.size, time: time)) + if observations.count > 6 { observations.removeFirst() } + guard observations.count >= 3, let first = observations.first, + time - first.time >= 1.0 / 30 else { return } + fixedWidth = abs(first.requested.width - requested.width) > 2 + && observations.allSatisfy { abs($0.actual.width - actual.width) <= 1 } ? actual.width : nil + fixedHeight = abs(first.requested.height - requested.height) > 2 + && observations.allSatisfy { abs($0.actual.height - actual.height) <= 1 } ? actual.height : nil + let ratio = actual.width / actual.height + aspectRatio = fixedWidth == nil && fixedHeight == nil + && abs(first.actual.width - actual.width) > 2 && abs(first.actual.height - actual.height) > 2 + && observations.allSatisfy { abs($0.actual.width / $0.actual.height - ratio) <= ratio * 0.005 } + ? ratio : nil + } + + func size(for requested: CGSize) -> CGSize { + var result = CGSize(width: fixedWidth ?? requested.width, height: fixedHeight ?? requested.height) + if let ratio = aspectRatio { + result.width = min(result.width, result.height * ratio) + result.height = result.width / ratio + } + return result + } + + private mutating func reset() { + observations.removeAll(keepingCapacity: true) + fixedWidth = nil + fixedHeight = nil + aspectRatio = nil + } +} + +/// Waits for AX and WindowServer to agree before accepting a constrained size. +/// Retries a stalled resize with a small position change before moving fully in bounds. +/// A new drag cancels this state in the driver. +struct WindowAnimationSettlement { + enum Decision { + case waiting, retrySize, retrySizeAt(CGPoint), align(CGRect), complete(CGRect), failed + } + + let startedAt: TimeInterval + private var previous: CGRect? + private var stableSince: TimeInterval? + private var retriedSize = false + private var retriedNearDestination = false + private let alignmentTolerance: CGFloat + + init(startedAt: TimeInterval, verifiedFrame: CGRect? = nil, alignmentTolerance: CGFloat = 1) { + self.startedAt = startedAt + self.alignmentTolerance = alignmentTolerance + if let verifiedFrame { + previous = verifiedFrame + stableSince = startedAt + } + } + + mutating func observe(ax: CGRect, server: CGRect?, destination: CGRect, + placement: WindowAnimationPlacement, origin: CGRect, + at now: TimeInterval) -> Decision { + guard now - startedAt < 0.3 else { return .failed } + guard let server, WindowAnimationGeometry.valid(ax), WindowAnimationGeometry.valid(server), + WindowAnimationGeometry.near(ax, server, tolerance: 1) else { + resetObservation() + return .waiting + } + guard let previous, WindowAnimationGeometry.near(previous, ax, tolerance: 1) else { + self.previous = ax + stableSince = now + return .waiting + } + guard let stableSince, now - stableSince >= 1.0 / 30 else { return .waiting } + if !retriedSize, abs(ax.width - destination.width) > 1 || abs(ax.height - destination.height) > 1 { + if placement.constrainToScreen { + // A shrink can stop while returning from off screen even when AX + // reports success. Moving the entire oversized frame in bounds can + // expose a backwards step after it has already reached its target. + var retryFrame = placement.frame(for: destination, actualSize: ax.size, origin: origin, progress: 1) + if let position = placement.positionBeforeGrowing(from: retryFrame, to: destination) { + retryFrame.origin = position + } + if !WindowAnimationGeometry.near(ax, retryFrame, tolerance: 1) { + if !retriedNearDestination, + abs(ax.minX - destination.minX) <= 1, abs(ax.minY - destination.minY) <= 1, + ax.width >= destination.width, ax.height >= destination.height { + // A one-point move can unblock the resize without exposing + // the full correction. Verify it before using the in-bounds + // retry; genuine minimum sizes still take the normal path. + retriedNearDestination = true + func step(_ current: CGFloat, toward target: CGFloat) -> CGFloat { + current + min(1, abs(target - current)) * (target < current ? -1 : 1) + } + resetObservation() + return .retrySizeAt(CGPoint(x: step(ax.minX, toward: retryFrame.minX), + y: step(ax.minY, toward: retryFrame.minY))) + } + retriedSize = true + resetObservation() + return .retrySizeAt(retryFrame.origin) + } + } + retriedSize = true + resetObservation() + return .retrySize + } + let aligned = placement.frame(for: destination, actualSize: ax.size, origin: origin, progress: 1) + if WindowAnimationGeometry.near(ax, aligned, tolerance: alignmentTolerance) { return .complete(ax) } + resetObservation() + return .align(aligned) + } + + private mutating func resetObservation() { + previous = nil + stableSince = nil + } +} + +/// Advances by elapsed time, skipping missed frames. +final class WindowFrameAnimation { + let destination: CGRect + private let origin: CGRect + private let startTime: TimeInterval + private let duration: TimeInterval + private let offset: () -> CGPoint + private let curve: (Double) -> CGFloat + private let write: (CGRect, CGFloat) -> Bool + private let finishedEarly: () -> Bool + private let finalize: ((CGRect) -> Void)? + private let cleanup: () -> Void + private let completion: (CGRect) -> Void + private(set) var isFinished = false + + init(from: CGRect, to: CGRect, startTime: TimeInterval, duration: TimeInterval, + offset: @escaping () -> CGPoint = { .zero }, + curve: @escaping (Double) -> CGFloat = WindowAnimationCurve.value, + write: @escaping (CGRect, CGFloat) -> Bool, + finishedEarly: @escaping () -> Bool = { false }, + finalize: ((CGRect) -> Void)? = nil, + cleanup: @escaping () -> Void, + completion: @escaping (CGRect) -> Void) { + origin = from + destination = to + self.startTime = startTime + self.duration = duration + self.offset = offset + self.curve = curve + self.write = write + self.finishedEarly = finishedEarly + self.finalize = finalize + self.cleanup = cleanup + self.completion = completion + } + + func tick(at time: TimeInterval) { + guard !isFinished else { return } + let progress = duration > 0 ? min(1, max(0, (time - startTime) / duration)) : 1 + if progress >= 1 { + finish() + return + } + let eased = curve(progress) + let delta = offset() + let frame = CGRect(x: origin.minX + (destination.minX - origin.minX) * eased + delta.x, + y: origin.minY + (destination.minY - origin.minY) * eased + delta.y, + width: origin.width + (destination.width - origin.width) * eased, + height: origin.height + (destination.height - origin.height) * eased) + if !write(frame, eased) || finishedEarly() { + // Let the normal mover settle the destination after a refused AX write. + finish() + } + } + + func finish() { + guard !isFinished else { return } + let delta = offset() + isFinished = true + let finalFrame = destination.offsetBy(dx: delta.x, dy: delta.y) + finalize?(finalFrame) + cleanup() + completion(finalFrame) + } + + func cancel() { + guard !isFinished else { return } + isFinished = true + cleanup() + } +} + +enum WindowAnimationInterruptionPolicy { + static func isMissionControlElement(role: String?, identifier: String?) -> Bool { + role == kAXGroupRole && identifier == "mc" + } + + static var missionControlActive: Bool { + guard let dock = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dock").first else { return false } + let application = AXUIElementCreateApplication(dock.processIdentifier) + AXUIElementSetMessagingTimeout(application, 0.05) + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(application, kAXChildrenAttribute as CFString, &value) == .success, + let children = value as? [AXUIElement] else { return false } + // Mission Control does not necessarily activate Dock or change the current Space. + // Its AX identifier is independent of the localized title. This is a best-effort + // system marker; ordinary Dock groups and unavailable AX reads must not match. + return children.contains { child in + var role: CFTypeRef? + var identifier: CFTypeRef? + AXUIElementCopyAttributeValue(child, kAXRoleAttribute as CFString, &role) + guard role as? String == kAXGroupRole else { return false } + AXUIElementCopyAttributeValue(child, kAXIdentifierAttribute as CFString, &identifier) + return isMissionControlElement(role: role as? String, identifier: identifier as? String) + } + } + + static func shouldCancel(activatedPID: pid_t?, targetPID: pid_t) -> Bool { + guard let activatedPID else { return false } + return activatedPID != targetPID + } +} + +struct WindowReleasedSnapStability { + enum Decision { case waiting, ready, timedOut } + let startedAt: TimeInterval + private var previous: CGRect? + private var stableSince: TimeInterval? + + init(startedAt: TimeInterval) { self.startedAt = startedAt } + + mutating func observe(ax: CGRect, server: CGRect?, at now: TimeInterval) -> Decision { + if let server, WindowAnimationGeometry.valid(ax), WindowAnimationGeometry.valid(server), + WindowAnimationGeometry.near(ax, server, tolerance: 1) { + if let previous, WindowAnimationGeometry.near(previous, server, tolerance: 1) { + if let stableSince, now - stableSince >= 1.0 / 30 { return .ready } + } else { stableSince = now } + previous = server + } else { + previous = nil + stableSince = nil + } + return now - startedAt >= 0.15 ? .timedOut : .waiting + } +} + +enum WindowAnimationGeometry { + static func valid(_ frame: CGRect) -> Bool { + !frame.isNull && !frame.isInfinite && [frame.minX, frame.minY, frame.width, frame.height].allSatisfy { $0.isFinite } && frame.width > 0 && frame.height > 0 + } + static func near(_ a: CGRect, _ b: CGRect, tolerance: CGFloat = 2) -> Bool { + valid(a) && valid(b) && abs(a.minX-b.minX) <= tolerance && abs(a.minY-b.minY) <= tolerance && abs(a.width-b.width) <= tolerance && abs(a.height-b.height) <= tolerance + } +} + +/// Opt-in local timing evidence. Normal app launches do no tracing or filesystem work. +/// Example Terminal command to launch Rectangle with this enabled: +/// open -a Rectangle --env RECTANGLE_ANIMATION_TRACE_PATH="/tmp/trace.json" +enum WindowAnimationDiagnostics { + private static let path = ProcessInfo.processInfo.environment["RECTANGLE_ANIMATION_TRACE_PATH"] + static var enabled: Bool { !(path ?? "").isEmpty } + private static let queue = DispatchQueue(label: "Rectangle.WindowAnimation.diagnostics", qos: .utility) + static func event(_ name: String, fields: [String: Any] = [:]) { + guard let path, !path.isEmpty else { return } + var record = fields + record["event"] = name + record["uptime"] = ProcessInfo.processInfo.systemUptime + record["timestamp"] = Date().timeIntervalSince1970 + record["pid"] = getpid() + guard JSONSerialization.isValidJSONObject(record), + var data = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) else { return } + data.append(0x0A) + queue.async { + let descriptor = open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { return } + defer { close(descriptor) } + data.withUnsafeBytes { buffer in + guard let base = buffer.baseAddress else { return } + _ = Darwin.write(descriptor, base, buffer.count) + } + } + } +} + +/// Coordinates animation lifecycle and moves the actual application window. +final class WindowAnimator { + static let shared = WindowAnimator() + private let direct = DirectWindowAnimator() + + private init() { + for name in [Notification.Name.windowAnimationPreferencesChanged, .configImported] { + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + self?.finish() + } + } + for name in [NSApplication.willTerminateNotification, NSApplication.didChangeScreenParametersNotification] { + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + self?.direct.cancel() + } + } + let workspace = NSWorkspace.shared.notificationCenter + for name in [NSWorkspace.activeSpaceDidChangeNotification, NSWorkspace.willSleepNotification] { + workspace.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + self?.direct.cancel() + } + } + workspace.addObserver(forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main) { [weak self] notification in + let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + self?.direct.cancelIfTargetDiffers(from: app?.processIdentifier) + } + } + + static var enabled: Bool { + Defaults.experimentalWindowAnimations.enabled + && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + && !NSWorkspace.shared.isVoiceOverEnabled + && !NSWorkspace.shared.isSwitchControlEnabled + } + + func destination(for element: AccessibilityElement) -> CGRect? { direct.destination(for: element) } + func logicalFrame(for element: AccessibilityElement) -> CGRect? { direct.destination(for: element) } + func cancel(for element: AccessibilityElement) { direct.cancel(for: element) } + func finish() { direct.finish() } + func finishForNewDrag() { + direct.mouseDown() + finish() + } + + func animate(_ element: AccessibilityElement, from startingFrame: CGRect? = nil, to destination: CGRect, + duration: TimeInterval = WindowAnimationCurve.duration, + resizeOnly: Bool = false, releasedSnap: Bool = false, + placement: WindowAnimationPlacement? = nil, + profile: WindowAnimationProfile = .standard, + offset: @escaping () -> CGPoint = { .zero }, + curve: @escaping (Double) -> CGFloat = WindowAnimationCurve.value, + completion: @escaping (CGRect) -> Void) { + direct.animate(element, from: startingFrame, to: destination, duration: duration, + resizeOnly: resizeOnly, releasedSnap: releasedSnap, placement: placement, profile: profile, + offset: offset, curve: curve, completion: completion) + } + + static func crossesDisplays(from source: CGRect, to destination: CGRect) -> Bool { + let frames = NSScreen.screens.map { $0.frame.screenFlipped } + guard let a = WindowDisplayTransition.display(containing: source, displays: frames), + let b = WindowDisplayTransition.display(containing: destination, displays: frames) else { return false } + return a != b + } +} diff --git a/Rectangle/WindowSizeWarning.swift b/Rectangle/WindowSizeWarning.swift index ccad9f400..90e2952c7 100644 --- a/Rectangle/WindowSizeWarning.swift +++ b/Rectangle/WindowSizeWarning.swift @@ -21,6 +21,7 @@ enum WindowSizeConstraint { } final class WindowSizeWarning: NSPanel { + private static let padding: CGFloat = 24 private var dismissal: DispatchWorkItem? private var labels: [NSTextField] = [] @@ -46,19 +47,37 @@ final class WindowSizeWarning: NSPanel { container.blendingMode = .behindWindow container.state = .active container.wantsLayer = true - container.layer?.cornerRadius = 10 + let radius: CGFloat = 20 + container.layer?.cornerRadius = radius container.layer?.masksToBounds = true + // Mask the material itself so its blur does not bleed outside the rounded corners. + let mask = NSImage(size: NSSize(width: radius * 2 + 1, height: radius * 2 + 1), flipped: false) { rect in + NSColor.white.setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() + return true + } + mask.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius) + mask.resizingMode = .stretch + container.maskImage = mask + + let icon = WindowSizeWarningIcon() + icon.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(icon) + let title = NSTextField(wrappingLabelWithString: NSLocalizedString( "windowSizeWarningTitle", tableName: "Main", value: "Minimum window size reached", comment: "Title of the on-screen message when a window cannot fit its requested size")) - title.font = .boldSystemFont(ofSize: NSFont.systemFontSize) + title.font = .systemFont(ofSize: 21, weight: .semibold) + title.alignment = .center let message = NSTextField(wrappingLabelWithString: NSLocalizedString( "windowSizeWarningMessage", tableName: "Main", - value: "Unable to resize window smaller. Windows may overlap.", + value: "Unable to resize window smaller.\nWindows may overlap.", comment: "Explains that an app can prevent a window from shrinking to the requested layout")) message.font = .systemFont(ofSize: NSFont.systemFontSize) + message.textColor = .secondaryLabelColor + message.alignment = .center labels = [title, message] for label in labels { @@ -66,13 +85,17 @@ final class WindowSizeWarning: NSPanel { container.addSubview(label) } NSLayoutConstraint.activate([ - title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), - title.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18), - title.topAnchor.constraint(equalTo: container.topAnchor, constant: 14), + icon.centerXAnchor.constraint(equalTo: container.centerXAnchor), + icon.topAnchor.constraint(equalTo: container.topAnchor, constant: Self.padding), + icon.widthAnchor.constraint(equalToConstant: 48), + icon.heightAnchor.constraint(equalToConstant: 36), + title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Self.padding), + title.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Self.padding), + title.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 17), message.leadingAnchor.constraint(equalTo: title.leadingAnchor), message.trailingAnchor.constraint(equalTo: title.trailingAnchor), - message.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6), - message.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14) + message.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 10), + message.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -Self.padding) ]) contentView = container } @@ -82,10 +105,10 @@ final class WindowSizeWarning: NSPanel { guard let contentView else { return } let visibleFrame = screen.visibleFrame let width = min(360, visibleFrame.width - 32) - guard width > 36, visibleFrame.height > 0 else { return } + guard width > Self.padding * 2, visibleFrame.height > 0 else { return } // Fix the text width before measuring so localized messages can wrap. - labels.forEach { $0.preferredMaxLayoutWidth = width - 36 } + labels.forEach { $0.preferredMaxLayoutWidth = width - Self.padding * 2 } contentView.setFrameSize(NSSize(width: width, height: 0)) contentView.layoutSubtreeIfNeeded() let height = min(contentView.fittingSize.height, visibleFrame.height) @@ -109,3 +132,33 @@ final class WindowSizeWarning: NSPanel { dismissal?.cancel() } } + +private final class WindowSizeWarningIcon: NSView { + override func draw(_ dirtyRect: NSRect) { + NSColor.labelColor.withAlphaComponent(0.8).setStroke() + let outline = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 6, yRadius: 6) + outline.lineWidth = 2 + outline.stroke() + + let arrows = NSBezierPath() + arrows.lineWidth = 2.5 + arrows.lineCapStyle = .round + arrows.lineJoinStyle = .round + arrows.move(to: NSPoint(x: 14, y: 26)) + arrows.line(to: NSPoint(x: 22, y: 18)) + arrows.move(to: NSPoint(x: 16, y: 18)) + arrows.line(to: NSPoint(x: 22, y: 18)) + arrows.line(to: NSPoint(x: 22, y: 24)) + arrows.move(to: NSPoint(x: 34, y: 10)) + arrows.line(to: NSPoint(x: 26, y: 18)) + arrows.move(to: NSPoint(x: 26, y: 12)) + arrows.line(to: NSPoint(x: 26, y: 18)) + arrows.line(to: NSPoint(x: 32, y: 18)) + arrows.stroke() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } +} diff --git a/Rectangle/mul.lproj/Main.xcstrings b/Rectangle/mul.lproj/Main.xcstrings index 406f578e6..5f9324a9c 100644 --- a/Rectangle/mul.lproj/Main.xcstrings +++ b/Rectangle/mul.lproj/Main.xcstrings @@ -1,6 +1,182 @@ { "sourceLanguage" : "en", "strings" : { + "Blur appearance" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blur appearance" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模糊外观" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "模糊外觀" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ぼかしの外観" + } + } + } + }, + "Follow System" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Follow System" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "跟随系统" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "跟隨系統" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "システムに合わせる" + } + } + } + }, + "Light" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Light" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "浅色" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "淺色" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ライト" + } + } + } + }, + "Dark" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dark" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "深色" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "深色" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダーク" + } + } + } + }, + "blur-preview-cell.title" : { + "comment" : "Class = \"NSButtonCell\"; title = \"Blur footprint\"; ObjectID = \"blur-preview-cell\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blur footprint" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模糊预览背景" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "模糊預覽背景" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プレビューの背景をぼかす" + } + } + } + }, + "ani-win-cell.title" : { + "comment" : "Class = \"NSButtonCell\"; title = \"Animate windows (experimental)\"; ObjectID = \"ani-win-cell\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animate windows (experimental)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "窗口动画(实验性)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "視窗動畫(實驗性)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウインドウをアニメーション表示(実験的)" + } + } + } + }, "0Ak-33-SM7.title" : { "comment" : "Class = \"NSTextFieldCell\"; title = \"Top Right\"; ObjectID = \"0Ak-33-SM7\";", "extractionState" : "extracted_with_value", @@ -8533,9 +8709,7 @@ } } }, - "After Rectangle maximizes or almost maximizes a window, executing the same action again moves the window back to its previous size and position." : { - - }, + "After Rectangle maximizes or almost maximizes a window, executing the same action again moves the window back to its previous size and position." : {}, "agt-UL-0e3.title" : { "comment" : "Class = \"NSMenuItem\"; title = \"Use Default\"; ObjectID = \"agt-UL-0e3\";", "extractionState" : "manual", @@ -13023,9 +13197,7 @@ } } }, - "Cyclic corner shortcuts expand:" : { - - }, + "Cyclic corner shortcuts expand:" : {}, "d7y-s8-7GE.title" : { "comment" : "Class = \"NSTextFieldCell\"; title = \"Top\"; ObjectID = \"d7y-s8-7GE\";", "extractionState" : "extracted_with_value", @@ -19981,6 +20153,35 @@ } } }, + "Go to System Settings → Privacy & Security → Device Control and Data Access" : { + "comment" : "macOS 27 and later permission pane name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go to System Settings → Privacy & Security → Device Control and Data Access" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往 系统设置 → 隐私与安全性 → 设备控制和数据访问" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往 系統設定 → 隱私權與安全性 → 裝置控制和資料取用" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "システム設定→プライバシーとセキュリティ→デバイスの制御とデータへのアクセスを選択します" + } + } + } + }, "Go to System Settings → Privacy & Security → Accessibility" : { "localizations" : { "ar" : { @@ -20159,9 +20360,7 @@ } } }, - "Green stoplight button maximizes instead of Full Screen" : { - - }, + "Green stoplight button maximizes instead of Full Screen" : {}, "Grid Positions" : { "localizations" : { "ko" : { @@ -21210,9 +21409,7 @@ } } }, - "Half actions preserve the window's size on the other axis" : { - - }, + "Half actions preserve the window's size on the other axis" : {}, "heT-W6-Fyf.title" : { "comment" : "Class = \"NSButtonCell\"; title = \"Double-click window title bar to maximize/restore\"; ObjectID = \"heT-W6-Fyf\";", "extractionState" : "extracted_with_value", @@ -21867,9 +22064,7 @@ } } }, - "Hold any modifier key or use the window menu for default macOS behavior" : { - - }, + "Hold any modifier key or use the window menu for default macOS behavior" : {}, "HOm-BV-2jc.title" : { "comment" : "Class = \"NSTextFieldCell\"; title = \"Move Up\"; ObjectID = \"HOm-BV-2jc\";", "extractionState" : "extracted_with_value", @@ -22258,9 +22453,7 @@ } } }, - "horizontally" : { - - }, + "horizontally" : {}, "hQb-2v-fYv.title" : { "comment" : "Class = \"NSMenuItem\"; title = \"Smart Quotes\"; ObjectID = \"hQb-2v-fYv\";", "extractionState" : "manual", @@ -27768,9 +27961,7 @@ } } }, - "Left Half then Top Half moves the window to the top left quarter; the action for the opposite edge expands it back." : { - - }, + "Left Half then Top Half moves the window to the top left quarter; the action for the opposite edge expands it back." : {}, "Left or right side" : { "localizations" : { "ar" : { @@ -27949,9 +28140,7 @@ } } }, - "Left side, top/bottom near corners" : { - - }, + "Left side, top/bottom near corners" : {}, "Left side, top/bottom side near corners" : { "extractionState" : "stale", "localizations" : { @@ -32837,9 +33026,7 @@ } } }, - "Offset window position on overlap" : { - - }, + "Offset window position on overlap" : {}, "ogc-rX-tC1.title" : { "comment" : "Class = \"NSMenuItem\"; title = \"Loosen\"; ObjectID = \"ogc-rX-tC1\";", "extractionState" : "manual", @@ -34569,9 +34756,7 @@ } } }, - "Preserve maximize state when moving across displays" : { - - }, + "Preserve maximize state when moving across displays" : {}, "Press the shortcut repeatedly to cycle through all positions in the grid." : { "localizations" : { "ko" : { @@ -37502,12 +37687,8 @@ } } }, - "Repeated Maximize restores the previous size and position" : { - - }, - "Resize adjacent windows when cycling side or corner shortcuts" : { - - }, + "Repeated Maximize restores the previous size and position" : {}, + "Resize adjacent windows when cycling side or corner shortcuts" : {}, "rgM-f4-ycn.title" : { "comment" : "Class = \"NSMenuItem\"; title = \"Smart Dashes\"; ObjectID = \"rgM-f4-ycn\";", "extractionState" : "manual", @@ -37832,9 +38013,7 @@ } } }, - "Right side, top/bottom near corners" : { - - }, + "Right side, top/bottom near corners" : {}, "Right side, top/bottom side near corners" : { "extractionState" : "stale", "localizations" : { @@ -38912,9 +39091,7 @@ } } }, - "Show stacked window list on hover" : { - - }, + "Show stacked window list on hover" : {}, "Side Split Ratio" : { "localizations" : { "ko" : { @@ -40884,9 +41061,7 @@ } } }, - "Thirds, top/bottom near corners" : { - - }, + "Thirds, top/bottom near corners" : {}, "Thirds, top/bottom side near corners" : { "extractionState" : "stale", "localizations" : { @@ -41603,15 +41778,9 @@ } } }, - "Toggle stacked window badge" : { - - }, - "Toggle Todo" : { - - }, - "Toggle window list" : { - - }, + "Toggle stacked window badge" : {}, + "Toggle Todo" : {}, + "Toggle window list" : {}, "Top Center Left Eighth" : { "localizations" : { "ko" : { @@ -41942,12 +42111,8 @@ } } }, - "Top/bottoms" : { - - }, - "Treat multiple displays as one" : { - - }, + "Top/bottoms" : {}, + "Treat multiple displays as one" : {}, "Treat multiple monitors as one" : { "extractionState" : "stale", "localizations" : { @@ -44456,9 +44621,7 @@ } } }, - "vertically" : { - - }, + "vertically" : {}, "Vfd-fe-hCe.title" : { "comment" : "Class = \"NSButtonCell\"; title = \"ⓘ\"; ObjectID = \"Vfd-fe-hCe\";", "extractionState" : "extracted_with_value", @@ -46667,9 +46830,7 @@ } } }, - "When using multiple displays, treats them as a single display. Requires System Settings > Desktop & Dock > Displays have separate Spaces to be OFF." : { - - }, + "When using multiple displays, treats them as a single display. Requires System Settings > Desktop & Dock > Displays have separate Spaces to be OFF." : {}, "Width Step (px)" : { "extractionState" : "manual", "localizations" : { @@ -46706,7 +46867,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unable to resize window smaller. Windows may overlap." + "value" : "Unable to resize window smaller.\nWindows may overlap." } } } @@ -50790,6 +50951,93 @@ } } } + }, + "Animation style" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animation style" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "动画样式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "動畫樣式" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アニメーションの種類" + } + } + } + }, + "Blur preview" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blur preview" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模糊预览" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "模糊預覽" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ぼかしプレビュー" + } + } + } + }, + "Direct resize" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direct resize" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直接缩放窗口" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "直接縮放視窗" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウインドウを直接リサイズ" + } + } + } } }, "version" : "1.0" diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 3ede2865e..9a7f04d76 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -95,9 +95,11 @@ class BandTilingTests: XCTestCase { override var isHidden: Bool? { false } override var isSystemDialog: Bool? { false } - override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true) { + override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) { setFrameCalls += 1 + let originalOrigin = acceptedFrame.origin acceptedFrame = frame + if !adjustPosition { acceptedFrame.origin = originalOrigin } acceptedFrame.size.height = max(frame.height, minimumHeight, minimumHeightAtOrigin(frame.minY)) acceptedFrame.size.width = min(frame.width, maximumWidth) } @@ -732,6 +734,95 @@ class BandTilingTests: XCTestCase { } +final class FootprintAlphaDefaultsTests: XCTestCase { + private var savedAlpha: Double = 0 + private var savedBlur = false + private var storedAlpha: Any? + private var storedBlur: Any? + + override func setUp() { + super.setUp() + savedAlpha = Defaults.footprintAlpha.value + savedBlur = Defaults.footprintBlur.enabled + storedAlpha = UserDefaults.standard.object(forKey: Defaults.footprintAlpha.key) + storedBlur = UserDefaults.standard.object(forKey: Defaults.footprintBlur.key) + UserDefaults.standard.removeObject(forKey: Defaults.footprintAlpha.key) + } + + override func tearDown() { + Defaults.footprintAlpha.value = savedAlpha + Defaults.footprintBlur.enabled = savedBlur + UserDefaults.standard.set(storedAlpha, forKey: Defaults.footprintAlpha.key) + UserDefaults.standard.set(storedBlur, forKey: Defaults.footprintBlur.key) + super.tearDown() + } + + func testUnsetAlphaFollowsPreviewStyleWithoutSavingAValue() { + let window = FootprintWindow(accessibility: { + FootprintAccessibility(reduceMotion: false, reduceTransparency: false) + }) + defer { window.close() } + + for blurred in [false, true, false] { + Defaults.footprintBlur.enabled = blurred + XCTAssertEqual(Defaults.effectiveFootprintAlpha, blurred ? 0 : 0.3) + XCTAssertEqual(window.presentation.alpha, blurred ? 1 : 0.3) + XCTAssertEqual(window.presentation.usesBlur, blurred) + XCTAssertNil(UserDefaults.standard.object(forKey: Defaults.footprintAlpha.key)) + } + } + + func testExplicitZeroSurvivesStyleChangesReloadAndConfigRoundTrip() throws { + Defaults.footprintAlpha.value = 0 + for blurred in [false, true] { + Defaults.footprintBlur.enabled = blurred + XCTAssertEqual(Defaults.effectiveFootprintAlpha, 0) + XCTAssertEqual(DoubleDefault(key: Defaults.footprintAlpha.key).value, 0) + + let exported = try exportedAlpha() + XCTAssertEqual(exported.double, 0) + XCTAssertNil(exported.float) + Defaults.footprintAlpha.value = 0.8 + Defaults.footprintAlpha.load(from: exported) + XCTAssertEqual(Defaults.effectiveFootprintAlpha, 0) + } + } + + func testLegacyFloatValuesAndDoublePrecisionArePreserved() throws { + for json in [#"{"float":0}"#, #"{"float":0.4}"#, #"{"double":0.123456789012345}"#] { + let imported = try JSONDecoder().decode(CodableDefault.self, from: Data(json.utf8)) + let expected = imported.double ?? Double(imported.float!) + Defaults.footprintAlpha.load(from: imported) + for blurred in [false, true] { + Defaults.footprintBlur.enabled = blurred + XCTAssertEqual(Defaults.effectiveFootprintAlpha, expected) + XCTAssertEqual(DoubleDefault(key: Defaults.footprintAlpha.key).value, expected) + XCTAssertEqual(try exportedAlpha().double, expected) + } + } + } + + func testExportUsesTheEffectiveUnsetAlpha() throws { + for blurred in [false, true] { + UserDefaults.standard.removeObject(forKey: Defaults.footprintAlpha.key) + Defaults.footprintBlur.enabled = blurred + let exported = try exportedAlpha() + XCTAssertEqual(exported.double, blurred ? 0 : 0.3) + XCTAssertNil(UserDefaults.standard.object(forKey: Defaults.footprintAlpha.key)) + + Defaults.footprintAlpha.value = 0.8 + Defaults.footprintAlpha.load(from: exported) + XCTAssertEqual(Defaults.effectiveFootprintAlpha, blurred ? 0 : 0.3) + } + } + + private func exportedAlpha() throws -> CodableDefault { + let json = try XCTUnwrap(Defaults.encoded()) + let config = try XCTUnwrap(Defaults.convert(jsonString: json)) + return try XCTUnwrap(config.defaults[Defaults.footprintAlpha.key]) + } +} + class PositionCyclesTests: XCTestCase { func testSixthsReturnTrue() { @@ -4278,6 +4369,222 @@ class OverlapOffsetGuardsTests: XCTestCase { } } +final class WindowDragGeometryTests: XCTestCase { + private let initial = CGRect(x: -1983, y: -964, width: 1920, height: 1016) + + func testDetectsMovementWhileAccessibilityStillReportsTheInitialFrame() throws { + let moved = initial.offsetBy(dx: 0, dy: 2) + var accessibilityReads = 0 + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: initial, + serverFrame: moved, accessibilityFrame: { + accessibilityReads += 1 + return self.initial + })) + XCTAssertTrue(geometry.isMoving) + XCTAssertTrue(geometry.movedWithoutResizing) + XCTAssertEqual(geometry.currentFrame, moved) + XCTAssertEqual(accessibilityReads, 0) + } + + func testDifferentCoordinateSourcesDoNotCreateFalseMovement() throws { + let accessibility = initial.offsetBy(dx: 1, dy: 1) + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: accessibility, initialServerFrame: initial, + serverFrame: initial, accessibilityFrame: { accessibility })) + XCTAssertFalse(geometry.isMoving) + XCTAssertFalse(geometry.isResizing) + } + + func testUnavailableServerFrameFallsBackToAccessibilityBaseline() throws { + let accessibility = initial.offsetBy(dx: 1, dy: 1) + for unavailable in [nil, CGRect.null, CGRect.zero] as [CGRect?] { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: accessibility, initialServerFrame: initial, + serverFrame: unavailable, accessibilityFrame: { accessibility })) + XCTAssertFalse(geometry.isMoving) + XCTAssertEqual(geometry.currentFrame, accessibility) + } + } + + func testServerFrameWithoutServerBaselineUsesAccessibility() throws { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: nil, + serverFrame: initial.offsetBy(dx: 20, dy: 20), + accessibilityFrame: { self.initial })) + XCTAssertFalse(geometry.isMoving) + } + + func testResizingFromEitherCornerDoesNotTriggerRestore() throws { + let frames = [CGRect(x: initial.minX, y: initial.minY, width: 1800, height: 900), + CGRect(x: initial.minX + 120, y: initial.minY + 100, width: 1800, height: 916)] + for frame in frames { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: initial, + serverFrame: frame, accessibilityFrame: { nil })) + XCTAssertTrue(geometry.isResizing) + XCTAssertFalse(geometry.isMoving) + XCTAssertFalse(geometry.movedWithoutResizing) + } + } + + func testMovingAndChangingSizeAcrossDisplaysStillCountsAsMovement() throws { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: initial, + serverFrame: CGRect(x: 20, y: 30, width: 1400, height: 800), + accessibilityFrame: { nil })) + XCTAssertTrue(geometry.isMoving) + XCTAssertFalse(geometry.movedWithoutResizing) + } + + func testMissingGeometryDoesNotCreateADrag() { + XCTAssertNil(WindowDragGeometry(initialFrame: initial, initialServerFrame: nil, + serverFrame: nil, accessibilityFrame: { nil })) + XCTAssertNil(WindowDragGeometry(initialFrame: initial, initialServerFrame: nil, + serverFrame: nil, accessibilityFrame: { .null })) + } +} + +final class DragRestorePlacementTests: XCTestCase { + private let current = CGRect(x: -1983, y: -950, width: 1920, height: 1016) + private let size = CGSize(width: 1100, height: 650) + + func testDisplayedFrameUsesTheOriginalGrabOffsetInsteadOfANewerMouseSample() { + let initial = CGRect(x: -1983, y: -964, width: 1920, height: 1016) + let displayed = initial.offsetBy(dx: 0, dy: 38) + let reference = DragRestorePlacement.referenceCursor(current: displayed, initial: initial, + mouseDown: CGPoint(x: -255, y: -934), + fallback: CGPoint(x: -255, y: -858)) + XCTAssertEqual(reference, CGPoint(x: -255, y: -896)) + XCTAssertEqual(reference.y - displayed.minY, 30) + } + + func testMissingMouseDownUsesTheAvailableCursorSample() { + let cursor = CGPoint(x: -561, y: -920) + XCTAssertEqual(DragRestorePlacement.referenceCursor(current: current, initial: current, + mouseDown: nil, fallback: cursor), cursor) + } + + func testLeftGrabKeepsNativePosition() { + let restored = DragRestorePlacement.frame(from: current, size: size, cursor: CGPoint(x: -1600, y: -920)) + XCTAssertEqual(restored.origin, current.origin) + XCTAssertEqual(restored.size, size) + } + + func testRightGrabMovesOnlyEnoughToKeepThePointerInside() { + let cursor = CGPoint(x: -561, y: -920) + let restored = DragRestorePlacement.frame(from: current, size: size, cursor: cursor) + XCTAssertEqual(restored.minX, -1629) + XCTAssertEqual(restored.maxX - cursor.x, 32) + XCTAssertTrue(restored.contains(cursor)) + XCTAssertLessThan(restored.maxX, current.maxX) + } + + func testNearbyGrabPointsDoNotSwitchToTheFarRightEdge() { + let cutoff = current.minX + size.width - 32 + let left = DragRestorePlacement.frame(from: current, size: size, cursor: CGPoint(x: cutoff - 1, y: -920)) + let right = DragRestorePlacement.frame(from: current, size: size, cursor: CGPoint(x: cutoff + 1, y: -920)) + XCTAssertEqual(left.minX, current.minX) + XCTAssertEqual(right.minX - left.minX, 1) + } + + func testGrabAtTheFarRightDoesNotPushPastTheOriginalEdge() { + let restored = DragRestorePlacement.frame(from: current, size: size, + cursor: CGPoint(x: current.maxX - 1, y: -920)) + XCTAssertEqual(restored.maxX, current.maxX) + } + + func testGrowingFromAHalfScreenDoesNotReposition() { + let half = CGRect(x: 400, y: 100, width: 800, height: 900) + let restored = DragRestorePlacement.frame(from: half, size: size, cursor: CGPoint(x: 1190, y: 130)) + XCTAssertEqual(restored.origin, half.origin) + } + + func testPlacementIsTheSameRelativeToEitherDisplay() { + let cursor = CGPoint(x: -561, y: -920) + let external = DragRestorePlacement.frame(from: current, size: size, cursor: cursor) + let local = DragRestorePlacement.frame(from: current.offsetBy(dx: 2000, dy: 1000), size: size, + cursor: CGPoint(x: cursor.x + 2000, y: cursor.y + 1000)) + XCTAssertEqual(local, external.offsetBy(dx: 2000, dy: 1000)) + XCTAssertEqual(DragRestorePlacement.frame(from: current, size: size, cursor: nil).origin, current.origin) + } +} + +final class DragRestoreReleaseTests: XCTestCase { + private final class WindowElement: AccessibilityElement { + var currentFrame = CGRect(x: 120, y: 120, width: 800, height: 600) + var writes: [CGRect] = [] + override var frame: CGRect { currentFrame } + override func beginAnimatedAdjustment() -> () -> Void { {} } + override func setAnimationFrame(_ frame: CGRect, resizeOnly: Bool = false) -> Bool { true } + override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) { + currentFrame.size = frame.size + if adjustPosition { currentFrame.origin = frame.origin } + writes.append(currentFrame) + } + } + + private final class Manager: SnappingManager { + var restores = 0 + override func unsnapRestore(windowId: CGWindowID, currentRect: CGRect, cursorLoc: CGPoint?) { + restores += 1 + } + override func snapAreaContainingCursor(priorSnapArea: SnapArea?, at loc: CGPoint) -> SnapArea? { nil } + } + + private func release(dragAlreadyDetected: Bool) throws -> Manager { + let saved = Defaults.windowSnapping.enabled + Defaults.windowSnapping.enabled = false + defer { Defaults.windowSnapping.enabled = saved } + let manager = Manager() + manager.windowElement = WindowElement(AXUIElementCreateSystemWide()) + manager.windowId = .max + manager.initialWindowRect = CGRect(x: 100, y: 100, width: 800, height: 600) + manager.windowMoving = dragAlreadyDetected + let event = try XCTUnwrap(NSEvent.mouseEvent(with: .leftMouseUp, location: .zero, modifierFlags: [], + timestamp: 1, windowNumber: 0, context: nil, + eventNumber: 1, clickCount: 1, pressure: 0)) + manager.handle(event: event) + return manager + } + + func testMouseUpDoesNotRestoreAgainWhenDisplayedSizeHasNotCaughtUp() throws { + let manager = try release(dragAlreadyDetected: true) + XCTAssertEqual(manager.restores, 0) + XCTAssertFalse(manager.windowMoving) + XCTAssertNil(manager.windowId) + XCTAssertNil(manager.windowElement) + } + + func testMouseUpStillRestoresAQuickDragThatHadNotBeenDetected() throws { + let manager = try release(dragAlreadyDetected: false) + XCTAssertEqual(manager.restores, 1) + XCTAssertFalse(manager.windowMoving) + XCTAssertNil(manager.initialWindowRect) + } + + func testDirectAnimationWithoutServerIdentityCompletesOnceAndMouseUpDoesNotRepeatIt() throws { + let saved = Defaults.experimentalWindowAnimations.enabled + Defaults.experimentalWindowAnimations.enabled = true + defer { + WindowAnimator.shared.finish() + Defaults.experimentalWindowAnimations.enabled = saved + } + try XCTSkipUnless(WindowAnimator.enabled, "Window animations are disabled by accessibility settings") + let window = WindowElement(AXUIElementCreateSystemWide()) + XCTAssertNil(window.windowId, "Direct animation does not require a WindowServer identity") + let destination = CGRect(x: 300, y: 120, width: 500, height: 400) + var completedFrames: [CGRect] = [] + WindowAnimator.shared.animate(window, to: destination, duration: 0.18) { completedFrames.append($0) } + + XCTAssertTrue(completedFrames.isEmpty) + XCTAssertEqual(WindowAnimator.shared.destination(for: window), destination) + WindowAnimator.shared.finish() + XCTAssertEqual(completedFrames, [destination]) + XCTAssertNil(WindowAnimator.shared.destination(for: window)) + + let manager = try release(dragAlreadyDetected: true) + + XCTAssertEqual(completedFrames, [destination], "A later native release must not repeat the completed animation") + XCTAssertEqual(manager.restores, 0) + XCTAssertNil(WindowAnimator.shared.destination(for: window)) + } +} + class SnappingManagerSessionTests: XCTestCase { private var savedSnappingEnabled: Bool? @@ -5009,6 +5316,124 @@ class TodoShortcutValidatorTests: XCTestCase { } } +class WindowAnimationPlacementTests: XCTestCase { + private let screen = CGRect(x: 0, y: 29, width: 1403, height: 869) + + private func placement(for zone: CGRect, screen: CGRect? = nil) -> WindowAnimationPlacement { + let screen = screen ?? self.screen + return WindowAnimationPlacement(screenFrame: screen, sharedEdges: zone.sharedEdges(withRect: screen), + constrainToScreen: true, gap: 0) + } + + func testMinimumSizeKeepsEverySelectedEdgeAndCorner() { + let cases: [(CGRect, CGSize, CGPoint)] = [ + (CGRect(x: 702, y: 29, width: 701, height: 869), CGSize(width: 913, height: 869), CGPoint(x: 490, y: 29)), + (CGRect(x: 0, y: 29, width: 701, height: 869), CGSize(width: 913, height: 869), CGPoint(x: 0, y: 29)), + (CGRect(x: 0, y: 29, width: 1403, height: 434), CGSize(width: 1403, height: 600), CGPoint(x: 0, y: 29)), + (CGRect(x: 0, y: 464, width: 1403, height: 434), CGSize(width: 1403, height: 600), CGPoint(x: 0, y: 298)), + (CGRect(x: 0, y: 29, width: 701, height: 434), CGSize(width: 913, height: 600), CGPoint(x: 0, y: 29)), + (CGRect(x: 702, y: 29, width: 701, height: 434), CGSize(width: 913, height: 600), CGPoint(x: 490, y: 29)), + (CGRect(x: 0, y: 464, width: 701, height: 434), CGSize(width: 913, height: 600), CGPoint(x: 0, y: 298)), + (CGRect(x: 702, y: 464, width: 701, height: 434), CGSize(width: 913, height: 600), CGPoint(x: 490, y: 298)) + ] + for (zone, size, expected) in cases { + let result = placement(for: zone).frame(for: zone, actualSize: size, origin: screen, progress: 1) + XCTAssertEqual(result.origin, expected) + XCTAssertEqual(result.size, size) + } + } + + func testWidthLimitDoesNotReverseMotionOrJumpAtTheEnd() { + let origin = CGRect(x: 100, y: 100, width: 1100, height: 650) + let target = CGRect(x: 702, y: 29, width: 701, height: 869) + let placement = placement(for: target) + var previous = origin + for step in 1...100 { + let t = CGFloat(step) / 100 + let requested = CGRect(x: origin.minX + (target.minX - origin.minX) * t, + y: origin.minY + (target.minY - origin.minY) * t, + width: origin.width + (target.width - origin.width) * t, + height: origin.height + (target.height - origin.height) * t) + let result = placement.frame(for: requested, actualSize: CGSize(width: max(913, requested.width), height: requested.height), + origin: origin, progress: t) + XCTAssertGreaterThanOrEqual(result.minX, previous.minX - 0.001) + XCTAssertLessThan(abs(result.minX - previous.minX), 7) + previous = result + } + XCTAssertEqual(previous.minX, 490, accuracy: 0.001) + } + + func testMaximumAndAspectRatioSizesKeepTheRightEdgeAndVerticalCenter() { + let target = CGRect(x: 702, y: 29, width: 701, height: 869) + for size in [CGSize(width: 600, height: 400), CGSize(width: 600, height: 450)] { + let result = placement(for: target).frame(for: target, actualSize: size, origin: screen, progress: 1) + XCTAssertEqual(result.maxX, screen.maxX) + XCTAssertEqual(result.midY, screen.midY, accuracy: 0.5) + XCTAssertEqual(result.size, size) + } + } + + func testCenteredLayoutCentersBothConstrainedDimensions() { + let target = CGRect(x: 351, y: 129, width: 702, height: 669) + let result = placement(for: target).frame(for: target, actualSize: CGSize(width: 913, height: 400), origin: screen, progress: 1) + XCTAssertEqual(result.midX, target.midX, accuracy: 0.5) + XCTAssertEqual(result.midY, target.midY, accuracy: 0.5) + } + + func testDockInsetsAndNegativeDisplayCoordinatesUseTheProvidedWorkArea() { + let screens = [CGRect(x: 45, y: 29, width: 1395, height: 869), screen, + CGRect(x: 0, y: 29, width: 1440, height: 831), + CGRect(x: -1600, y: -870, width: 1600, height: 900)] + for screen in screens { + let target = CGRect(x: screen.midX, y: screen.minY, width: screen.width / 2, height: screen.height) + let result = placement(for: target, screen: screen).frame(for: target, actualSize: CGSize(width: 913, height: 600), origin: screen, progress: 1) + XCTAssertEqual(result.maxX, screen.maxX) + XCTAssertEqual(result.midY, screen.midY, accuracy: 0.5) + } + } + + func testInitiallyOutOfBoundsWindowIsNotClippedOnItsFirstFrame() { + let origin = CGRect(x: 600, y: 29, width: 913, height: 700) + let target = CGRect(x: 702, y: 29, width: 701, height: 869) + let placement = placement(for: target) + XCTAssertEqual(placement.frame(for: origin, actualSize: origin.size, origin: origin, progress: 0), origin) + let final = placement.frame(for: target, actualSize: CGSize(width: 913, height: 869), origin: origin, progress: 1) + XCTAssertEqual(final.maxX, screen.maxX) + } + + func testGapCorrectionMatchesNormalWindowBounds() { + let result = WindowFrameBounds.constrained(CGRect(x: 1000, y: 0, width: 600, height: 500), to: screen, gap: 10) + XCTAssertEqual(result.origin, CGPoint(x: 793, y: 39)) + XCTAssertTrue(WindowFrameBounds.constrained(.null, to: screen, gap: 10).isNull) + } + + func testFinalFrameIsAppliedBeforeCleanupAndOnlyOnce() { + var events: [String] = [] + let destination = CGRect(x: 700, y: 29, width: 700, height: 869) + let animation = WindowFrameAnimation(from: .zero, to: destination, startTime: 0, duration: 0.34, + write: { _, _ in events.append("tick"); return true }, + finalize: { frame in XCTAssertEqual(frame, destination); events.append("final") }, + cleanup: { events.append("cleanup") }, + completion: { _ in events.append("completion") }) + animation.tick(at: 0.1) + animation.tick(at: 0.5) + animation.finish() + XCTAssertEqual(events, ["tick", "final", "cleanup", "completion"]) + } + + func testCancellationDoesNotWriteTheDestination() { + var events: [String] = [] + let animation = WindowFrameAnimation(from: .zero, to: screen, startTime: 0, duration: 0.34, + write: { _, _ in true }, + finalize: { _ in events.append("final") }, + cleanup: { events.append("cleanup") }, + completion: { _ in events.append("completion") }) + animation.cancel() + animation.finish() + XCTAssertEqual(events, ["cleanup"]) + } +} + class ClampedWindowAlignerTests: XCTestCase { // Screen 2000x1200 at origin. Coordinates are already screen-flipped (window space): @@ -5324,6 +5749,7 @@ final class WindowSizeConstraintExecutionTests: XCTestCase { let settings: [(Default, CodableDefault)] = [ (Defaults.subsequentExecutionMode, CodableDefault(int: SubsequentExecutionMode.none.rawValue)), (Defaults.cooperativeCornerResize, CodableDefault(bool: false)), + (Defaults.experimentalWindowAnimations, CodableDefault(bool: false)), (Defaults.useCursorScreenDetection, CodableDefault(bool: false)), (Defaults.moveFixedSizeToEdge, CodableDefault(int: EdgeAlignment.edgesAndCorners.rawValue)), (Defaults.gapSize, CodableDefault(float: 0)), @@ -5501,7 +5927,7 @@ final class WindowSizeConstraintExecutionTests: XCTestCase { override func getWindowId() -> CGWindowID? { nil } override func isResizable() -> Bool { true } - override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true) { + override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) { currentFrame = frame if frame.size == targetSize { resizeAttempts += 1 @@ -5530,8 +5956,8 @@ final class WindowSizeConstraintExecutionTests: XCTestCase { override func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect) {} - override func postProcess(result: ResultParameters, resultingRect: CGRect) { - super.postProcess(result: result, resultingRect: resultingRect) + override func postProcess(result: ResultParameters, resultingRect: CGRect, incrementCount: Bool = true) { + super.postProcess(result: result, resultingRect: resultingRect, incrementCount: incrementCount) didFinish?() } } @@ -5636,8 +6062,8 @@ final class CrossDisplayResizeTests: XCTestCase { override func getWindowId() -> CGWindowID? { nil } override func isResizable() -> Bool { true } - override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true) { - currentFrame = frame + override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) { + currentFrame = CGRect(origin: adjustPosition ? frame.origin : currentFrame.origin, size: frame.size) if frame.size == target.size { resizeAttempts += 1 if resizeAttempts <= 2 { @@ -5652,7 +6078,7 @@ final class CrossDisplayResizeTests: XCTestCase { override func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect) {} - override func postProcess(result: ResultParameters, resultingRect: CGRect) { + override func postProcess(result: ResultParameters, resultingRect: CGRect, incrementCount: Bool = true) { didFinish?(result, resultingRect) } } diff --git a/TerminalCommands.md b/TerminalCommands.md index 4ea0562ff..d8f160b50 100644 --- a/TerminalCommands.md +++ b/TerminalCommands.md @@ -43,6 +43,7 @@ The preferences window is purposefully slim, but there's a lot that can be modif - [Attempt to preserve window position when moving to another display](#attempt-to-preserve-window-position-when-moving-to-another-display) - [Offset cycling position when overlapping another window](#offset-cycling-position-when-overlapping-another-window) - [Move windows that can't fill the snap area to the edge](#move-windows-that-cant-fill-the-snap-area-to-the-edge) +- [Apps using native resize during window animations](#apps-using-native-resize-during-window-animations) ## Keyboard Shortcuts @@ -305,13 +306,13 @@ defaults write com.knollsoft.Rectangle cascadeActiveApp -dict-add keyCode -float ## Modify the "footprint" displayed for drag to snap area -Adjust the alpha (transparency). Default is 0.3. +Adjust the alpha (transparency). Default is 0.3, or 0 for the blurred preview, where it controls tint opacity. ```bash defaults write com.knollsoft.Rectangle footprintAlpha -float ``` -Change the border width. Default is 2 (used to be 1). +Change the border width. Default is 2 (used to be 1), or 1 for the blurred preview. A custom value overrides either default. ```bash defaults write com.knollsoft.Rectangle footprintBorderWidth -float @@ -323,13 +324,13 @@ Disable the fade. defaults write com.knollsoft.Rectangle footprintFade -int 2 ``` -Change the color. +Change the color. With blur enabled, this sets the tint color. Delete `footprintColor` to restore the automatic light/dark tint color. ```bash defaults write com.knollsoft.Rectangle footprintColor -string "{\"red\":0,\"blue\":0.5,\"green\":0.5}" ``` -Change the animation duration. The value is a multiplier. Default is 0 (no animation). +Change the animation duration. The value is a multiplier. Default is 0 (no movement animation). ```bash defaults write com.knollsoft.Rectangle footprintAnimationDurationMultiplier -float @@ -654,3 +655,13 @@ defaults write com.knollsoft.Rectangle moveFixedSizeToEdge -int 1 # align edges defaults write com.knollsoft.Rectangle moveFixedSizeToEdge -int 2 # align corners only, center halves/sides defaults write com.knollsoft.Rectangle moveFixedSizeToEdge -int 3 # center within the snap area ``` + +## Apps using native resize during window animations + +Certain applications (such as IINA) enforce aspect ratios or custom constraints asynchronously when resized. During direct window animations, continuous intermediate frame adjustments can fight with the application's internal aspect-ratio corrections. For apps in this list, Rectangle resizes the window once natively, allows the animation duration for the app to asynchronously settle its aspect ratio, and then aligns the final achieved size using a position-only write. + +The default list contains IINA (`com.colliderli.iina`). You can configure this list of bundle IDs: + +```bash +defaults write com.knollsoft.Rectangle directAnimationNativeResizeApps -string "[\"com.colliderli.iina\", \"org.videolan.vlc\"]" +```