Skip to content
Open
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
4 changes: 4 additions & 0 deletions Input Source Pro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -368,6 +369,7 @@
D60000712F20000000000071 /* AppURLActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLActionTests.swift; sourceTree = "<group>"; };
D60000812F20000000000081 /* URLActivationSuppressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLActivationSuppressionTests.swift; sourceTree = "<group>"; };
D60001912F20000000000191 /* ActivateEventInputSourceChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivateEventInputSourceChangeTests.swift; sourceTree = "<group>"; };
D60002012F20000000000201 /* IndicatorNearMousePointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorNearMousePointTests.swift; sourceTree = "<group>"; };
D60001012F20000000000101 /* AppKindComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKindComparisonTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -749,6 +751,7 @@
D60000712F20000000000071 /* AppURLActionTests.swift */,
D60000812F20000000000081 /* URLActivationSuppressionTests.swift */,
D60001912F20000000000191 /* ActivateEventInputSourceChangeTests.swift */,
D60002012F20000000000201 /* IndicatorNearMousePointTests.swift */,
);
path = Tests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never> in
guard let self = self, isEnabled else { return Empty().eraseToAnyPublisher() }

return self.alwaysNearMousePublisher()
}
.sink { _ in }
.store(in: cancelBag)
}

private func alwaysNearMousePublisher() -> AnyPublisher<Void, Never> {
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<FKeyMode?, Never> = indicatorVM.functionKeyModeChangeSubject
.flatMapLatest { mode -> AnyPublisher<FKeyMode?, Never> 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<Void, Never> 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
}
}
}
14 changes: 12 additions & 2 deletions Input Source Pro/Controllers/IndicatorWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 5 additions & 24 deletions Input Source Pro/Models/PreferencesVM+IndicatorPosition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
10 changes: 10 additions & 0 deletions Input Source Pro/Models/PreferencesVM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand All @@ -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 }

Expand Down
5 changes: 5 additions & 0 deletions Input Source Pro/Persistence/SettingsBackup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions Input Source Pro/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 2 additions & 0 deletions Input Source Pro/Resources/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
"tryToDisplayIndicatorNearCursor" = "利用可能な場合、入力カーソルの近くに表示";
"isEnableAlwaysOnIndicator" = "入力カーソルの近くに常時表示インジケーターを表示";
"alwaysOnIndicatorTips" = "この機能は、「利用可能な場合、入力カーソルの近くに表示」が有効になっている必要があります。";
"isAlwaysDisplayIndicatorNearMouse" = "マウスカーソルの近くに常にインジケーターを表示";
"alwaysDisplayIndicatorNearMouseTips" = "インジケーターは非表示にならず、マウスカーソルに追従します。この機能が有効な間は「詳細設定」のオプションは適用されません。";
"Customize Styles" = "スタイルのカスタマイズ";

"Show" = "表示";
Expand Down
2 changes: 2 additions & 0 deletions Input Source Pro/Resources/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
"tryToDisplayIndicatorNearCursor" = "가능한 경우 입력 커서 근처에 표시";
"isEnableAlwaysOnIndicator" = "입력 커서 근처에 항상 켜진 표시기 표시";
"alwaysOnIndicatorTips" = "이 기능은 \"가능한 경우 입력 커서 근처에 표시\"가 활성화되어야 합니다.";
"isAlwaysDisplayIndicatorNearMouse" = "마우스 커서 근처에 항상 표시기 표시";
"alwaysDisplayIndicatorNearMouseTips" = "표시기가 사라지지 않고 마우스 커서를 따라다닙니다. 이 옵션이 켜져 있는 동안 \"고급 설정\"의 옵션은 적용되지 않습니다.";
"Customize Styles" = "스타일 사용자 정의";

"Show" = "보기";
Expand Down
2 changes: 2 additions & 0 deletions Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
"tryToDisplayIndicatorNearCursor" = "优先在输入光标附近显示";
"isEnableAlwaysOnIndicator" = "在输入光标处显示常驻提示";
"alwaysOnIndicatorTips" = "开启此功能需先启用「优先在输入光标附近显示」";
"isAlwaysDisplayIndicatorNearMouse" = "始终在鼠标指针附近显示提示";
"alwaysDisplayIndicatorNearMouseTips" = "提示不会隐藏,并会跟随鼠标指针移动。开启后,「高级设置」中的选项将不再生效。";
"Customize Styles" = "自定义样式";

"Show" = "显示";
Expand Down
2 changes: 2 additions & 0 deletions Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
"tryToDisplayIndicatorNearCursor" = "優先在輸入游標附近顯示";
"isEnableAlwaysOnIndicator" = "在輸入游標處顯示常駐提示";
"alwaysOnIndicatorTips" = "開啟此功能需先啟用「優先在輸入游標附近顯示」";
"isAlwaysDisplayIndicatorNearMouse" = "始終在滑鼠指標附近顯示提示";
"alwaysDisplayIndicatorNearMouseTips" = "提示不會隱藏,並會跟隨滑鼠指標移動。開啟後,「進階設定」中的選項將不再生效。";
"Customize Styles" = "自訂樣式";

"Show" = "顯示";
Expand Down
Loading