Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Core/Sources/Core/Windows/WindowPositioning.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
172 changes: 172 additions & 0 deletions Core/Tests/CoreTests/WindowsTests/WindowPositioningTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
39 changes: 39 additions & 0 deletions azooKeyMac/InputController/WindowPositioning+CoreGraphics.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import Core
import CoreGraphics

Expand Down Expand Up @@ -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
}
}
9 changes: 7 additions & 2 deletions azooKeyMac/InputController/azooKeyMacInputController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down
Loading