diff --git a/Free Ruler/Base.lproj/PreferencesController.xib b/Free Ruler/Base.lproj/PreferencesController.xib index 1c0a9fb..cfb4b56 100644 --- a/Free Ruler/Base.lproj/PreferencesController.xib +++ b/Free Ruler/Base.lproj/PreferencesController.xib @@ -18,7 +18,7 @@ - + @@ -31,15 +31,15 @@ - + - + - + - + @@ -59,7 +59,6 @@ - diff --git a/Free Ruler/Prefs.swift b/Free Ruler/Prefs.swift index 8551c43..68a5747 100644 --- a/Free Ruler/Prefs.swift +++ b/Free Ruler/Prefs.swift @@ -176,7 +176,7 @@ extension Prefs { } static var defaultGroupRulers: Bool { - return true + return false } func applyDefaults(from settings: RulerSettings, layout: RulerLayoutState? = nil) { diff --git a/Free Ruler/RulerWindow.swift b/Free Ruler/RulerWindow.swift index 29053d1..21c4569 100644 --- a/Free Ruler/RulerWindow.swift +++ b/Free Ruler/RulerWindow.swift @@ -1352,6 +1352,10 @@ final class RulerController: NSWindowController, NSWindowDelegate, NotificationO captureStateFromWindow() } + func captureCurrentState() { + captureStateFromWindow() + } + func resetPosition() { state.settings.zeroCorner = Prefs.defaultZeroCorner state.layout = RulerLayoutState.defaults( @@ -1605,6 +1609,7 @@ final class RulerManager { private struct GroupedDragState { let draggedRulerID: UUID let framesByRulerID: [UUID: NSRect] + let attachedRulerIDs: Set } private let controllerFactory: ControllerFactory @@ -1758,18 +1763,26 @@ final class RulerManager { } func beginGroupedDrag(from controller: RulerController) { + if let groupedDragState = groupedDragState { + detachGroupedDragFollowers(groupedDragState) + } + guard prefs.groupRulers, controllers.contains(where: { $0 === controller }) else { groupedDragState = nil return } + let visibleControllers = controllers.filter(\.isVisible) groupedDragState = GroupedDragState( draggedRulerID: controller.state.id, framesByRulerID: Dictionary( - uniqueKeysWithValues: controllers - .filter(\.isVisible) + uniqueKeysWithValues: visibleControllers .map { ($0.state.id, $0.rulerWindow.frame) } + ), + attachedRulerIDs: attachGroupedDragFollowers( + to: controller, + visibleControllers: visibleControllers ) ) } @@ -1794,20 +1807,41 @@ final class RulerManager { isApplyingGroupedDrag = false } + var movedFollower = false for otherController in controllers where otherController !== controller && otherController.isVisible { guard var frame = groupedDragState.framesByRulerID[otherController.state.id] else { continue } + guard !rulerMovesWithGroupedDragParent( + otherController, + draggedController: controller, + groupedDragState: groupedDragState + ) else { + continue + } frame.origin.x += offset.width frame.origin.y += offset.height otherController.move(to: frame) + movedFollower = true } - notifyStateChanged() + if movedFollower { + notifyStateChanged() + } } func finishGroupedDrag(from controller: RulerController) { + guard let groupedDragState = groupedDragState else { return } + guard groupedDragState.draggedRulerID == controller.state.id else { + detachGroupedDragFollowers(groupedDragState) + self.groupedDragState = nil + return + } + syncGroupedDrag(from: controller) - groupedDragState = nil + detachGroupedDragFollowers(from: controller, groupedDragState: groupedDragState) + captureGroupedDragFollowerStates(excluding: controller, groupedDragState: groupedDragState) + self.groupedDragState = nil + notifyStateChanged() } func controller(containing window: NSWindow?) -> RulerController? { @@ -1846,6 +1880,64 @@ final class RulerManager { } } + private func attachGroupedDragFollowers( + to draggedController: RulerController, + visibleControllers: [RulerController] + ) -> Set { + let draggedWindow = draggedController.rulerWindow + var attachedRulerIDs = Set() + + for followerController in visibleControllers where followerController !== draggedController { + let followerWindow = followerController.rulerWindow + guard followerWindow.parent == nil else { continue } + + draggedWindow.addChildWindow(followerWindow, ordered: .below) + attachedRulerIDs.insert(followerController.state.id) + } + + return attachedRulerIDs + } + + private func detachGroupedDragFollowers(_ groupedDragState: GroupedDragState) { + guard let draggedController = controller(id: groupedDragState.draggedRulerID) else { return } + + detachGroupedDragFollowers(from: draggedController, groupedDragState: groupedDragState) + } + + private func detachGroupedDragFollowers( + from draggedController: RulerController, + groupedDragState: GroupedDragState + ) { + let draggedWindow = draggedController.rulerWindow + + for rulerID in groupedDragState.attachedRulerIDs { + guard let followerController = controller(id: rulerID), + followerController.rulerWindow.parent === draggedWindow else { continue } + + draggedWindow.removeChildWindow(followerController.rulerWindow) + } + } + + private func rulerMovesWithGroupedDragParent( + _ followerController: RulerController, + draggedController: RulerController, + groupedDragState: GroupedDragState + ) -> Bool { + return groupedDragState.attachedRulerIDs.contains(followerController.state.id) + || followerController.rulerWindow.parent === draggedController.rulerWindow + } + + private func captureGroupedDragFollowerStates( + excluding draggedController: RulerController, + groupedDragState: GroupedDragState + ) { + for followerController in controllers where followerController !== draggedController { + guard groupedDragState.framesByRulerID[followerController.state.id] != nil else { continue } + + followerController.captureCurrentState() + } + } + private func staggeredState(from defaultState: RulerInstanceState) -> RulerInstanceState { var state = defaultState let offset = Ruler.thickness / 2 diff --git a/Free Ruler/UITestSupport+App.swift b/Free Ruler/UITestSupport+App.swift index 5d2765b..f073020 100644 --- a/Free Ruler/UITestSupport+App.swift +++ b/Free Ruler/UITestSupport+App.swift @@ -18,7 +18,7 @@ extension UITestSupport { "NSWindow Frame preferencesWindow", ].forEach(defaults.removeObject(forKey:)) - prefs.groupRulers = true + prefs.groupRulers = Prefs.defaultGroupRulers prefs.floatRulers = true prefs.rulerShadow = false prefs.foregroundOpacity = 90 diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index f826779..d2065a8 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -251,6 +251,10 @@ final class RulerCoreTests: XCTestCase { movedFirstFrame.origin.y += dragOffset.height appDelegate.rulerManager.beginGroupedDrag(from: first) + XCTAssertTrue(first.rulerWindow.childWindows?.contains(second.rulerWindow) ?? false) + XCTAssertTrue(second.rulerWindow.parent === first.rulerWindow) + XCTAssertFalse(first.rulerWindow.childWindows?.contains(hidden.rulerWindow) ?? false) + first.move(to: movedFirstFrame) appDelegate.rulerManager.syncGroupedDrag(from: first) appDelegate.rulerManager.finishGroupedDrag(from: first) @@ -266,6 +270,8 @@ final class RulerCoreTests: XCTestCase { for: .horizontal ) ) + XCTAssertFalse(first.rulerWindow.childWindows?.contains(second.rulerWindow) ?? false) + XCTAssertNil(second.rulerWindow.parent) } } } @@ -756,7 +762,7 @@ final class RulerCoreTests: XCTestCase { prefs.foregroundOpacity = 42 prefs.backgroundOpacity = 21 prefs.floatRulers = false - prefs.groupRulers = false + prefs.groupRulers = true prefs.rulerShadow = true prefs.zeroCorner = .bottomRight prefs.defaultHorizontalLength = 333 @@ -1452,6 +1458,18 @@ final class RulerCoreTests: XCTestCase { } } + func testGroupRulersDefaultsOffAndPersistsToUserDefaults() { + withRestoredRulerPreferences { + XCTAssertFalse(Prefs.defaultGroupRulers) + + prefs.groupRulers = true + XCTAssertTrue(UserDefaults.standard.bool(forKey: "groupRulers")) + + prefs.groupRulers = false + XCTAssertFalse(UserDefaults.standard.bool(forKey: "groupRulers")) + } + } + func testZeroCornerGeometryDerivesOrientationTraits() { let cases: [ ( diff --git a/FreeRulerUITests/FreeRulerUITests.swift b/FreeRulerUITests/FreeRulerUITests.swift index ffc5b22..4f91c00 100644 --- a/FreeRulerUITests/FreeRulerUITests.swift +++ b/FreeRulerUITests/FreeRulerUITests.swift @@ -54,7 +54,7 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) - XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) horizontalRuler.click() app.typeKey("v", modifierFlags: []) @@ -67,7 +67,7 @@ final class FreeRulerUITests: XCTestCase { matches: horizontalRuler.frame, message: "Ruler window should shrink to the visible horizontal ruler frame" ) - XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) app.typeKey("v", modifierFlags: []) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) @@ -83,20 +83,20 @@ final class FreeRulerUITests: XCTestCase { matches: verticalRuler.frame, message: "Ruler window should shrink to the visible vertical ruler frame" ) - XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) } func testGroupRulersKeyboardCommandTogglesGroupedDraggingWithoutChangingRulerWindow() { XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) - XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) let originalFrame = rulerWindow.frame horizontalRuler.click() app.typeKey("g", modifierFlags: []) - XCTAssertTrue(waitForPreference("groupRulers", equals: false)) + XCTAssertTrue(waitForPreference("groupRulers", equals: true)) XCTAssertTrue(rulerWindow.waitForExistence(timeout: 2)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) @@ -107,14 +107,14 @@ final class FreeRulerUITests: XCTestCase { ) app.typeKey("g", modifierFlags: []) - XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) XCTAssertTrue(rulerWindow.waitForExistence(timeout: 2)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) verticalRuler.click() app.typeKey("g", modifierFlags: []) - XCTAssertTrue(waitForPreference("groupRulers", equals: false)) + XCTAssertTrue(waitForPreference("groupRulers", equals: true)) XCTAssertTrue(rulerWindow.waitForExistence(timeout: 2)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) @@ -396,7 +396,7 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForNonExistence(timeout: 2)) - XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) } private func isolateVerticalWing() { @@ -405,7 +405,7 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(horizontalRuler.waitForNonExistence(timeout: 2)) - XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) } private func resetRulerCursorScenario() { @@ -414,7 +414,7 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(rulerWindow.waitForVisibleFrame(timeout: 1)) XCTAssertTrue(horizontalRuler.waitForVisibleFrame(timeout: 1)) XCTAssertTrue(verticalRuler.waitForVisibleFrame(timeout: 1)) - XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) } private func assertCursorSequence(on ruler: XCUIElement, label: String) {