From 69d468a20ee6c5a51795b6fb491158fc27917695 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 31 Jul 2026 01:44:52 +0200 Subject: [PATCH 1/2] fix(keyboard): track media key press state --- .../Keyboard/KeyboardVisualizer.swift | 4 +- .../Keyboard/KeycapEventCoordinator.swift | 103 +++++++--- .../Keyboard/KeycapItemFactory.swift | 2 +- .../CaptureController/CaptureController.swift | 2 +- .../KeyboardSettingsPreviewTests.swift | 10 + .../Keycaps/KeycapEventCoordinatorTests.swift | 192 ++++++++++++++++++ .../Keycaps/KeycapItemFactoryTests.swift | 34 ++++ 7 files changed, 311 insertions(+), 36 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift index 6431d79..0b4c1b6 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift @@ -96,7 +96,9 @@ final class KeyboardVisualizer { self.prepareForNextContentEvent() let keycap = KeycapItemFactory.mediaKeyItem(for: mediaKey, palette: self.visualizerSettings.palette) - self.eventCoordinator.handleStandalone( + self.eventCoordinator.handleMediaKey( + kind: mediaKey.kind, + isPressed: keycap.isPressed, items: [keycap], appendGroup: { self.visualizerWindow.appendGroup(with: $0) }, updateGroup: { group, items in self.visualizerWindow.updateGroup(group, with: items) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift index 885c482..457d9ba 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift @@ -13,17 +13,22 @@ protocol KeycapGroupItem { } final class KeycapEventCoordinator { + private enum TrackedButtonIdentifier: Hashable { + case mouse(MouseEvent.Kind) + case media(MediaKeyEvent.Kind) + } + private var pendingModifierGroup: GroupView? private var completedModifierGroup: GroupView? private var activeKeyGroups: [UInt16: GroupView] = [:] - private var activeMouseGroups: [MouseEvent.Kind: GroupView] = [:] + private var activeButtonGroups: [TrackedButtonIdentifier: GroupView] = [:] private var groupItems: [ObjectIdentifier: [Item]] = [:] func reset() { self.pendingModifierGroup = nil self.completedModifierGroup = nil self.activeKeyGroups.removeAll(keepingCapacity: true) - self.activeMouseGroups.removeAll(keepingCapacity: true) + self.activeButtonGroups.removeAll(keepingCapacity: true) self.groupItems.removeAll(keepingCapacity: true) } @@ -35,7 +40,7 @@ final class KeycapEventCoordinator self.completedModifierGroup = nil } self.activeKeyGroups = self.activeKeyGroups.filter { $0.value !== group } - self.activeMouseGroups = self.activeMouseGroups.filter { $0.value !== group } + self.activeButtonGroups = self.activeButtonGroups.filter { $0.value !== group } self.groupItems[ObjectIdentifier(group)] = nil } @@ -130,17 +135,75 @@ final class KeycapEventCoordinator items: [Item], appendGroup: ([Item]) -> GroupView, updateGroup: (GroupView, [Item]) -> Void + ) { + self.handleTrackedButton( + identifier: .mouse(kind), + isPressed: isPressed, + items: items, + appendGroup: appendGroup, + updateGroup: updateGroup + ) + } + + func handleStandalone( + items: [Item], + appendGroup: ([Item]) -> GroupView, + updateGroup: (GroupView, [Item]) -> Void + ) { + guard !items.isEmpty else { return } + + if let pendingModifierGroup, self.canAbsorbIntoPendingChord(pendingModifierGroup) { + let merged = self.ordered(items: self.merged(items: items, into: self.storedItems(for: pendingModifierGroup))) + self.groupItems[ObjectIdentifier(pendingModifierGroup)] = merged + updateGroup(pendingModifierGroup, merged) + self.completedModifierGroup = pendingModifierGroup + return + } + + let group = appendGroup(items) + self.groupItems[ObjectIdentifier(group)] = items + } + + func handleMediaKey( + kind: MediaKeyEvent.Kind, + isPressed: Bool, + items: [Item], + appendGroup: ([Item]) -> GroupView, + updateGroup: (GroupView, [Item]) -> Void + ) { + self.handleTrackedButton( + identifier: .media(kind), + isPressed: isPressed, + items: items, + appendGroup: appendGroup, + updateGroup: updateGroup + ) + } +} + +// MARK: - Private API +private extension KeycapEventCoordinator { + private func storedItems(for group: GroupView) -> [Item] { + self.groupItems[ObjectIdentifier(group)] ?? [] + } + + private func handleTrackedButton( + identifier: TrackedButtonIdentifier, + isPressed: Bool, + items: [Item], + appendGroup: ([Item]) -> GroupView, + updateGroup: (GroupView, [Item]) -> Void ) { guard !items.isEmpty else { return } - if let activeGroup = self.activeMouseGroups[kind] { + if let activeGroup = self.activeButtonGroups[identifier] { let merged = self.ordered(items: self.merged(items: items, into: self.storedItems(for: activeGroup))) self.groupItems[ObjectIdentifier(activeGroup)] = merged updateGroup(activeGroup, merged) self.pendingModifierGroup = activeGroup self.completedModifierGroup = activeGroup if !isPressed { - self.activeMouseGroups[kind] = nil + self.activeButtonGroups[identifier] = nil } return } @@ -151,7 +214,7 @@ final class KeycapEventCoordinator updateGroup(pendingModifierGroup, merged) self.completedModifierGroup = pendingModifierGroup if isPressed { - self.activeMouseGroups[kind] = pendingModifierGroup + self.activeButtonGroups[identifier] = pendingModifierGroup } return } @@ -161,36 +224,10 @@ final class KeycapEventCoordinator self.groupItems[ObjectIdentifier(group)] = orderedItems self.completedModifierGroup = group if isPressed { - self.activeMouseGroups[kind] = group + self.activeButtonGroups[identifier] = group } } - func handleStandalone( - items: [Item], - appendGroup: ([Item]) -> GroupView, - updateGroup: (GroupView, [Item]) -> Void - ) { - guard !items.isEmpty else { return } - - if let pendingModifierGroup, self.canAbsorbIntoPendingChord(pendingModifierGroup) { - let merged = self.ordered(items: self.merged(items: items, into: self.storedItems(for: pendingModifierGroup))) - self.groupItems[ObjectIdentifier(pendingModifierGroup)] = merged - updateGroup(pendingModifierGroup, merged) - self.completedModifierGroup = pendingModifierGroup - return - } - - let group = appendGroup(items) - self.groupItems[ObjectIdentifier(group)] = items - } -} - -// MARK: - Private API -private extension KeycapEventCoordinator { - private func storedItems(for group: GroupView) -> [Item] { - self.groupItems[ObjectIdentifier(group)] ?? [] - } - /// Only pure modifier previews can absorb later modifier-only updates. private func canAppendModifiers(to group: GroupView) -> Bool { let items = self.storedItems(for: group) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift index dae145d..c43d1b9 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift @@ -128,7 +128,7 @@ enum KeycapItemFactory { } static func mediaKeyItem(for mediaKey: MediaKeyEvent, palette: KeycapThemePalette) -> KeycapItem { - Self.mediaKeyItem(for: mediaKey.kind, isPressed: false, palette: palette) + Self.mediaKeyItem(for: mediaKey.kind, isPressed: mediaKey.isPressed, palette: palette) } static func mediaKeyItem( diff --git a/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift b/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift index 81337ea..906c66c 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift @@ -193,7 +193,7 @@ extension CaptureController: EventTapDelegate { } func eventTap(_ tap: EventTap, noteMediaKey mediaKey: MediaKeyEvent) { - guard self.isCapturing, mediaKey.isPressed, mediaKey.isRecognized else { return } + guard self.isCapturing, mediaKey.isRecognized else { return } self.eventProcessor.noteMediaKey(mediaKey) } diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Keyboard/KeyboardSettingsPreviewTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Keyboard/KeyboardSettingsPreviewTests.swift index 955548d..e6180a9 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Keyboard/KeyboardSettingsPreviewTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Keyboard/KeyboardSettingsPreviewTests.swift @@ -90,6 +90,16 @@ final class KeyboardSettingsPreviewTests: XCTestCase { ]) } + func testMediaPreviewGroupsRenderPressedMediaKeys() { + let groups = KeyboardSettingsPane.PreviewGroup.previewGroups(settings: settings) + let mediaGroups = groups.filter { $0.category == .mediaKey } + + XCTAssertEqual(mediaGroups.count, 2) + XCTAssertTrue(mediaGroups.allSatisfy { group in + group.items.allSatisfy(\.isPressed) + }) + } + func testPreviewGroupsFallBackToKeyboardCategoryOnly() { settings.onlyShowModifiedKeystrokes = true settings.showSpecialKeys = false diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapEventCoordinatorTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapEventCoordinatorTests.swift index cc43cf6..2ff8965 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapEventCoordinatorTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapEventCoordinatorTests.swift @@ -419,6 +419,106 @@ final class KeycapEventCoordinatorTests: XCTestCase { XCTAssertEqual(updatedGroups[1].first(where: { $0.identity == .mouse(.leftButton) })?.isPressed, false) } + func testMediaKeyReleaseUpdatesExistingGroup() { + let coordinator = KeycapEventCoordinator() + var appendedGroups: [[TestItem]] = [] + var updatedGroups: [[TestItem]] = [] + + coordinator.handleMediaKey( + kind: .play, + isPressed: true, + items: [TestItem(identity: .media(.play), isPressed: true)], + appendGroup: { + appendedGroups.append($0) + return TestGroupView() + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + coordinator.handleMediaKey( + kind: .play, + isPressed: false, + items: [TestItem(identity: .media(.play), isPressed: false)], + appendGroup: { + appendedGroups.append($0) + return TestGroupView() + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + XCTAssertEqual(appendedGroups.count, 1) + XCTAssertEqual(appendedGroups[0].map(\.identity), [.media(.play)]) + XCTAssertEqual(appendedGroups[0].first?.isPressed, true) + XCTAssertEqual(updatedGroups.count, 1) + XCTAssertEqual(updatedGroups[0].map(\.identity), [.media(.play)]) + XCTAssertEqual(updatedGroups[0].first?.isPressed, false) + } + + func testMediaKeyReleaseUpdatesExistingChordGroup() { + let coordinator = KeycapEventCoordinator() + var appendedGroups: [[TestItem]] = [] + var updatedGroups: [[TestItem]] = [] + + coordinator.handleFlagsChanged( + currentTrackedFlags: [.command], + releasedTrackedFlags: [], + buildItems: { currentFlags, releasedFlags in + Self.modifierItems(currentFlags: currentFlags, releasedFlags: releasedFlags) + }, + appendGroup: { + appendedGroups.append($0) + return TestGroupView() + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + coordinator.handleMediaKey( + kind: .play, + isPressed: true, + items: [ + TestItem(identity: .modifier(.leftCommand), isPressed: true), + TestItem(identity: .media(.play), isPressed: true) + ], + appendGroup: { + appendedGroups.append($0) + return TestGroupView() + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + coordinator.handleMediaKey( + kind: .play, + isPressed: false, + items: [ + TestItem(identity: .modifier(.leftCommand), isPressed: true), + TestItem(identity: .media(.play), isPressed: false) + ], + appendGroup: { + appendedGroups.append($0) + return TestGroupView() + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + XCTAssertEqual(appendedGroups.count, 1) + XCTAssertEqual(appendedGroups[0].map(\.identity), [.modifier(.leftCommand)]) + XCTAssertEqual(updatedGroups.count, 2) + XCTAssertEqual(updatedGroups[0].map(\.identity), [.modifier(.leftCommand), .media(.play)]) + XCTAssertEqual(updatedGroups[0].first(where: { $0.identity == .media(.play) })?.isPressed, true) + XCTAssertEqual(updatedGroups[1].map(\.identity), [.modifier(.leftCommand), .media(.play)]) + XCTAssertEqual(updatedGroups[1].first(where: { $0.identity == .media(.play) })?.isPressed, false) + } + func testRemoveGroupClearsTrackedKeyState() { let coordinator = KeycapEventCoordinator() let removedGroup = TestGroupView() @@ -499,6 +599,98 @@ final class KeycapEventCoordinatorTests: XCTestCase { XCTAssertEqual(appendedGroups[1].first?.isPressed, false) } + func testRemoveGroupClearsTrackedMediaState() { + let coordinator = KeycapEventCoordinator() + let removedGroup = TestGroupView() + var appendedGroups: [[TestItem]] = [] + var updatedGroups: [[TestItem]] = [] + + coordinator.handleMediaKey( + kind: .play, + isPressed: true, + items: [TestItem(identity: .media(.play), isPressed: true)], + appendGroup: { + appendedGroups.append($0) + return removedGroup + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + coordinator.removeGroup(removedGroup) + + coordinator.handleMediaKey( + kind: .play, + isPressed: false, + items: [TestItem(identity: .media(.play), isPressed: false)], + appendGroup: { + appendedGroups.append($0) + return TestGroupView() + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + XCTAssertEqual(appendedGroups.count, 2) + XCTAssertTrue(updatedGroups.isEmpty) + XCTAssertEqual(appendedGroups[1].map(\.identity), [.media(.play)]) + XCTAssertEqual(appendedGroups[1].first?.isPressed, false) + } + + func testMediaKeyAppendCanRemoveExistingTrackedGroup() { + let coordinator = KeycapEventCoordinator() + let removedGroup = TestGroupView() + var appendedGroups: [[TestItem]] = [] + var updatedGroups: [[TestItem]] = [] + + coordinator.handleMediaKey( + kind: .play, + isPressed: true, + items: [TestItem(identity: .media(.play), isPressed: true)], + appendGroup: { + appendedGroups.append($0) + return removedGroup + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + coordinator.handleMediaKey( + kind: .next, + isPressed: true, + items: [TestItem(identity: .media(.next), isPressed: true)], + appendGroup: { + coordinator.removeGroup(removedGroup) + appendedGroups.append($0) + return TestGroupView() + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + coordinator.handleMediaKey( + kind: .play, + isPressed: false, + items: [TestItem(identity: .media(.play), isPressed: false)], + appendGroup: { + appendedGroups.append($0) + return TestGroupView() + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + XCTAssertEqual(appendedGroups.count, 3) + XCTAssertTrue(updatedGroups.isEmpty) + XCTAssertEqual(appendedGroups[2].map(\.identity), [.media(.play)]) + XCTAssertEqual(appendedGroups[2].first?.isPressed, false) + } + func testRemoveGroupClearsPendingModifierState() { let coordinator = KeycapEventCoordinator() let removedGroup = TestGroupView() diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift index f1b1e42..968b96a 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift @@ -146,6 +146,23 @@ final class KeycapItemFactoryTests: XCTestCase { XCTAssertFalse(upItem.isPressed) } + func testMediaKeyItemUsesPressedStateFromMediaKeyEvent() { + let palette = Self.makePalette() + + let downItem = KeycapItemFactory.mediaKeyItem( + for: Self.makeMediaKeyEvent(keyCode: 16, keyState: 0x0A), + palette: palette + ) + let upItem = KeycapItemFactory.mediaKeyItem( + for: Self.makeMediaKeyEvent(keyCode: 16, keyState: 0x0B), + palette: palette + ) + + XCTAssertEqual(downItem.identity, .media(.play)) + XCTAssertTrue(downItem.isPressed) + XCTAssertFalse(upItem.isPressed) + } + private static func makePalette( style: KeycapStyle = .minimal, theme: KeyboardVisualizerTheme = .citrus @@ -164,4 +181,21 @@ final class KeycapItemFactoryTests: XCTestCase { ) } + private static func makeMediaKeyEvent(keyCode: Int, keyState: Int) -> MediaKeyEvent { + let data1 = (keyCode << 16) | (keyState << 8) + let event = NSEvent.otherEvent( + with: .systemDefined, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + subtype: Int16(NX_SUBTYPE_AUX_CONTROL_BUTTONS), + data1: data1, + data2: 0 + )! + + return MediaKeyEvent(nsEvent: event) + } + } From 5fc76602bffeed02eb429cbecb2f85364ba50ee6 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 31 Jul 2026 14:50:51 +0200 Subject: [PATCH 2/2] Fix tests --- .../Settings/Keyboard/KeyboardSettingsPane+PreviewGroup.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane+PreviewGroup.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane+PreviewGroup.swift index 42be512..8c93a99 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane+PreviewGroup.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane+PreviewGroup.swift @@ -167,10 +167,10 @@ extension KeyboardSettingsPane.PreviewGroup { private static func mediaKeySampleGroups() -> [[KeycapPreviewSample]] { [ [ - .media(.brightnessUp) + .media(.brightnessUp, isPressed: true) ], [ - .media(.play) + .media(.play, isPressed: true) ], ] }