diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift index 9db362a..6e7121c 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift @@ -26,6 +26,9 @@ final class KeyboardVisualizer { let settings = KeyboardVisualizerSettings(store: store) self.visualizerSettings = settings self.visualizerWindow = KeyboardVisualizerWindow(settings: settings) + self.visualizerWindow.onGroupRemoved = { [weak self] group in + self?.eventCoordinator.removeGroup(group) + } } func activate() { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift index a1e8f73..b578401 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift @@ -15,6 +15,7 @@ final class KeyboardVisualizerWindow: NSWindow { private let rootView = NSView() private var groupViews: [KeyboardVisualizerGroupView] = [] private var cancellables = Set() + var onGroupRemoved: ((KeyboardVisualizerGroupView) -> Void)? init( settings: KeyboardVisualizerSettings = KeyboardVisualizerSettings(), @@ -75,6 +76,7 @@ final class KeyboardVisualizerWindow: NSWindow { guard let self, let groupView else { return } self.groupViews.removeAll { $0 === groupView } groupView.removeFromSuperview() + self.onGroupRemoved?(groupView) self.layoutGroups() } } @@ -84,6 +86,7 @@ final class KeyboardVisualizerWindow: NSWindow { while self.groupViews.count > maxCount { let view = self.groupViews.removeFirst() view.removeFromSuperview() + self.onGroupRemoved?(view) } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift index 746fe8e..885c482 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift @@ -20,11 +20,23 @@ final class KeycapEventCoordinator private var groupItems: [ObjectIdentifier: [Item]] = [:] func reset() { - pendingModifierGroup = nil - completedModifierGroup = nil - activeKeyGroups.removeAll(keepingCapacity: true) - activeMouseGroups.removeAll(keepingCapacity: true) - groupItems.removeAll(keepingCapacity: true) + self.pendingModifierGroup = nil + self.completedModifierGroup = nil + self.activeKeyGroups.removeAll(keepingCapacity: true) + self.activeMouseGroups.removeAll(keepingCapacity: true) + self.groupItems.removeAll(keepingCapacity: true) + } + + func removeGroup(_ group: GroupView) { + if self.pendingModifierGroup === group { + self.pendingModifierGroup = nil + } + if self.completedModifierGroup === group { + self.completedModifierGroup = nil + } + self.activeKeyGroups = self.activeKeyGroups.filter { $0.value !== group } + self.activeMouseGroups = self.activeMouseGroups.filter { $0.value !== group } + self.groupItems[ObjectIdentifier(group)] = nil } func handleFlagsChanged( @@ -37,32 +49,32 @@ final class KeycapEventCoordinator let items = buildItems(currentTrackedFlags, releasedTrackedFlags) guard !items.isEmpty else { if currentTrackedFlags.isEmpty { - pendingModifierGroup = nil - completedModifierGroup = nil + self.pendingModifierGroup = nil + self.completedModifierGroup = nil } return } - if let pendingModifierGroup, canAppendModifiers(to: pendingModifierGroup) { - let merged = ordered(items: merged(items: items, into: storedItems(for: pendingModifierGroup))) - groupItems[ObjectIdentifier(pendingModifierGroup)] = merged + if let pendingModifierGroup, self.canAppendModifiers(to: pendingModifierGroup) { + let merged = self.ordered(items: self.merged(items: items, into: self.storedItems(for: pendingModifierGroup))) + self.groupItems[ObjectIdentifier(pendingModifierGroup)] = merged updateGroup(pendingModifierGroup, merged) - completedModifierGroup = pendingModifierGroup - } else if let completedModifierGroup, canRefreshModifiers(in: completedModifierGroup, with: items) { - let merged = ordered(items: merged(items: items, into: storedItems(for: completedModifierGroup))) - groupItems[ObjectIdentifier(completedModifierGroup)] = merged + self.completedModifierGroup = pendingModifierGroup + } else if let completedModifierGroup, self.canRefreshModifiers(in: completedModifierGroup, with: items) { + let merged = self.ordered(items: self.merged(items: items, into: self.storedItems(for: completedModifierGroup))) + self.groupItems[ObjectIdentifier(completedModifierGroup)] = merged updateGroup(completedModifierGroup, merged) } else { - let orderedItems = ordered(items: items) + let orderedItems = self.ordered(items: items) let group = appendGroup(orderedItems) - groupItems[ObjectIdentifier(group)] = orderedItems - pendingModifierGroup = group - completedModifierGroup = group + self.groupItems[ObjectIdentifier(group)] = orderedItems + self.pendingModifierGroup = group + self.completedModifierGroup = group } if currentTrackedFlags.isEmpty { - pendingModifierGroup = nil - completedModifierGroup = nil + self.pendingModifierGroup = nil + self.completedModifierGroup = nil } } @@ -75,40 +87,40 @@ final class KeycapEventCoordinator ) { guard !items.isEmpty else { return } - if let activeGroup = activeKeyGroups[keyCode] { - let existingItems = storedItems(for: activeGroup) - let permittedItems = permittedTrackedKeyItems(items, forExistingItems: existingItems) - let merged = ordered(items: merged(items: permittedItems, into: existingItems)) - groupItems[ObjectIdentifier(activeGroup)] = merged + if let activeGroup = self.activeKeyGroups[keyCode] { + let existingItems = self.storedItems(for: activeGroup) + let permittedItems = self.permittedTrackedKeyItems(items, forExistingItems: existingItems) + let merged = self.ordered(items: self.merged(items: permittedItems, into: existingItems)) + self.groupItems[ObjectIdentifier(activeGroup)] = merged updateGroup(activeGroup, merged) - pendingModifierGroup = activeGroup - completedModifierGroup = activeGroup + self.pendingModifierGroup = activeGroup + self.completedModifierGroup = activeGroup if !isKeyDown { - activeKeyGroups[keyCode] = nil + self.activeKeyGroups[keyCode] = nil } return } - if let pendingModifierGroup, canAbsorbIntoPendingChord(pendingModifierGroup) { - let merged = ordered(items: merged(items: items, into: storedItems(for: pendingModifierGroup))) - groupItems[ObjectIdentifier(pendingModifierGroup)] = merged + 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.pendingModifierGroup = pendingModifierGroup - completedModifierGroup = pendingModifierGroup - activeKeyGroups[keyCode] = pendingModifierGroup + self.completedModifierGroup = pendingModifierGroup + self.activeKeyGroups[keyCode] = pendingModifierGroup if !isKeyDown { - activeKeyGroups[keyCode] = nil + self.activeKeyGroups[keyCode] = nil } return } - let orderedItems = ordered(items: items) + let orderedItems = self.ordered(items: items) let group = appendGroup(orderedItems) - groupItems[ObjectIdentifier(group)] = orderedItems - pendingModifierGroup = group - completedModifierGroup = group + self.groupItems[ObjectIdentifier(group)] = orderedItems + self.pendingModifierGroup = group + self.completedModifierGroup = group if isKeyDown { - activeKeyGroups[keyCode] = group + self.activeKeyGroups[keyCode] = group } } @@ -121,35 +133,35 @@ final class KeycapEventCoordinator ) { guard !items.isEmpty else { return } - if let activeGroup = activeMouseGroups[kind] { - let merged = ordered(items: merged(items: items, into: storedItems(for: activeGroup))) - groupItems[ObjectIdentifier(activeGroup)] = merged + if let activeGroup = self.activeMouseGroups[kind] { + let merged = self.ordered(items: self.merged(items: items, into: self.storedItems(for: activeGroup))) + self.groupItems[ObjectIdentifier(activeGroup)] = merged updateGroup(activeGroup, merged) - pendingModifierGroup = activeGroup - completedModifierGroup = activeGroup + self.pendingModifierGroup = activeGroup + self.completedModifierGroup = activeGroup if !isPressed { - activeMouseGroups[kind] = nil + self.activeMouseGroups[kind] = nil } return } - if let pendingModifierGroup, canAbsorbIntoPendingChord(pendingModifierGroup) { - let merged = ordered(items: merged(items: items, into: storedItems(for: pendingModifierGroup))) - groupItems[ObjectIdentifier(pendingModifierGroup)] = merged + 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) - completedModifierGroup = pendingModifierGroup + self.completedModifierGroup = pendingModifierGroup if isPressed { - activeMouseGroups[kind] = pendingModifierGroup + self.activeMouseGroups[kind] = pendingModifierGroup } return } - let orderedItems = ordered(items: items) + let orderedItems = self.ordered(items: items) let group = appendGroup(orderedItems) - groupItems[ObjectIdentifier(group)] = orderedItems - completedModifierGroup = group + self.groupItems[ObjectIdentifier(group)] = orderedItems + self.completedModifierGroup = group if isPressed { - activeMouseGroups[kind] = group + self.activeMouseGroups[kind] = group } } @@ -160,37 +172,40 @@ final class KeycapEventCoordinator ) { guard !items.isEmpty else { return } - if let pendingModifierGroup, canAbsorbIntoPendingChord(pendingModifierGroup) { - let merged = ordered(items: merged(items: items, into: storedItems(for: pendingModifierGroup))) - groupItems[ObjectIdentifier(pendingModifierGroup)] = merged + 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) - completedModifierGroup = pendingModifierGroup + self.completedModifierGroup = pendingModifierGroup return } let group = appendGroup(items) - groupItems[ObjectIdentifier(group)] = items + self.groupItems[ObjectIdentifier(group)] = items } +} +// MARK: - Private API +private extension KeycapEventCoordinator { private func storedItems(for group: GroupView) -> [Item] { - groupItems[ObjectIdentifier(group)] ?? [] + self.groupItems[ObjectIdentifier(group)] ?? [] } /// Only pure modifier previews can absorb later modifier-only updates. private func canAppendModifiers(to group: GroupView) -> Bool { - let items = storedItems(for: group) + let items = self.storedItems(for: group) return !items.isEmpty && items.allSatisfy(\.identity.isModifier) } /// Only a pure modifier preview can absorb the first non-modifier key in a chord. private func canAbsorbIntoPendingChord(_ group: GroupView) -> Bool { - canAppendModifiers(to: group) + self.canAppendModifiers(to: group) } /// Existing chord groups may only receive modifier updates for modifiers they already show. private func canRefreshModifiers(in group: GroupView, with items: [Item]) -> Bool { let existingModifierIdentities = Set( - storedItems(for: group) + self.storedItems(for: group) .filter { $0.identity.isModifier } .map(\.identity) ) 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 6f16b30..cc43cf6 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,130 @@ final class KeycapEventCoordinatorTests: XCTestCase { XCTAssertEqual(updatedGroups[1].first(where: { $0.identity == .mouse(.leftButton) })?.isPressed, false) } + func testRemoveGroupClearsTrackedKeyState() { + let coordinator = KeycapEventCoordinator() + let removedGroup = TestGroupView() + var appendedGroups: [[TestItem]] = [] + var updatedGroups: [[TestItem]] = [] + + coordinator.handleTrackedKey( + keyCode: 8, + isKeyDown: true, + items: [TestItem(identity: .keyCode(8), isPressed: true)], + appendGroup: { + appendedGroups.append($0) + return removedGroup + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + coordinator.removeGroup(removedGroup) + + coordinator.handleTrackedKey( + keyCode: 8, + isKeyDown: false, + items: [TestItem(identity: .keyCode(8), 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), [.keyCode(8)]) + XCTAssertEqual(appendedGroups[1].first?.isPressed, false) + } + + func testRemoveGroupClearsTrackedMouseState() { + let coordinator = KeycapEventCoordinator() + let removedGroup = TestGroupView() + var appendedGroups: [[TestItem]] = [] + var updatedGroups: [[TestItem]] = [] + + coordinator.handleMouseButton( + kind: .leftButton, + isPressed: true, + items: [TestItem(identity: .mouse(.leftButton), isPressed: true)], + appendGroup: { + appendedGroups.append($0) + return removedGroup + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + coordinator.removeGroup(removedGroup) + + coordinator.handleMouseButton( + kind: .leftButton, + isPressed: false, + items: [TestItem(identity: .mouse(.leftButton), 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), [.mouse(.leftButton)]) + XCTAssertEqual(appendedGroups[1].first?.isPressed, false) + } + + func testRemoveGroupClearsPendingModifierState() { + let coordinator = KeycapEventCoordinator() + let removedGroup = TestGroupView() + 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 removedGroup + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + coordinator.removeGroup(removedGroup) + + coordinator.handleTrackedKey( + keyCode: 8, + isKeyDown: true, + items: [ + TestItem(identity: .modifier(.leftCommand), isPressed: true), + TestItem(identity: .keyCode(8), isPressed: true) + ], + appendGroup: { + appendedGroups.append($0) + return TestGroupView() + }, + updateGroup: { _, items in + updatedGroups.append(items) + } + ) + + XCTAssertEqual(appendedGroups.count, 2) + XCTAssertTrue(updatedGroups.isEmpty) + XCTAssertEqual(appendedGroups[1].map(\.identity), [.modifier(.leftCommand), .keyCode(8)]) + } + private static func modifierItems( currentFlags: NSEvent.ModifierFlags, releasedFlags: NSEvent.ModifierFlags