From 8db4523dff77dc044f8c22cd942308deae231d3b Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 13:35:37 -0400 Subject: [PATCH] Drag visible rulers together when grouped --- Free Ruler/AppDelegate.swift | 6 +- Free Ruler/Base.lproj/MainMenu.xib | 12 ++-- Free Ruler/GroupedRulerWindow.swift | 83 ++++++++++++++++++++++ Free Ruler/de.lproj/MainMenu.strings | 8 +-- Free Ruler/es.lproj/MainMenu.strings | 8 +-- Free Ruler/fi.lproj/MainMenu.strings | 8 +-- Free Ruler/ja.lproj/MainMenu.strings | 6 +- Free Ruler/zh-hans.lproj/MainMenu.strings | 6 +- FreeRulerTests/RulerCoreTests.swift | 85 ++++++++++++++++++++--- 9 files changed, 182 insertions(+), 40 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 7f7ed70..a6ae4af 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -525,7 +525,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func applyRulerWindowMode(showRulersIfNeeded: Bool = false) { if rulerManager.hasRulers { - rulerManager.showAll() updateMouseTickTimer() return } @@ -745,8 +744,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @IBAction func toggleGroupRulers(_ sender: Any) { - guard !rulerManager.hasRulers else { return } - prefs.groupRulers = !prefs.groupRulers showGroupRulersHotkeyBezel(on: bezelScreen(for: sender)) } @@ -1034,7 +1031,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { case kVK_ANSI_F: toggleFloatRulers(sender) case kVK_ANSI_G: - guard !rulerManager.hasRulers else { return true } toggleGroupRulers(sender) case kVK_ANSI_S: toggleRulerShadow(sender) @@ -1120,7 +1116,7 @@ extension AppDelegate: NSMenuItemValidation { case #selector(closeKeyWindow(_:)): return rulerManager.activeController != nil || NSApp.keyWindow?.isVisible == true case #selector(toggleGroupRulers(_:)): - return !rulerManager.hasRulers + return true case #selector(toggleHorizontalRuler(_:)): if let controller = rulerManager.activeController { let isVisible = controller.state.isWingVisible(.horizontal) diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index 7db7dd7..45df119 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -218,16 +218,16 @@ - + - + - + - + @@ -237,13 +237,13 @@ - + - + diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 2ed0e64..7615371 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -1096,6 +1096,9 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi let groupedWindow: GroupedRulerWindow var state: RulerInstanceState var onBecameActive: ((GroupedRulerController) -> Void)? + var onDragStarted: ((GroupedRulerController) -> Void)? + var onDragged: ((GroupedRulerController) -> Void)? + var onDragFinished: ((GroupedRulerController) -> Void)? var onStateChanged: ((GroupedRulerController) -> Void)? private var keyListener: Any? @@ -1319,6 +1322,11 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi notifyStateChanged() } + func move(to frame: NSRect) { + groupedWindow.setFrame(frame, display: false) + captureStateFromWindow() + } + func resetPosition() { state.settings.zeroCorner = Prefs.defaultZeroCorner state.layout = RulerLayoutState.defaults( @@ -1489,6 +1497,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi ) ) captureStateFromWindow() + onDragged?(self) mouseInteraction.windowDidMove(isLeftMouseButtonPressed: isLeftMouseButtonPressed()) } @@ -1511,6 +1520,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi override func mouseDown(with event: NSEvent) { mouseInteraction.mouseDown(with: event) + onDragStarted?(self) } override func mouseUp(with event: NSEvent) { @@ -1521,6 +1531,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi if mouseInteraction.finishMouseDrag(with: event) { syncRulerWindowFrames(persistAutosave: true) captureStateFromWindow() + onDragFinished?(self) } } @@ -1649,11 +1660,18 @@ extension GroupedRulerController { final class RulerManager { typealias ControllerFactory = (RulerInstanceState) -> GroupedRulerController + private struct GroupedDragState { + let draggedRulerID: UUID + let framesByRulerID: [UUID: NSRect] + } + private let controllerFactory: ControllerFactory private(set) var controllers: [GroupedRulerController] = [] private(set) var activeRulerID: UUID? var onActiveControllerChanged: ((GroupedRulerController?) -> Void)? var onStateChanged: ((RulerManager) -> Void)? + private var groupedDragState: GroupedDragState? + private var isApplyingGroupedDrag = false init( initialStates: [RulerInstanceState] = [], @@ -1770,6 +1788,59 @@ final class RulerManager { notifyStateChanged() } + func beginGroupedDrag(from controller: GroupedRulerController) { + guard prefs.groupRulers, + controllers.contains(where: { $0 === controller }) else { + groupedDragState = nil + return + } + + groupedDragState = GroupedDragState( + draggedRulerID: controller.state.id, + framesByRulerID: Dictionary( + uniqueKeysWithValues: controllers + .filter(\.isVisible) + .map { ($0.state.id, $0.groupedWindow.frame) } + ) + ) + } + + func syncGroupedDrag(from controller: GroupedRulerController) { + guard prefs.groupRulers, + !isApplyingGroupedDrag, + let groupedDragState = groupedDragState, + groupedDragState.draggedRulerID == controller.state.id, + let originalDraggedFrame = groupedDragState.framesByRulerID[controller.state.id] else { + return + } + + let offset = NSSize( + width: controller.groupedWindow.frame.minX - originalDraggedFrame.minX, + height: controller.groupedWindow.frame.minY - originalDraggedFrame.minY + ) + guard offset.width != 0 || offset.height != 0 else { return } + + isApplyingGroupedDrag = true + defer { + isApplyingGroupedDrag = false + } + + for otherController in controllers where otherController !== controller && otherController.isVisible { + guard var frame = groupedDragState.framesByRulerID[otherController.state.id] else { continue } + + frame.origin.x += offset.width + frame.origin.y += offset.height + otherController.move(to: frame) + } + + notifyStateChanged() + } + + func finishGroupedDrag(from controller: GroupedRulerController) { + syncGroupedDrag(from: controller) + groupedDragState = nil + } + func controller(containing window: NSWindow?) -> GroupedRulerController? { guard let window = window else { return nil } @@ -1785,6 +1856,18 @@ final class RulerManager { guard let controller = controller else { return } self?.markActive(controller) } + controller.onDragStarted = { [weak self, weak controller] _ in + guard let controller = controller else { return } + self?.beginGroupedDrag(from: controller) + } + controller.onDragged = { [weak self, weak controller] _ in + guard let controller = controller else { return } + self?.syncGroupedDrag(from: controller) + } + controller.onDragFinished = { [weak self, weak controller] _ in + guard let controller = controller else { return } + self?.finishGroupedDrag(from: controller) + } controller.onStateChanged = { [weak self, weak controller] _ in guard let controller = controller, self?.activeRulerID == controller.state.id else { return } diff --git a/Free Ruler/de.lproj/MainMenu.strings b/Free Ruler/de.lproj/MainMenu.strings index 8e8010d..bf8c692 100644 --- a/Free Ruler/de.lproj/MainMenu.strings +++ b/Free Ruler/de.lproj/MainMenu.strings @@ -14,8 +14,8 @@ /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; */ "6dh-zS-Vam.title" = "Wiederholen"; -/* Class = "NSMenuItem"; title = "Reset Rulers"; ObjectID = "6ph-5N-O9R"; */ -"6ph-5N-O9R.title" = "Lineale zurücksetzen"; +/* Class = "NSMenuItem"; title = "Reset Ruler Position"; ObjectID = "6ph-5N-O9R"; */ +"6ph-5N-O9R.title" = "Linealposition zurücksetzen"; /* Class = "NSMenuItem"; title = "Group Rulers"; ObjectID = "7Ga-Fb-LLc"; */ "7Ga-Fb-LLc.title" = "Lineale gruppieren"; @@ -116,8 +116,8 @@ /* Class = "NSMenu"; title = "Services"; ObjectID = "hz9-B4-Xy5"; */ "hz9-B4-Xy5.title" = "Dienste"; -/* Class = "NSMenuItem"; title = "Align Rulers at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ -"iKV-uW-hwy.title" = "Lineale an Zeigerposition ausrichten"; +/* Class = "NSMenuItem"; title = "Align Ruler at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ +"iKV-uW-hwy.title" = "Lineal an Zeigerposition ausrichten"; /* Class = "NSMenuItem"; title = "Delete"; ObjectID = "pa3-QI-u2k"; */ "pa3-QI-u2k.title" = "Löschen"; diff --git a/Free Ruler/es.lproj/MainMenu.strings b/Free Ruler/es.lproj/MainMenu.strings index 6ff7d35..da2f752 100644 --- a/Free Ruler/es.lproj/MainMenu.strings +++ b/Free Ruler/es.lproj/MainMenu.strings @@ -17,8 +17,8 @@ /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; */ "6dh-zS-Vam.title" = "Rehacer"; -/* Class = "NSMenuItem"; title = "Reset Rulers"; ObjectID = "6ph-5N-O9R"; */ -"6ph-5N-O9R.title" = "Restablecer reglas"; +/* Class = "NSMenuItem"; title = "Reset Ruler Position"; ObjectID = "6ph-5N-O9R"; */ +"6ph-5N-O9R.title" = "Restablecer posición de la regla"; /* Class = "NSMenuItem"; title = "Group Rulers"; ObjectID = "7Ga-Fb-LLc"; */ "7Ga-Fb-LLc.title" = "Agrupar reglas"; @@ -122,8 +122,8 @@ /* Class = "NSMenuItem"; title = "Unit"; ObjectID = "iDP-2z-irv"; */ "iDP-2z-irv.title" = "Unidad"; -/* Class = "NSMenuItem"; title = "Align Rulers at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ -"iKV-uW-hwy.title" = "Alinear reglas con el ratón"; +/* Class = "NSMenuItem"; title = "Align Ruler at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ +"iKV-uW-hwy.title" = "Alinear regla con el ratón"; /* Class = "NSMenuItem"; title = "Inches"; ObjectID = "lt1-Hj-2TR"; */ "lt1-Hj-2TR.title" = "Pulgadas"; diff --git a/Free Ruler/fi.lproj/MainMenu.strings b/Free Ruler/fi.lproj/MainMenu.strings index 36741dd..75be73a 100644 --- a/Free Ruler/fi.lproj/MainMenu.strings +++ b/Free Ruler/fi.lproj/MainMenu.strings @@ -14,8 +14,8 @@ /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; */ "6dh-zS-Vam.title" = "Tee sittenkin"; -/* Class = "NSMenuItem"; title = "Reset Rulers"; ObjectID = "6ph-5N-O9R"; */ -"6ph-5N-O9R.title" = "Nollaa viivaimet"; +/* Class = "NSMenuItem"; title = "Reset Ruler Position"; ObjectID = "6ph-5N-O9R"; */ +"6ph-5N-O9R.title" = "Nollaa viivaimen sijainti"; /* Class = "NSMenuItem"; title = "Group Rulers"; ObjectID = "7Ga-Fb-LLc"; */ "7Ga-Fb-LLc.title" = "Ryhmitä viivaimet"; @@ -116,8 +116,8 @@ /* Class = "NSMenu"; title = "Services"; ObjectID = "hz9-B4-Xy5"; */ "hz9-B4-Xy5.title" = "Palvelut"; -/* Class = "NSMenuItem"; title = "Align Rulers at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ -"iKV-uW-hwy.title" = "Kohdista viivaimet hiiren sijaintiin"; +/* Class = "NSMenuItem"; title = "Align Ruler at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ +"iKV-uW-hwy.title" = "Kohdista viivain hiiren sijaintiin"; /* Class = "NSMenuItem"; title = "Delete"; ObjectID = "pa3-QI-u2k"; */ "pa3-QI-u2k.title" = "Poista"; diff --git a/Free Ruler/ja.lproj/MainMenu.strings b/Free Ruler/ja.lproj/MainMenu.strings index 653914e..4e24ca3 100644 --- a/Free Ruler/ja.lproj/MainMenu.strings +++ b/Free Ruler/ja.lproj/MainMenu.strings @@ -17,8 +17,8 @@ /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; */ "6dh-zS-Vam.title" = "やり直し"; -/* Class = "NSMenuItem"; title = "Reset Rulers"; ObjectID = "6ph-5N-O9R"; */ -"6ph-5N-O9R.title" = "定規をリセット"; +/* Class = "NSMenuItem"; title = "Reset Ruler Position"; ObjectID = "6ph-5N-O9R"; */ +"6ph-5N-O9R.title" = "定規の位置をリセット"; /* Class = "NSMenuItem"; title = "Group Rulers"; ObjectID = "7Ga-Fb-LLc"; */ "7Ga-Fb-LLc.title" = "定規をグループ化"; @@ -122,7 +122,7 @@ /* Class = "NSMenuItem"; title = "Unit"; ObjectID = "iDP-2z-irv"; */ "iDP-2z-irv.title" = "単位"; -/* Class = "NSMenuItem"; title = "Align Rulers at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ +/* Class = "NSMenuItem"; title = "Align Ruler at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ "iKV-uW-hwy.title" = "マウスの位置に定規を移動"; /* Class = "NSMenuItem"; title = "Inches"; ObjectID = "lt1-Hj-2TR"; */ diff --git a/Free Ruler/zh-hans.lproj/MainMenu.strings b/Free Ruler/zh-hans.lproj/MainMenu.strings index 9cd0f65..c536982 100644 --- a/Free Ruler/zh-hans.lproj/MainMenu.strings +++ b/Free Ruler/zh-hans.lproj/MainMenu.strings @@ -14,8 +14,8 @@ /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; */ "6dh-zS-Vam.title" = "重做"; -/* Class = "NSMenuItem"; title = "Reset Rulers"; ObjectID = "6ph-5N-O9R"; */ -"6ph-5N-O9R.title" = "重置尺子"; +/* Class = "NSMenuItem"; title = "Reset Ruler Position"; ObjectID = "6ph-5N-O9R"; */ +"6ph-5N-O9R.title" = "重置尺子位置"; /* Class = "NSMenuItem"; title = "Group Rulers"; ObjectID = "7Ga-Fb-LLc"; */ "7Ga-Fb-LLc.title" = "组合尺子"; @@ -116,7 +116,7 @@ /* Class = "NSMenu"; title = "Services"; ObjectID = "hz9-B4-Xy5"; */ "hz9-B4-Xy5.title" = "服务"; -/* Class = "NSMenuItem"; title = "Align Rulers at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ +/* Class = "NSMenuItem"; title = "Align Ruler at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ "iKV-uW-hwy.title" = "将尺子与鼠标位置对齐"; /* Class = "NSMenuItem"; title = "Delete"; ObjectID = "pa3-QI-u2k"; */ diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 2a6d339..594574c 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -189,6 +189,57 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(third.state.layout.zeroPoint.y, first.state.layout.zeroPoint.y - (staggerOffset * 2)) } + func testRulerManagerMovesVisibleRulersTogetherDuringGroupedDrag() { + withRestoredRulerPreferences { + withRestoredRulerSetState { + prefs.groupRulers = true + prefs.zeroCorner = .topLeft + let appDelegate = AppDelegate() + let first = appDelegate.rulerManager.createRuler( + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + let second = appDelegate.rulerManager.createRuler( + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + let hidden = appDelegate.rulerManager.createRuler( + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + defer { + first.hide() + second.hide() + hidden.hide() + } + first.show() + second.show() + + let firstFrame = first.groupedWindow.frame + let secondFrame = second.groupedWindow.frame + let hiddenFrame = hidden.groupedWindow.frame + let dragOffset = NSSize(width: 37, height: -24) + var movedFirstFrame = firstFrame + movedFirstFrame.origin.x += dragOffset.width + movedFirstFrame.origin.y += dragOffset.height + + appDelegate.rulerManager.beginGroupedDrag(from: first) + first.move(to: movedFirstFrame) + appDelegate.rulerManager.syncGroupedDrag(from: first) + appDelegate.rulerManager.finishGroupedDrag(from: first) + + XCTAssertEqual(first.groupedWindow.frame, movedFirstFrame) + XCTAssertEqual(second.groupedWindow.frame.minX, secondFrame.minX + dragOffset.width) + XCTAssertEqual(second.groupedWindow.frame.minY, secondFrame.minY + dragOffset.height) + XCTAssertEqual(hidden.groupedWindow.frame, hiddenFrame) + XCTAssertEqual( + second.state.layout.zeroPoint, + ZeroCornerGeometry(zeroCorner: second.state.settings.zeroCorner).zeroPoint( + in: second.groupedWindow.screenFrame(for: .horizontal), + for: .horizontal + ) + ) + } + } + } + func testRulerContextMenuActivatesClickedRulerAndShowsSettingsCommand() { withInstalledAppDelegate { appDelegate in let manager = appDelegate.rulerManager @@ -3332,26 +3383,32 @@ final class RulerCoreTests: XCTestCase { } } - func testManagedGroupHotkeyDoesNotToggleRetiredGroupedMode() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } - - prefs.groupRulers = true + func testManagedGroupHotkeyTogglesGroupedDraggingMode() { + withRestoredRulerPreferences { + prefs.groupRulers = false let appDelegate = AppDelegate() - appDelegate.showRulers() + let controller = appDelegate.rulerManager.createRuler() + defer { + controller.hide() + } XCTAssertTrue( appDelegate.performRulerHotkey( keyCode: kVK_ANSI_G, modifierFlags: [], - sender: appDelegate.groupedRulerController! + sender: controller ) ) - XCTAssertTrue(prefs.groupRulers) - XCTAssertEqual(appDelegate.rulerManager.controllers.count, 1) - appDelegate.groupedRulerController?.hide() + + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_G, + modifierFlags: [], + sender: controller + ) + ) + XCTAssertFalse(prefs.groupRulers) } } @@ -3499,10 +3556,16 @@ final class RulerCoreTests: XCTestCase { action: #selector(AppDelegate.toggleVerticalRuler(_:)), keyEquivalent: "" ) + let groupItem = NSMenuItem( + title: "", + action: #selector(AppDelegate.toggleGroupRulers(_:)), + keyEquivalent: "" + ) XCTAssertTrue(appDelegate.validateMenuItem(closeItem)) XCTAssertFalse(appDelegate.validateMenuItem(horizontalItem)) XCTAssertTrue(appDelegate.validateMenuItem(verticalItem)) + XCTAssertTrue(appDelegate.validateMenuItem(groupItem)) } func testUngroupedHorizontalFlipDoesNotMoveRulerWindows() {