From b398c57b6d7674319f8d50f034289009d0094c25 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 28 May 2026 23:34:21 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Fix Decodex App login panel jump","authority":"manual"} --- apps/decodex-app/Package.swift | 1 + .../DecodexApp/LoginPanelPresenter.swift | 155 ++++++++++++++---- .../LoginPanelLayoutTests.swift | 67 ++++++++ 3 files changed, 193 insertions(+), 30 deletions(-) create mode 100644 apps/decodex-app/Tests/DecodexAppTests/LoginPanelLayoutTests.swift diff --git a/apps/decodex-app/Package.swift b/apps/decodex-app/Package.swift index 9a2e8b3e..cabda3eb 100644 --- a/apps/decodex-app/Package.swift +++ b/apps/decodex-app/Package.swift @@ -10,5 +10,6 @@ let package = Package( ], targets: [ .executableTarget(name: "DecodexApp"), + .testTarget(name: "DecodexAppTests", dependencies: ["DecodexApp"]), ], ) diff --git a/apps/decodex-app/Sources/DecodexApp/LoginPanelPresenter.swift b/apps/decodex-app/Sources/DecodexApp/LoginPanelPresenter.swift index c33b4b97..a642bf1b 100644 --- a/apps/decodex-app/Sources/DecodexApp/LoginPanelPresenter.swift +++ b/apps/decodex-app/Sources/DecodexApp/LoginPanelPresenter.swift @@ -26,6 +26,9 @@ struct LoginPanelPresenter: NSViewRepresentable { private weak var state: LoginWindowState? private var panel: LoginFloatingPanel? private var hostingView: NSHostingView? + private weak var lastPlacementParent: NSWindow? + private var lastPanelSize: NSSize? + private var hasPlacedPanel = false private var isClosingProgrammatically = false @MainActor @@ -40,8 +43,8 @@ struct LoginPanelPresenter: NSViewRepresentable { let rootView = makeRootView(store: store, state: state) if let panel, let hostingView { hostingView.rootView = rootView - resizeAndPlace(panel, hostingView: hostingView) - show(panel) + let parentChanged = show(panel) + resizeAndPlace(panel, hostingView: hostingView, forcePlacement: parentChanged) return } @@ -69,7 +72,7 @@ struct LoginPanelPresenter: NSViewRepresentable { self.panel = panel self.hostingView = hostingView - resizeAndPlace(panel, hostingView: hostingView) + resizeAndPlace(panel, hostingView: hostingView, forcePlacement: true) show(panel) } @@ -99,57 +102,83 @@ struct LoginPanelPresenter: NSViewRepresentable { isClosingProgrammatically = false self.panel = nil hostingView = nil + lastPlacementParent = nil + lastPanelSize = nil + hasPlacedPanel = false } @MainActor - private func resizeAndPlace(_ panel: NSPanel, hostingView: NSHostingView) { + private func resizeAndPlace( + _ panel: NSPanel, + hostingView: NSHostingView, + forcePlacement: Bool = false + ) { hostingView.layoutSubtreeIfNeeded() let fittingSize = hostingView.fittingSize - let panelSize = NSSize( - width: max(328, fittingSize.width), - height: max(190, fittingSize.height) + let panelSize = LoginPanelLayout.panelSize(for: fittingSize) + let parentWindow = hostView?.window + let parentChanged = windowsDiffer(lastPlacementParent, parentWindow) + let sizeChanged = lastPanelSize.map { LoginPanelLayout.sizeDiffers($0, panelSize) } ?? true + guard forcePlacement || parentChanged || sizeChanged else { + return + } + + let currentFrame = hasPlacedPanel ? panel.frame : nil + let frame = NSRect( + origin: origin(for: panelSize, parentWindow: parentWindow, currentFrame: currentFrame), + size: panelSize ) - panel.setContentSize(panelSize) - panel.setFrameOrigin(origin(for: panelSize)) + panel.setFrame(frame, display: true) + lastPlacementParent = parentWindow + lastPanelSize = panelSize + hasPlacedPanel = true } @MainActor - private func show(_ panel: NSPanel) { + @discardableResult + private func show(_ panel: NSPanel) -> Bool { guard let parentWindow = hostView?.window else { + let hadParent = panel.parent != nil + panel.parent?.removeChildWindow(panel) panel.orderFrontRegardless() - return + return hadParent || windowsDiffer(lastPlacementParent, nil) } + let parentChanged = panel.parent !== parentWindow if panel.parent !== parentWindow { panel.parent?.removeChildWindow(panel) parentWindow.addChildWindow(panel, ordered: .above) } panel.level = parentWindow.level panel.orderFrontRegardless() + return parentChanged } @MainActor - private func origin(for panelSize: NSSize) -> NSPoint { - let parentWindow = hostView?.window + private func origin( + for panelSize: NSSize, + parentWindow: NSWindow?, + currentFrame: NSRect? + ) -> NSPoint { let screen = parentWindow?.screen ?? NSScreen.main - let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1_280, height: 800) - let margin: CGFloat = 8 - - var x: CGFloat - var y: CGFloat - if let parentFrame = parentWindow?.frame { - x = parentFrame.midX - panelSize.width / 2 - y = parentFrame.maxY - panelSize.height - 68 - } else { - let mouse = NSEvent.mouseLocation - x = mouse.x - panelSize.width / 2 - y = mouse.y - panelSize.height - 18 - } - - x = min(max(x, visibleFrame.minX + margin), visibleFrame.maxX - panelSize.width - margin) - y = min(max(y, visibleFrame.minY + margin), visibleFrame.maxY - panelSize.height - margin) + return LoginPanelLayout.origin( + for: panelSize, + parentFrame: parentWindow?.frame, + currentFrame: currentFrame, + mouseLocation: NSEvent.mouseLocation, + visibleFrame: screen?.visibleFrame ?? LoginPanelLayout.fallbackVisibleFrame + ) + } - return NSPoint(x: x, y: y) + private func windowsDiffer(_ lhs: NSWindow?, _ rhs: NSWindow?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return false + case (.some(let lhs), .some(let rhs)): + return lhs !== rhs + default: + return true + } } func windowWillClose(_ notification: Notification) { @@ -158,6 +187,9 @@ struct LoginPanelPresenter: NSViewRepresentable { } panel = nil hostingView = nil + lastPlacementParent = nil + lastPanelSize = nil + hasPlacedPanel = false guard isClosingProgrammatically == false else { return } @@ -169,6 +201,69 @@ struct LoginPanelPresenter: NSViewRepresentable { } } +enum LoginPanelLayout { + static let minimumSize = NSSize(width: 328, height: 190) + static let fallbackVisibleFrame = NSRect(x: 0, y: 0, width: 1_280, height: 800) + private static let parentTopOffset: CGFloat = 68 + private static let mouseTopOffset: CGFloat = 18 + private static let placementMargin: CGFloat = 8 + private static let sizeTolerance: CGFloat = 0.5 + + static func panelSize(for fittingSize: NSSize) -> NSSize { + NSSize( + width: ceil(max(minimumSize.width, fittingSize.width)), + height: ceil(max(minimumSize.height, fittingSize.height)) + ) + } + + static func origin( + for panelSize: NSSize, + parentFrame: NSRect?, + currentFrame: NSRect?, + mouseLocation: NSPoint, + visibleFrame: NSRect + ) -> NSPoint { + let proposedOrigin: NSPoint + if let parentFrame { + proposedOrigin = NSPoint( + x: parentFrame.midX - panelSize.width / 2, + y: parentFrame.maxY - panelSize.height - parentTopOffset + ) + } else if let currentFrame { + proposedOrigin = NSPoint( + x: currentFrame.midX - panelSize.width / 2, + y: currentFrame.maxY - panelSize.height + ) + } else { + proposedOrigin = NSPoint( + x: mouseLocation.x - panelSize.width / 2, + y: mouseLocation.y - panelSize.height - mouseTopOffset + ) + } + + return NSPoint( + x: clamped( + proposedOrigin.x, + min: visibleFrame.minX + placementMargin, + max: visibleFrame.maxX - panelSize.width - placementMargin + ), + y: clamped( + proposedOrigin.y, + min: visibleFrame.minY + placementMargin, + max: visibleFrame.maxY - panelSize.height - placementMargin + ) + ) + } + + static func sizeDiffers(_ lhs: NSSize, _ rhs: NSSize) -> Bool { + abs(lhs.width - rhs.width) > sizeTolerance || abs(lhs.height - rhs.height) > sizeTolerance + } + + private static func clamped(_ value: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat { + Swift.min(Swift.max(value, min), max) + } +} + private final class LoginFloatingPanel: NSPanel { override var canBecomeKey: Bool { true diff --git a/apps/decodex-app/Tests/DecodexAppTests/LoginPanelLayoutTests.swift b/apps/decodex-app/Tests/DecodexAppTests/LoginPanelLayoutTests.swift new file mode 100644 index 00000000..f6f77213 --- /dev/null +++ b/apps/decodex-app/Tests/DecodexAppTests/LoginPanelLayoutTests.swift @@ -0,0 +1,67 @@ +import AppKit +@testable import DecodexApp +import XCTest + +final class LoginPanelLayoutTests: XCTestCase { + func testPanelSizeRoundsUpAndKeepsMinimum() { + let size = LoginPanelLayout.panelSize(for: NSSize(width: 328.2, height: 189.4)) + + XCTAssertEqual(size.width, 329) + XCTAssertEqual(size.height, 190) + } + + func testExistingPanelKeepsCurrentTopWhenParentWindowIsMissing() { + let currentFrame = NSRect(x: 300, y: 200, width: 80, height: 120) + let origin = LoginPanelLayout.origin( + for: NSSize(width: 100, height: 50), + parentFrame: nil, + currentFrame: currentFrame, + mouseLocation: NSPoint(x: 20, y: 20), + visibleFrame: NSRect(x: 0, y: 0, width: 1_000, height: 1_000) + ) + + XCTAssertEqual(origin.x, 290) + XCTAssertEqual(origin.y, 270) + } + + func testParentWindowPlacementOverridesMouseAndCurrentFrame() { + let origin = LoginPanelLayout.origin( + for: NSSize(width: 120, height: 80), + parentFrame: NSRect(x: 100, y: 100, width: 400, height: 300), + currentFrame: NSRect(x: 800, y: 800, width: 100, height: 100), + mouseLocation: NSPoint(x: 20, y: 20), + visibleFrame: NSRect(x: 0, y: 0, width: 1_000, height: 1_000) + ) + + XCTAssertEqual(origin.x, 240) + XCTAssertEqual(origin.y, 252) + } + + func testOriginIsClampedInsideVisibleFrame() { + let origin = LoginPanelLayout.origin( + for: NSSize(width: 200, height: 100), + parentFrame: nil, + currentFrame: nil, + mouseLocation: NSPoint(x: 10, y: 10), + visibleFrame: NSRect(x: 0, y: 0, width: 300, height: 200) + ) + + XCTAssertEqual(origin.x, 8) + XCTAssertEqual(origin.y, 8) + } + + func testSizeDiffUsesTolerance() { + XCTAssertFalse( + LoginPanelLayout.sizeDiffers( + NSSize(width: 328, height: 190), + NSSize(width: 328.4, height: 190.5) + ) + ) + XCTAssertTrue( + LoginPanelLayout.sizeDiffers( + NSSize(width: 328, height: 190), + NSSize(width: 328.6, height: 190) + ) + ) + } +}