diff --git a/Core/Sources/Core/Windows/WindowPositioning.swift b/Core/Sources/Core/Windows/WindowPositioning.swift index 5df29319..42af7b35 100644 --- a/Core/Sources/Core/Windows/WindowPositioning.swift +++ b/Core/Sources/Core/Windows/WindowPositioning.swift @@ -65,9 +65,23 @@ public enum WindowPositioning { newWindowFrame.origin = Point(x: cursorLocation.x, y: cursorLocation.y - desiredSize.height - cursorHeight) } + // 横方向のクランプ。まず右端を screenRect 内に収め、 + // ウィンドウが screenRect より広いなど、収まりきらない場合は最終的に minX へ寄せる。 if newWindowFrame.maxX > screenRect.maxX { newWindowFrame.origin.x = screenRect.maxX - newWindowFrame.width } + if newWindowFrame.minX < screenRect.minX { + newWindowFrame.origin.x = screenRect.minX + } + + // 縦方向のクランプ。まず上端(maxY)を screenRect 内に収め、 + // ウィンドウが screenRect より高いなど、収まりきらない場合は最終的に minY へ寄せる。 + if newWindowFrame.maxY > screenRect.maxY { + newWindowFrame.origin.y = screenRect.maxY - newWindowFrame.height + } + if newWindowFrame.minY < screenRect.minY { + newWindowFrame.origin.y = screenRect.minY + } return newWindowFrame } diff --git a/Core/Tests/CoreTests/WindowsTests/WindowPositioningTests.swift b/Core/Tests/CoreTests/WindowsTests/WindowPositioningTests.swift index c5006062..257dfcf2 100644 --- a/Core/Tests/CoreTests/WindowsTests/WindowPositioningTests.swift +++ b/Core/Tests/CoreTests/WindowsTests/WindowPositioningTests.swift @@ -46,6 +46,116 @@ import Testing #expect(frame.origin == WindowPositioning.Point(x: 80, y: 14)) } +// メインの左側にある副ディスプレイ(origin.x が負)でも、カーソル付近の自然な位置に +// ウィンドウが置かれること。負の origin を持つ screenRect でも minX/maxX/minY/maxY の +// 演算が破綻しないことの回帰テスト。 +@Test func testFrameNearCursorPlacesNearCursorOnSecondaryScreenWithNegativeOrigin() async throws { + let currentFrame = WindowPositioning.Rect( + origin: .init(x: 0, y: 0), + size: .init(width: 40, height: 20) + ) + let screenRect = WindowPositioning.Rect( + origin: .init(x: -1920, y: 0), + size: .init(width: 1920, height: 1080) + ) + // 副ディスプレイ中央付近のカーソル + let cursorLocation = WindowPositioning.Point(x: -1000, y: 500) + let desiredSize = WindowPositioning.Size(width: 40, height: 30) + + let frame = WindowPositioning.frameNearCursor( + currentFrame: currentFrame, + screenRect: screenRect, + cursorLocation: cursorLocation, + desiredSize: desiredSize + ) + + // 上方向配置: origin.y = 500 - 30 - 16 = 454 + #expect(frame.origin == WindowPositioning.Point(x: -1000, y: 454)) + #expect(frame.size == desiredSize) + #expect(frame.minX >= screenRect.minX) + #expect(frame.maxX <= screenRect.maxX) +} + +// ウィンドウがスクリーンより広い場合、右端クランプで origin.x が screenRect.minX より +// 左に押し出されないこと(左端クランプの追加検証)。 +@Test func testFrameNearCursorClampsToLeftEdgeWhenWindowWiderThanScreen() async throws { + let currentFrame = WindowPositioning.Rect( + origin: .init(x: 0, y: 0), + size: .init(width: 60, height: 30) + ) + let screenRect = WindowPositioning.Rect( + origin: .init(x: 0, y: 0), + size: .init(width: 50, height: 100) + ) + let cursorLocation = WindowPositioning.Point(x: 40, y: 50) + let desiredSize = WindowPositioning.Size(width: 60, height: 30) + + let frame = WindowPositioning.frameNearCursor( + currentFrame: currentFrame, + screenRect: screenRect, + cursorLocation: cursorLocation, + desiredSize: desiredSize + ) + + // 右端クランプで origin.x = 50 - 60 = -10 になるが、左端クランプで minX に張り付く。 + #expect(frame.origin.x == screenRect.minX) +} + +// macOS は y up 座標系で minY が画面の下端。 +// 上方向配置(カーソル上にウィンドウを置く分岐)でも minY < screenRect.minY なら +// minY(=画面下端)に張り付くこと。cursorHeight 分だけ画面下端を割り込むケースを再現。 +@Test func testFrameNearCursorClampsToBottomEdgeWhenAbovePlacementOverflows() async throws { + let currentFrame = WindowPositioning.Rect( + origin: .init(x: 0, y: 0), + size: .init(width: 20, height: 30) + ) + let screenRect = WindowPositioning.Rect( + origin: .init(x: 0, y: 0), + size: .init(width: 100, height: 100) + ) + // cursorY - height = 0 (= minY) なので「>= minY」で上方向配置になる。 + // origin.y = 30 - 30 - 16 = -16 となり minY を割り込む。 + let cursorLocation = WindowPositioning.Point(x: 50, y: 30) + let desiredSize = WindowPositioning.Size(width: 20, height: 30) + + let frame = WindowPositioning.frameNearCursor( + currentFrame: currentFrame, + screenRect: screenRect, + cursorLocation: cursorLocation, + desiredSize: desiredSize + ) + + #expect(frame.minY == screenRect.minY) +} + +// 上端クランプ: ウィンドウが screenRect より縦に大きい場合、まず maxY 側で +// 画面上端に張り付こうとし、それでも minY < screenRect.minY なら最終的に minY=下端に +// 寄る。ここではまず maxY > screenRect.maxY を踏ませることが目的。 +@Test func testFrameNearCursorClampsToTopEdgeWhenWindowTallerThanScreen() async throws { + let currentFrame = WindowPositioning.Rect( + origin: .init(x: 0, y: 0), + size: .init(width: 20, height: 200) + ) + let screenRect = WindowPositioning.Rect( + origin: .init(x: 0, y: 0), + size: .init(width: 100, height: 100) + ) + // cursorY - height = -190 < 0 なので下方向配置。origin.y = 10 + 16 = 26、 + // maxY = 226 > 100 で maxY クランプが発動する。 + let cursorLocation = WindowPositioning.Point(x: 50, y: 10) + let desiredSize = WindowPositioning.Size(width: 20, height: 200) + + let frame = WindowPositioning.frameNearCursor( + currentFrame: currentFrame, + screenRect: screenRect, + cursorLocation: cursorLocation, + desiredSize: desiredSize + ) + + // maxY クランプ: origin.y = 100 - 200 = -100。直後の minY クランプで 0 へ補正される。 + #expect(frame.minY == screenRect.minY) +} + @Test func testFrameRightOfAnchorClampsToVisibleFrame() async throws { let currentFrame = WindowPositioning.Rect( origin: .init(x: 0, y: 0), @@ -71,6 +181,68 @@ import Testing #expect(frame.size == currentFrame.size) } +// 副ディスプレイがメインの左にある(origin.x が負)想定で、anchor も負座標にある場合に +// frameRightOfAnchor が破綻せず副ディスプレイ内へ収まること。 +// 予測ウィンドウの位置決め (positionPredictionWindowRightOfCandidateWindow) の回帰テスト。 +@Test func testFrameRightOfAnchorOnSecondaryScreenWithNegativeOrigin() async throws { + let currentFrame = WindowPositioning.Rect( + origin: .init(x: 0, y: 0), + size: .init(width: 200, height: 100) + ) + let screenRect = WindowPositioning.Rect( + origin: .init(x: -1920, y: 0), + size: .init(width: 1920, height: 1080) + ) + // 副ディスプレイ中央付近の anchor。右隣に gap=8 で十分余地あり。 + let anchorFrame = WindowPositioning.Rect( + origin: .init(x: -1000, y: 500), + size: .init(width: 300, height: 200) + ) + + let frame = WindowPositioning.frameRightOfAnchor( + currentFrame: currentFrame, + anchorFrame: anchorFrame, + screenRect: screenRect, + gap: 8 + ) + + #expect(frame.origin.x == anchorFrame.maxX + 8) // = -692 + #expect(frame.origin.y == anchorFrame.origin.y) // = 500 + #expect(frame.size == currentFrame.size) + #expect(frame.minX >= screenRect.minX) + #expect(frame.maxX <= screenRect.maxX) +} + +// 副ディスプレイ右端ぎりぎりに anchor がある場合、右隣に置けないので +// 左へクランプされ、結果として screenRect 内に収まること(副ディスプレイ側で発動)。 +@Test func testFrameRightOfAnchorClampsLeftWithinSecondaryScreen() async throws { + let currentFrame = WindowPositioning.Rect( + origin: .init(x: 0, y: 0), + size: .init(width: 200, height: 100) + ) + let screenRect = WindowPositioning.Rect( + origin: .init(x: -1920, y: 0), + size: .init(width: 1920, height: 1080) + ) + // anchor.maxX = -50。+gap=8 で予測ウィンドウは -42 始まりとなり、maxX = 158 で + // 副ディスプレイ右端 (0) を超える。クランプで origin.x = 0 - 200 = -200 に補正される。 + let anchorFrame = WindowPositioning.Rect( + origin: .init(x: -350, y: 100), + size: .init(width: 300, height: 200) + ) + + let frame = WindowPositioning.frameRightOfAnchor( + currentFrame: currentFrame, + anchorFrame: anchorFrame, + screenRect: screenRect, + gap: 8 + ) + + #expect(frame.origin.x == screenRect.maxX - currentFrame.width) + #expect(frame.maxX <= screenRect.maxX) + #expect(frame.minX >= screenRect.minX) +} + @Test func testPromptWindowOriginMovesAboveWhenBelowWouldOverflow() async throws { let screenRect = WindowPositioning.Rect( origin: .init(x: 0, y: 0), diff --git a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift index 137ecdd6..1471eaec 100644 --- a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift +++ b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift @@ -228,7 +228,14 @@ class BaseCandidateViewController: NSViewController { } func resizeWindowToFitContent(cursorLocation: CGPoint) { - guard let window = self.view.window, let screen = window.screen else { + guard let window = self.view.window else { + return + } + // window.screen は「ウィンドウが現在乗っているスクリーン」を返すため、 + // マルチディスプレイ環境でカーソルが別ディスプレイへ移動した直後は + // カーソル所在のディスプレイと一致しないことがある。 + // ここでは必ずカーソル位置を含むスクリーンを優先して取得する。 + guard let screen = ScreenLookup.screen(containing: cursorLocation, fallbackWindow: window) else { return } diff --git a/azooKeyMac/InputController/WindowPositioning+CoreGraphics.swift b/azooKeyMac/InputController/WindowPositioning+CoreGraphics.swift index da7ca1be..0530b306 100644 --- a/azooKeyMac/InputController/WindowPositioning+CoreGraphics.swift +++ b/azooKeyMac/InputController/WindowPositioning+CoreGraphics.swift @@ -1,3 +1,4 @@ +import AppKit import Core import CoreGraphics @@ -30,3 +31,41 @@ extension WindowPositioning.Rect { CGRect(origin: origin.cgPoint, size: size.cgSize) } } + +enum ScreenLookup { + /// 与えられたグローバル座標を含む `NSScreen` を返す。 + /// + /// 包含判定には `NSScreen.frame`(メニューバー/Dock を含むディスプレイ全体)を用いる。 + /// `visibleFrame` で判定するとメニューバー直下や Dock 領域に近い座標で外れることがあるため、 + /// 包含と利用領域は意図的に分離している(呼び出し側でクランプには `visibleFrame` を渡す想定)。 + /// + /// どの screen にも含まれなかった場合のフォールバック順は以下: + /// 1. point に最も近い `NSScreen`(ディスプレイ間の隙間や境界上の座標で滑らかに収束させるため) + /// 2. `fallbackWindow?.screen`(呼び出し側がウィンドウを持っている場合の最低限の保証) + /// 3. `NSScreen.main` + /// + /// `fallbackWindow?.screen` をあえて最近接 screen より後ろに置くのは、本ヘルパーの主目的が + /// 「`window.screen` がカーソル所在のディスプレイと一致しない問題」を避けることにあり、 + /// 異常座標時に古いスクリーンを返す挙動を許容したくないため。 + static func screen(containing point: CGPoint, fallbackWindow: NSWindow? = nil) -> NSScreen? { + if let hit = NSScreen.screens.first(where: { NSMouseInRect(point, $0.frame, false) }) { + return hit + } + if let nearest = NSScreen.screens.min(by: { lhs, rhs in + squaredDistance(from: point, to: lhs.frame) < squaredDistance(from: point, to: rhs.frame) + }) { + return nearest + } + if let windowScreen = fallbackWindow?.screen { + return windowScreen + } + return NSScreen.main + } + + private static func squaredDistance(from point: CGPoint, to rect: CGRect) -> CGFloat { + let center = CGPoint(x: rect.midX, y: rect.midY) + let dx = point.x - center.x + let dy = point.y - center.y + return dx * dx + dy * dy + } +} diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index e87a2a86..0bf0e6f8 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -649,13 +649,18 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } private func positionPredictionWindowRightOfCandidateWindow(gap: CGFloat = 8) { - guard let screen = self.predictionWindow.screen ?? self.candidatesWindow.screen else { + // アンカーである候補ウィンドウの中心が乗っているスクリーンを基準にする。 + // predictionWindow.screen / candidatesWindow.screen はマルチディスプレイ遷移直後に + // 古いディスプレイを返すことがあるため、frame の中心点で能動的に判定する。 + let anchorFrame = self.candidatesWindow.frame + let anchorCenter = CGPoint(x: anchorFrame.midX, y: anchorFrame.midY) + guard let screen = ScreenLookup.screen(containing: anchorCenter, fallbackWindow: self.candidatesWindow) else { return } let frame = WindowPositioning.frameRightOfAnchor( currentFrame: WindowPositioning.Rect(self.predictionWindow.frame), - anchorFrame: WindowPositioning.Rect(self.candidatesWindow.frame), + anchorFrame: WindowPositioning.Rect(anchorFrame), screenRect: WindowPositioning.Rect(screen.visibleFrame), gap: Double(gap) )