diff --git a/Input Source Pro.xcodeproj/project.pbxproj b/Input Source Pro.xcodeproj/project.pbxproj index 9c303ef..a937fb3 100644 --- a/Input Source Pro.xcodeproj/project.pbxproj +++ b/Input Source Pro.xcodeproj/project.pbxproj @@ -176,6 +176,7 @@ D60000722F20000000000072 /* AppURLActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60000712F20000000000071 /* AppURLActionTests.swift */; }; D60000822F20000000000082 /* URLActivationSuppressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60000812F20000000000081 /* URLActivationSuppressionTests.swift */; }; D60001922F20000000000192 /* ActivateEventInputSourceChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60001912F20000000000191 /* ActivateEventInputSourceChangeTests.swift */; }; + D60002022F20000000000202 /* IndicatorNearMousePointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60002012F20000000000201 /* IndicatorNearMousePointTests.swift */; }; D60001022F20000000000102 /* AppKindComparisonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60001012F20000000000101 /* AppKindComparisonTests.swift */; }; /* End PBXBuildFile section */ @@ -368,6 +369,7 @@ D60000712F20000000000071 /* AppURLActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLActionTests.swift; sourceTree = ""; }; D60000812F20000000000081 /* URLActivationSuppressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLActivationSuppressionTests.swift; sourceTree = ""; }; D60001912F20000000000191 /* ActivateEventInputSourceChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivateEventInputSourceChangeTests.swift; sourceTree = ""; }; + D60002012F20000000000201 /* IndicatorNearMousePointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorNearMousePointTests.swift; sourceTree = ""; }; D60001012F20000000000101 /* AppKindComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKindComparisonTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -749,6 +751,7 @@ D60000712F20000000000071 /* AppURLActionTests.swift */, D60000812F20000000000081 /* URLActivationSuppressionTests.swift */, D60001912F20000000000191 /* ActivateEventInputSourceChangeTests.swift */, + D60002012F20000000000201 /* IndicatorNearMousePointTests.swift */, ); path = Tests; sourceTree = ""; @@ -1070,6 +1073,7 @@ D60000722F20000000000072 /* AppURLActionTests.swift in Sources */, D60000822F20000000000082 /* URLActivationSuppressionTests.swift in Sources */, D60001922F20000000000192 /* ActivateEventInputSourceChangeTests.swift in Sources */, + D60002022F20000000000202 /* IndicatorNearMousePointTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Input Source Pro/Controllers/IndicatorWindowController+Activation.swift b/Input Source Pro/Controllers/IndicatorWindowController+Activation.swift index 7f2c453..119b6f5 100644 --- a/Input Source Pro/Controllers/IndicatorWindowController+Activation.swift +++ b/Input Source Pro/Controllers/IndicatorWindowController+Activation.swift @@ -254,3 +254,130 @@ extension IndicatorWindowController { .eraseToAnyPublisher() } } + +// MARK: - Always near mouse + +extension IndicatorWindowController { + private static let mouseMoveEvents: NSEvent.EventTypeMask = [ + .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged, + ] + + /// While "always display near mouse" is on, this pipeline owns the indicator: + /// it keeps it visible, moves it with the pointer, and refreshes its content + /// itself. The activate-event pipeline is idle in that mode. + func watchAlwaysNearMouse() { + preferencesVM.$preferences + .map(\.isAlwaysDisplayIndicatorNearMouseEnabled) + .removeDuplicates() + .flatMapLatest { [weak self] isEnabled -> AnyPublisher in + guard let self = self, isEnabled else { return Empty().eraseToAnyPublisher() } + + return self.alwaysNearMousePublisher() + } + .sink { _ in } + .store(in: cancelBag) + } + + private func alwaysNearMousePublisher() -> AnyPublisher { + let isHiddenForApp = applicationVM.$appKind + .map { [weak self] appKind -> Bool in + guard let self = self, let appKind = appKind else { return false } + + return self.preferencesVM.isHideIndicator(appKind) + } + .removeDuplicates() + + // The function-key badge shows for a second after a toggle, the same as + // the transient indicator does. + let badge: AnyPublisher = indicatorVM.functionKeyModeChangeSubject + .flatMapLatest { mode -> AnyPublisher in + Timer.delay(seconds: 1) + .map { _ -> FKeyMode? in nil } + .prepend(.some(mode)) + .eraseToAnyPublisher() + } + .prepend(nil) + .eraseToAnyPublisher() + + // Re-render when the indicator's look changes in Settings (style, size, + // colours, per-keyboard customisation). A @Published value is delivered + // before its property is updated, so hop to the main queue first. + let styleChanged = Publishers.Merge( + preferencesVM.$preferences.mapToVoid().eraseToAnyPublisher(), + preferencesVM.$keyboardConfigs.mapToVoid().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + + // Take the input source from the emitted state for the same reason: + // reading indicatorVM.state here would lag one change behind. + let content = Publishers.CombineLatest3(indicatorVM.$state.map(\.inputSource), badge, styleChanged) + + // The global monitor never sees events delivered to this app, so also + // watch locally to keep following over our own windows. + let mouseMoved = Publishers.Merge( + NSEvent.watch(matching: Self.mouseMoveEvents), + NSEvent.watchLocal(matching: Self.mouseMoveEvents) + ) + .throttle(for: .milliseconds(16), scheduler: DispatchQueue.main, latest: true) + .mapToVoid() + .eraseToAnyPublisher() + + // The panel joins the active Space only when ordered front, so re-order it + // after a Space switch to bring it along. + let spaceChanged = NSWorkspace.shared.notificationCenter + .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) + .receive(on: DispatchQueue.main) + .tap { [weak self] _ in + guard let self = self, self.isActive else { return } + + self.deactive() + self.active() + } + .mapToVoid() + .eraseToAnyPublisher() + + return isHiddenForApp + .flatMapLatest { [weak self] isHidden -> AnyPublisher in + guard let self = self, !isHidden else { + self?.isActive = false + return Empty().eraseToAnyPublisher() + } + + let contentShown = content + .tap { self.showNearMouseContent(inputSource: $0.0, badge: $0.1) } + .mapToVoid() + .eraseToAnyPublisher() + + return Publishers.MergeMany([contentShown, mouseMoved, spaceChanged]) + .tap { self.moveNearMouse() } + .eraseToAnyPublisher() + } + .handleEvents(receiveCancel: { [weak self] in self?.isActive = false }) + .eraseToAnyPublisher() + } + + private func showNearMouseContent(inputSource: InputSource, badge mode: FKeyMode?) { + let event: IndicatorVM.ActivateEvent = mode.map { .functionKeyModeChanges($0) } + ?? .inputSourceChanges(inputSource, .noChanges) + + updateIndicator(event: event, inputSource: inputSource) + } + + private func moveNearMouse() { + guard let size = getAppSize(), + let screen = NSScreen.getScreenWithMouse() + else { return } + + let point = IndicatorPosition.pointNearMouse( + mouseLocation: NSEvent.mouseLocation, + size: size, + visibleFrame: screen.visibleFrame + ) + + moveIndicator(position: (.nearMouse, point)) + + if !isActive { + isActive = true + } + } +} diff --git a/Input Source Pro/Controllers/IndicatorWindowController.swift b/Input Source Pro/Controllers/IndicatorWindowController.swift index a8866c5..3f38199 100644 --- a/Input Source Pro/Controllers/IndicatorWindowController.swift +++ b/Input Source Pro/Controllers/IndicatorWindowController.swift @@ -83,10 +83,20 @@ class IndicatorWindowController: FloatWindowController { } .eraseToAnyPublisher() - indicatorVM.screenIsLockedPublisher - .flatMapLatest { isLocked in isLocked ? Empty().eraseToAnyPublisher() : indicatorPublisher } + // While the indicator is pinned to the mouse (see watchAlwaysNearMouse), + // that pipeline owns visibility and position, so this one stays idle. + let isAlwaysNearMouse = preferencesVM.$preferences + .map(\.isAlwaysDisplayIndicatorNearMouseEnabled) + .removeDuplicates() + + Publishers.CombineLatest(indicatorVM.screenIsLockedPublisher, isAlwaysNearMouse) + .map { isLocked, isAlwaysNearMouse in isLocked || isAlwaysNearMouse } + .removeDuplicates() + .flatMapLatest { isIdle in isIdle ? Empty().eraseToAnyPublisher() : indicatorPublisher } .sink { _ in } .store(in: cancelBag) + + watchAlwaysNearMouse() } @available(*, unavailable) diff --git a/Input Source Pro/Models/PreferencesVM+IndicatorPosition.swift b/Input Source Pro/Models/PreferencesVM+IndicatorPosition.swift index 80f6b29..ec0b6bc 100644 --- a/Input Source Pro/Models/PreferencesVM+IndicatorPosition.swift +++ b/Input Source Pro/Models/PreferencesVM+IndicatorPosition.swift @@ -139,30 +139,11 @@ private extension PreferencesVM { AnyPublisher.create { observer in guard let screen = NSScreen.getScreenWithMouse() else { return AnyCancellable {} } - let offset: CGFloat = 12 - let padding: CGFloat = 5 - let visibleFrame = screen.visibleFrame - let maxXPoint = visibleFrame.maxX - let minXPoint = visibleFrame.minX - let maxYPoint = visibleFrame.maxY - let minYPoint = visibleFrame.minY - - var mousePoint = CGPoint(x: NSEvent.mouseLocation.x, y: NSEvent.mouseLocation.y) - - // default offset - mousePoint.x += offset - mousePoint.y -= offset - - // move app to cursor's right/bottom edge - mousePoint.y -= size.height - - // avoid overflow - mousePoint.x = min(maxXPoint - size.width - padding, mousePoint.x) - mousePoint.x = max(minXPoint + padding, mousePoint.x) - mousePoint.y = min(maxYPoint - size.height - padding, mousePoint.y) - mousePoint.y = max(minYPoint + padding, mousePoint.y) - - observer.send(mousePoint) + observer.send(IndicatorPosition.pointNearMouse( + mouseLocation: NSEvent.mouseLocation, + size: size, + visibleFrame: screen.visibleFrame + )) observer.send(completion: .finished) return AnyCancellable {} diff --git a/Input Source Pro/Models/PreferencesVM.swift b/Input Source Pro/Models/PreferencesVM.swift index b94c273..dd1ee21 100644 --- a/Input Source Pro/Models/PreferencesVM.swift +++ b/Input Source Pro/Models/PreferencesVM.swift @@ -327,6 +327,7 @@ struct Preferences { static let tryToDisplayIndicatorNearCursor = "tryToDisplayIndicatorNearCursor" static let isEnableAlwaysOnIndicator = "isEnableAlwaysOnIndicator" + static let isAlwaysDisplayIndicatorNearMouse = "isAlwaysDisplayIndicatorNearMouse" static let indicatorPosition = "indicatorPosition" static let indicatorPositionAlignment = "indicatorPositionAlignment" @@ -529,6 +530,9 @@ struct Preferences { @UserDefault(Preferences.Key.isEnableAlwaysOnIndicator) var isEnableAlwaysOnIndicator = false + @UserDefault(Preferences.Key.isAlwaysDisplayIndicatorNearMouse) + var isAlwaysDisplayIndicatorNearMouse = false + @CodableUserDefault(Preferences.Key.indicatorPosition) var indicatorPosition = IndicatorPosition.nearMouse @@ -552,6 +556,12 @@ extension Preferences { return isEnhancedModeEnabled && isActiveWhenFocusedElementChanges } + /// The option is a sub-setting of "Follow Mouse", so it only takes effect + /// while that position mode is selected. + var isAlwaysDisplayIndicatorNearMouseEnabled: Bool { + return isAlwaysDisplayIndicatorNearMouse && (indicatorPosition ?? .nearMouse) == .nearMouse + } + var indicatorKind: IndicatorKind { guard let indicatorInfo = indicatorInfo else { return .iconAndTitle } diff --git a/Input Source Pro/Persistence/SettingsBackup.swift b/Input Source Pro/Persistence/SettingsBackup.swift index 3aaf35c..846199f 100644 --- a/Input Source Pro/Persistence/SettingsBackup.swift +++ b/Input Source Pro/Persistence/SettingsBackup.swift @@ -145,6 +145,7 @@ struct SettingsBackupPreferences: Codable { var indicatorForgeground: IndicatorColor? var tryToDisplayIndicatorNearCursor: Bool? var isEnableAlwaysOnIndicator: Bool? + var isAlwaysDisplayIndicatorNearMouse: Bool? var indicatorPosition: IndicatorPosition? var indicatorPositionAlignment: IndicatorPosition.Alignment? var indicatorPositionSpacing: IndicatorPosition.Spacing? @@ -203,6 +204,7 @@ struct SettingsBackupPreferences: Codable { indicatorForgeground = preferences.indicatorForgeground tryToDisplayIndicatorNearCursor = preferences.tryToDisplayIndicatorNearCursor isEnableAlwaysOnIndicator = preferences.isEnableAlwaysOnIndicator + isAlwaysDisplayIndicatorNearMouse = preferences.isAlwaysDisplayIndicatorNearMouse indicatorPosition = preferences.indicatorPosition indicatorPositionAlignment = preferences.indicatorPositionAlignment indicatorPositionSpacing = preferences.indicatorPositionSpacing @@ -292,6 +294,9 @@ struct SettingsBackupPreferences: Codable { preferences.tryToDisplayIndicatorNearCursor = tryToDisplayIndicatorNearCursor } if let isEnableAlwaysOnIndicator { preferences.isEnableAlwaysOnIndicator = isEnableAlwaysOnIndicator } + if let isAlwaysDisplayIndicatorNearMouse { + preferences.isAlwaysDisplayIndicatorNearMouse = isAlwaysDisplayIndicatorNearMouse + } if let indicatorPosition { preferences.indicatorPosition = indicatorPosition } if let indicatorPositionAlignment { preferences.indicatorPositionAlignment = indicatorPositionAlignment } if let indicatorPositionSpacing { preferences.indicatorPositionSpacing = indicatorPositionSpacing } diff --git a/Input Source Pro/Resources/en.lproj/Localizable.strings b/Input Source Pro/Resources/en.lproj/Localizable.strings index 59c749b..5cdfcf9 100644 --- a/Input Source Pro/Resources/en.lproj/Localizable.strings +++ b/Input Source Pro/Resources/en.lproj/Localizable.strings @@ -67,6 +67,8 @@ "tryToDisplayIndicatorNearCursor" = "Display Near the Input Cursor if Available"; "isEnableAlwaysOnIndicator" = "Display Always-On Indicator Near the Input Cursor"; "alwaysOnIndicatorTips" = "This feature requires \"Display Near the Input Cursor if Available\" to be enabled."; +"isAlwaysDisplayIndicatorNearMouse" = "Always Display Indicator Near the Mouse Cursor"; +"alwaysDisplayIndicatorNearMouseTips" = "The indicator stays visible and follows the mouse cursor. While this is on, the options under \"Advanced\" are not applied."; "Customize Styles" = "Customize Styles"; "Show" = "Show"; diff --git a/Input Source Pro/Resources/ja.lproj/Localizable.strings b/Input Source Pro/Resources/ja.lproj/Localizable.strings index fd3146a..24671f1 100644 --- a/Input Source Pro/Resources/ja.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ja.lproj/Localizable.strings @@ -67,6 +67,8 @@ "tryToDisplayIndicatorNearCursor" = "利用可能な場合、入力カーソルの近くに表示"; "isEnableAlwaysOnIndicator" = "入力カーソルの近くに常時表示インジケーターを表示"; "alwaysOnIndicatorTips" = "この機能は、「利用可能な場合、入力カーソルの近くに表示」が有効になっている必要があります。"; +"isAlwaysDisplayIndicatorNearMouse" = "マウスカーソルの近くに常にインジケーターを表示"; +"alwaysDisplayIndicatorNearMouseTips" = "インジケーターは非表示にならず、マウスカーソルに追従します。この機能が有効な間は「詳細設定」のオプションは適用されません。"; "Customize Styles" = "スタイルのカスタマイズ"; "Show" = "表示"; diff --git a/Input Source Pro/Resources/ko.lproj/Localizable.strings b/Input Source Pro/Resources/ko.lproj/Localizable.strings index 0121c4f..88440af 100644 --- a/Input Source Pro/Resources/ko.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ko.lproj/Localizable.strings @@ -67,6 +67,8 @@ "tryToDisplayIndicatorNearCursor" = "가능한 경우 입력 커서 근처에 표시"; "isEnableAlwaysOnIndicator" = "입력 커서 근처에 항상 켜진 표시기 표시"; "alwaysOnIndicatorTips" = "이 기능은 \"가능한 경우 입력 커서 근처에 표시\"가 활성화되어야 합니다."; +"isAlwaysDisplayIndicatorNearMouse" = "마우스 커서 근처에 항상 표시기 표시"; +"alwaysDisplayIndicatorNearMouseTips" = "표시기가 사라지지 않고 마우스 커서를 따라다닙니다. 이 옵션이 켜져 있는 동안 \"고급 설정\"의 옵션은 적용되지 않습니다."; "Customize Styles" = "스타일 사용자 정의"; "Show" = "보기"; diff --git a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings index 8049db4..a31a93c 100644 --- a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings @@ -69,6 +69,8 @@ "tryToDisplayIndicatorNearCursor" = "优先在输入光标附近显示"; "isEnableAlwaysOnIndicator" = "在输入光标处显示常驻提示"; "alwaysOnIndicatorTips" = "开启此功能需先启用「优先在输入光标附近显示」"; +"isAlwaysDisplayIndicatorNearMouse" = "始终在鼠标指针附近显示提示"; +"alwaysDisplayIndicatorNearMouseTips" = "提示不会隐藏,并会跟随鼠标指针移动。开启后,「高级设置」中的选项将不再生效。"; "Customize Styles" = "自定义样式"; "Show" = "显示"; diff --git a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings index 7d93c8c..a71ebbc 100644 --- a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings @@ -69,6 +69,8 @@ "tryToDisplayIndicatorNearCursor" = "優先在輸入游標附近顯示"; "isEnableAlwaysOnIndicator" = "在輸入游標處顯示常駐提示"; "alwaysOnIndicatorTips" = "開啟此功能需先啟用「優先在輸入游標附近顯示」"; +"isAlwaysDisplayIndicatorNearMouse" = "始終在滑鼠指標附近顯示提示"; +"alwaysDisplayIndicatorNearMouseTips" = "提示不會隱藏,並會跟隨滑鼠指標移動。開啟後,「進階設定」中的選項將不再生效。"; "Customize Styles" = "自訂樣式"; "Show" = "顯示"; diff --git a/Input Source Pro/UI/Screens/PositionSettingsView.swift b/Input Source Pro/UI/Screens/PositionSettingsView.swift index 84e0dc0..99e918e 100644 --- a/Input Source Pro/UI/Screens/PositionSettingsView.swift +++ b/Input Source Pro/UI/Screens/PositionSettingsView.swift @@ -38,6 +38,19 @@ struct PositionSettingsView: View { } ) + let alwaysNearMouseBinding = Binding( + get: { + preferencesVM.preferences.isAlwaysDisplayIndicatorNearMouse + }, + set: { newValue in + preferencesVM.update { + $0.isAlwaysDisplayIndicatorNearMouse = newValue + } + } + ) + + let isAlwaysNearMouse = preferencesVM.preferences.isAlwaysDisplayIndicatorNearMouseEnabled + ScrollView { VStack(spacing: 18) { SettingsSection(title: "Position") { @@ -56,6 +69,34 @@ struct PositionSettingsView: View { .pickerStyle(.segmented) .padding() + if preferencesVM.preferences.indicatorPosition == .nearMouse { + HStack { + Toggle("", isOn: alwaysNearMouseBinding) + .toggleStyle(.switch) + .labelsHidden() + + Text("isAlwaysDisplayIndicatorNearMouse".i18n()) + + Spacer() + + QuestionButton( + content: { + SwiftUI.Image(systemName: "questionmark") + .font(.system(size: 11, weight: .bold)) + .padding(6) + }, + popover: { _ in + Text("alwaysDisplayIndicatorNearMouseTips".i18n()) + .font(.footnote) + .opacity(0.6) + .padding() + } + ) + } + .padding() + .border(width: 1, edges: [.top], color: NSColor.border2.color) + } + if preferencesVM.preferences.indicatorPosition != .nearMouse { HStack { Text("Spacing".i18n() + ":") @@ -108,7 +149,7 @@ struct PositionSettingsView: View { HStack { Toggle(isOn: $preferencesVM.preferences.tryToDisplayIndicatorNearCursor) {} .toggleStyle(.switch) - .disabled(!preferencesVM.preferences.isEnhancedModeEnabled) + .disabled(!preferencesVM.preferences.isEnhancedModeEnabled || isAlwaysNearMouse) Text("tryToDisplayIndicatorNearCursor".i18n()) @@ -139,7 +180,7 @@ struct PositionSettingsView: View { .border(width: 1, edges: [.bottom], color: NSColor.border2.color) VStack { - let needDisableAlwaysOnIndicator = !preferencesVM.preferences.isEnhancedModeEnabled || !preferencesVM.preferences.tryToDisplayIndicatorNearCursor + let needDisableAlwaysOnIndicator = !preferencesVM.preferences.isEnhancedModeEnabled || !preferencesVM.preferences.tryToDisplayIndicatorNearCursor || isAlwaysNearMouse HStack { Toggle("", isOn: $preferencesVM.preferences.isEnableAlwaysOnIndicator) diff --git a/Input Source Pro/Utilities/AppKit/NSEvent.swift b/Input Source Pro/Utilities/AppKit/NSEvent.swift index 99ff6b2..4d7c02f 100644 --- a/Input Source Pro/Utilities/AppKit/NSEvent.swift +++ b/Input Source Pro/Utilities/AppKit/NSEvent.swift @@ -10,7 +10,11 @@ extension NSEvent { handler: { observer.send($0) } ) - return AnyCancellable { NSEvent.removeMonitor(monitor!) } + return AnyCancellable { + if let monitor = monitor { + NSEvent.removeMonitor(monitor) + } + } } } diff --git a/Input Source Pro/Utilities/Indicator/IndicatorPosition.swift b/Input Source Pro/Utilities/Indicator/IndicatorPosition.swift index fca0280..d3d3a51 100644 --- a/Input Source Pro/Utilities/Indicator/IndicatorPosition.swift +++ b/Input Source Pro/Utilities/Indicator/IndicatorPosition.swift @@ -55,6 +55,32 @@ extension IndicatorPosition { } } +extension IndicatorPosition { + /// Origin for an indicator of `size` placed just below and to the right of + /// the mouse pointer, clamped so the whole indicator stays inside `visibleFrame`. + static func pointNearMouse(mouseLocation: CGPoint, size: CGSize, visibleFrame: CGRect) -> CGPoint { + let offset: CGFloat = 12 + let padding: CGFloat = 5 + + var point = mouseLocation + + // default offset + point.x += offset + point.y -= offset + + // move app to cursor's right/bottom edge + point.y -= size.height + + // avoid overflow + point.x = min(visibleFrame.maxX - size.width - padding, point.x) + point.x = max(visibleFrame.minX + padding, point.x) + point.y = min(visibleFrame.maxY - size.height - padding, point.y) + point.y = max(visibleFrame.minY + padding, point.y) + + return point + } +} + extension IndicatorPosition.Alignment { var name: String { switch self { diff --git a/Tests/IndicatorNearMousePointTests.swift b/Tests/IndicatorNearMousePointTests.swift new file mode 100644 index 0000000..b5bfb44 --- /dev/null +++ b/Tests/IndicatorNearMousePointTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import Input_Source_Pro + +/// Verifies the pure geometry behind placing the indicator next to the mouse +/// pointer: a fixed gap to the pointer's bottom-right, clamped so the whole +/// indicator stays inside the screen's visible frame. +final class IndicatorNearMousePointTests: XCTestCase { + private let size = CGSize(width: 60, height: 30) + private let frame = CGRect(x: 0, y: 0, width: 1000, height: 800) + + func testPlacesIndicatorBelowRightOfPointer() { + let point = IndicatorPosition.pointNearMouse( + mouseLocation: CGPoint(x: 100, y: 500), size: size, visibleFrame: frame + ) + + // 12pt gap on both axes; the origin is the bottom-left corner, so the + // indicator's top edge sits 12pt below the pointer. + XCTAssertEqual(point, CGPoint(x: 112, y: 458)) + } + + func testClampsInsideRightEdge() { + let point = IndicatorPosition.pointNearMouse( + mouseLocation: CGPoint(x: 990, y: 500), size: size, visibleFrame: frame + ) + + XCTAssertEqual(point.x, 1000 - 60 - 5) + XCTAssertEqual(point.y, 458) + } + + func testClampsInsideBottomEdge() { + let point = IndicatorPosition.pointNearMouse( + mouseLocation: CGPoint(x: 100, y: 10), size: size, visibleFrame: frame + ) + + XCTAssertEqual(point.x, 112) + XCTAssertEqual(point.y, 5) + } + + func testClampsInsideLeftAndTopOfOffsetScreen() { + // A secondary display whose visible frame does not start at the origin, + // with the pointer in its menu bar (above the visible frame). + let secondary = CGRect(x: 1000, y: 100, width: 1000, height: 800) + + let point = IndicatorPosition.pointNearMouse( + mouseLocation: CGPoint(x: 990, y: 920), size: size, visibleFrame: secondary + ) + + XCTAssertEqual(point.x, 1000 + 5) + XCTAssertEqual(point.y, 900 - 30 - 5) + } +}