diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift index 0b4c1b6..0742c9e 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift @@ -12,6 +12,20 @@ import Combine final class KeyboardVisualizer { private static let trackedModifierFlags: NSEvent.ModifierFlags = [.command, .shift, .option, .control, .function] + var isPresentationActive: Bool = false { + didSet { + self.updatePresentationState() + } + } + + var isPresented: Bool { + self.visualizerWindow.isVisible + } + + var visibleGroupCount: Int { + self.visualizerWindow.groupCount + } + private let visualizerSettings: KeyboardVisualizerSettings private let visualizerWindow: KeyboardVisualizerWindow private let eventCoordinator = KeycapEventCoordinator() @@ -35,19 +49,22 @@ final class KeyboardVisualizer { self?.eventCoordinator.removeGroup(group) } settings.isEnabledChanges - .filter { !$0 } - .sink { [weak self] _ in - self?.clearDisplayState() + .sink { [weak self] isEnabled in + guard let self else { return } + if !isEnabled { + self.clearDisplayState() + } + self.updatePresentationState() } .store(in: &self.cancellables) } func activate() { - self.visualizerWindow.orderFront(nil) + self.updatePresentationState() } func display(_ item: DisplayItem) { - guard self.visualizerSettings.isEnabled else { + guard self.visualizerSettings.isEnabled, self.isPresentationActive else { self.clearDisplayState() return } @@ -212,6 +229,15 @@ final class KeyboardVisualizer { self.visualizerWindow.removeAllGroups() } + private func updatePresentationState() { + guard self.visualizerSettings.isEnabled, self.isPresentationActive else { + self.clearDisplayState() + self.visualizerWindow.orderOut(nil) + return + } + self.visualizerWindow.orderFront(nil) + } + private var currentTrackedFlags: NSEvent.ModifierFlags { self.currentModifierFlags.intersection(Self.trackedModifierFlags) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift index bad9a7b..ef20f07 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift @@ -16,6 +16,7 @@ final class KeyboardVisualizerWindow: NSWindow { private var groupViews: [KeyboardVisualizerGroupView] = [] private var cancellables = Set() var onGroupRemoved: ((KeyboardVisualizerGroupView) -> Void)? + var groupCount: Int { self.groupViews.count } init( settings: KeyboardVisualizerSettings = KeyboardVisualizerSettings(), diff --git a/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift b/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift index 906c66c..4dcfa7f 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift @@ -119,8 +119,9 @@ private extension CaptureController { func applyCapturing(_ capturing: Bool) { guard !capturing || self.eventTap.isInstalled else { return } let wasCapturing = self.isCapturing - Task { @MainActor [pointerVisualizersManager = self.pointerVisualizersManager] in + Task { @MainActor [pointerVisualizersManager = self.pointerVisualizersManager, keyboardVisualizer = self.keyboardVisualizer] in pointerVisualizersManager.isPresentationActive = capturing + keyboardVisualizer.isPresentationActive = capturing } if wasCapturing != capturing { self.onCapturingChanged?(capturing) diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift new file mode 100644 index 0000000..217be47 --- /dev/null +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift @@ -0,0 +1,71 @@ +// +// KeyboardVisualizerTests.swift +// KeytyTests +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import XCTest +@testable import Keyty + +@MainActor +final class KeyboardVisualizerTests: XCTestCase { + private var store: InMemoryKeyValueStore! + private var settings: KeyboardVisualizerSettings! + private var visualizer: KeyboardVisualizer! + + override func setUp() { + super.setUp() + self.store = InMemoryKeyValueStore() + self.settings = KeyboardVisualizerSettings(store: self.store) + self.settings.registerDefaults() + self.visualizer = KeyboardVisualizer(settings: self.settings) + } + + override func tearDown() { + self.visualizer = nil + self.settings = nil + self.store = nil + super.tearDown() + } + + func testEnabledVisualizerWaitsForPresentationActivation() { + self.settings.isEnabled = true + + XCTAssertFalse(self.visualizer.isPresented) + + self.visualizer.isPresentationActive = true + + XCTAssertTrue(self.visualizer.isPresented) + } + + func testPresentationDeactivationClearsVisibleGroupsImmediately() { + self.settings.isEnabled = true + self.visualizer.isPresentationActive = true + + let keystroke = TestKeystrokes.make( + keyCode: KeyboardKeyCode.k.rawValue, + characters: "k", + charactersIgnoringModifiers: "k" + ) + self.visualizer.display( + DisplayItem( + asContentWithText: keystroke.displayString, + sourceEvent: keystroke.inputEvent, + startsNewLine: false, + isCommand: keystroke.isCommand, + isModified: keystroke.isModified, + isMouseEvent: false + ) + ) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + + self.visualizer.isPresentationActive = false + + XCTAssertFalse(self.visualizer.isPresented) + XCTAssertEqual(self.visualizer.visibleGroupCount, 0) + XCTAssertTrue(self.settings.isEnabled) + } +}