diff --git a/Sources/MikuCodeApp/Companion/AppPresentation.swift b/Sources/MikuCodeApp/Companion/AppPresentation.swift index b32995c..95e4292 100644 --- a/Sources/MikuCodeApp/Companion/AppPresentation.swift +++ b/Sources/MikuCodeApp/Companion/AppPresentation.swift @@ -235,6 +235,27 @@ enum DisplayFrameSelector { } } +/// Decides where the reused workspace window must be placed before each +/// presentation. The window is created once, but the idle Miku panel can move +/// to another display between opens (screen-parameter changes rebind it to the +/// display under the pointer) — without this, the workspace reopens on +/// whichever display it was first created on. +enum WorkspaceDisplayPlacement { + /// Returns the frame the window should adopt, or nil to keep its current + /// frame (same display — preserves the user's manual size/position — or + /// mid-full-screen, where reframing fights the system transition). + static func frameForPresentation( + windowScreenFrame: CGRect?, + targetScreenFrame: CGRect, + targetVisibleFrame: CGRect, + isFullScreen: Bool + ) -> CGRect? { + guard !isFullScreen else { return nil } + guard windowScreenFrame != targetScreenFrame else { return nil } + return targetVisibleFrame + } +} + enum PresentationLayout { static func metrics(in size: CGSize) -> PresentationLayoutMetrics { let width = max(1, size.width) diff --git a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift index 424df9f..557b579 100644 --- a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift +++ b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift @@ -51,6 +51,7 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { guard presentation.requestOpen() else { return } refreshScreenFrameFromPanel() let window = ensureWorkspaceWindow() + moveWorkspaceWindowToPanelDisplay(window) workspacePresentedAt = ProcessInfo.processInfo.systemUptime panel.orderOut(nil) guard ordersFront else { return } @@ -58,6 +59,20 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { window.makeKeyAndOrderFront(nil) } + /// The workspace window is created once and reused across opens; present it + /// on the display the idle Miku currently occupies, not wherever it was + /// created. + private func moveWorkspaceWindowToPanelDisplay(_ window: WorkspaceWindow) { + guard let targetScreen = panel.screen ?? NSScreen.main else { return } + guard let frame = WorkspaceDisplayPlacement.frameForPresentation( + windowScreenFrame: window.screen?.frame, + targetScreenFrame: targetScreen.frame, + targetVisibleFrame: targetScreen.visibleFrame, + isFullScreen: window.styleMask.contains(.fullScreen) + ) else { return } + window.setFrame(frame, display: true) + } + /// Close requested by clicking the workspace Miku. The click that opened the /// workspace lands on the same screen region the Miku close target appears /// in, and stale/replayed events from that press can fire the close button diff --git a/Tests/MikuCodeAppTests/MikuCodeAppTests.swift b/Tests/MikuCodeAppTests/MikuCodeAppTests.swift index ed593c3..d55f9a1 100644 --- a/Tests/MikuCodeAppTests/MikuCodeAppTests.swift +++ b/Tests/MikuCodeAppTests/MikuCodeAppTests.swift @@ -133,6 +133,55 @@ final class PresentationLayoutTests: XCTestCase { ) } + func testWorkspacePresentsOnTheDisplayTheIdleMikuOccupies() { + let builtIn = CGRect(x: 0, y: 0, width: 1_728, height: 1_117) + let external = CGRect(x: 1_728, y: -152, width: 1_920, height: 1_080) + let externalVisible = external.insetBy(dx: 0, dy: 12) + + // Window created on the built-in display, Miku now on the external one: + // the presentation must move it to the external display's visible frame. + XCTAssertEqual( + WorkspaceDisplayPlacement.frameForPresentation( + windowScreenFrame: builtIn, + targetScreenFrame: external, + targetVisibleFrame: externalVisible, + isFullScreen: false + ), + externalVisible + ) + + // A window that has never been on screen also moves to Miku's display. + XCTAssertEqual( + WorkspaceDisplayPlacement.frameForPresentation( + windowScreenFrame: nil, + targetScreenFrame: external, + targetVisibleFrame: externalVisible, + isFullScreen: false + ), + externalVisible + ) + + // Same display: keep the user's manually adjusted frame. + XCTAssertNil( + WorkspaceDisplayPlacement.frameForPresentation( + windowScreenFrame: external, + targetScreenFrame: external, + targetVisibleFrame: externalVisible, + isFullScreen: false + ) + ) + + // Never fight an in-flight full-screen transition. + XCTAssertNil( + WorkspaceDisplayPlacement.frameForPresentation( + windowScreenFrame: builtIn, + targetScreenFrame: external, + targetVisibleFrame: externalVisible, + isFullScreen: true + ) + ) + } + func testAdaptiveMetricsKeepTerminalLargeAndMikuFarRight() { let displaySizes = [ CGSize(width: 1_024, height: 768),