From d52a71d52e95b4239c5db69f4deba232a0d25a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9C=EC=A0=95=ED=99=94?= Date: Thu, 3 Sep 2026 17:52:41 +0900 Subject: [PATCH] feat(indicator): add option to always display the indicator near the mouse cursor Add an opt-in "Always Display Indicator Near the Mouse Cursor" setting under the "Follow Mouse" position mode. While it is on, the indicator stays visible and tracks the pointer in real time instead of appearing only on app or input source changes. It needs no Accessibility permission: pointer movement comes from global and local NSEvent monitors throttled to ~60 Hz, and the panel is re-ordered front on Space changes so it follows across Spaces. The mode owns the indicator through a dedicated pipeline that renders the current input source (and the function-key badge for a second), hides it for apps with a "hide indicator" rule, and re-renders when indicator style or per-keyboard colours change. The activate-event pipeline stays idle while the mode is on, and the text-caret options under Advanced are disabled since they do not apply. Extracts the near-mouse geometry into IndicatorPosition.pointNearMouse so the one-shot "Follow Mouse" placement and the new mode share it, and covers it with unit tests. Also guards NSEvent.watch against a nil monitor on teardown, wires the preference through settings backup/restore, and adds localization for en, ja, ko, zh-Hans and zh-Hant. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PYfrZ8oYRfkLjVXUBF5BHA --- Input Source Pro.xcodeproj/project.pbxproj | 4 + ...IndicatorWindowController+Activation.swift | 127 ++++++++++++++++++ .../IndicatorWindowController.swift | 14 +- .../PreferencesVM+IndicatorPosition.swift | 29 +--- Input Source Pro/Models/PreferencesVM.swift | 10 ++ .../Persistence/SettingsBackup.swift | 5 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/ja.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + .../UI/Screens/PositionSettingsView.swift | 45 ++++++- .../Utilities/AppKit/NSEvent.swift | 6 +- .../Indicator/IndicatorPosition.swift | 26 ++++ Tests/IndicatorNearMousePointTests.swift | 51 +++++++ 15 files changed, 298 insertions(+), 29 deletions(-) create mode 100644 Tests/IndicatorNearMousePointTests.swift 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) + } +}