diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift index 14b1b2f..ca0c255 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift @@ -183,6 +183,26 @@ private extension KeyboardVisualizer { } } + private func displayFunctionKey(isPressed: Bool, modifierFlags: NSEvent.ModifierFlags) { + self.prepareForNextContentEvent() + + let keycap = KeycapItemFactory.functionItem( + isPressed: isPressed, + palette: self.visualizerSettings.palette + ) + let group = self.eventCoordinator.handleTrackedKey( + keyCode: KeyboardKeyCode.function.rawValue, + isKeyDown: isPressed, + items: [keycap], + appendGroup: { self.visualizerWindow.appendGroup(with: $0, defersMaxCount: self.visualizerSettings.collapseRepeatedGroups) }, + updateGroup: { group, items in self.visualizerWindow.updateGroup(group, with: items) } + ) + self.collapseActiveRepeatIfNeeded(group) + if !isPressed, modifierFlags.intersection(Self.trackedModifierFlags).isEmpty { + self.finalizeGroupIfNeeded(group) + } + } + private func displayModifierPreview(_ modifierFlags: NSEvent.ModifierFlags) { let currentTrackedFlags = modifierFlags.intersection(Self.trackedModifierFlags) let previousTrackedFlags = self.lastModifierFlags.intersection(Self.trackedModifierFlags) @@ -208,6 +228,14 @@ private extension KeyboardVisualizer { self.finalizeGroupIfNeeded(group) } + // `fn` is not a modifier: it gets its own keycap, tracked from key down to key up + // so a press and its release update one group instead of rendering twice. + let functionNow = modifierFlags.contains(.function) + let functionWas = self.lastModifierFlags.contains(.function) + if self.visualizerSettings.showSpecialKeys, functionNow != functionWas { + self.displayFunctionKey(isPressed: functionNow, modifierFlags: modifierFlags) + } + self.lastModifierFlags = modifierFlags let items = KeycapItemFactory.modifierItems( diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Keyboard.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Keyboard.swift index e74c748..3d8fb59 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Keyboard.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Keyboard.swift @@ -31,7 +31,7 @@ extension KeycapItemFactory { palette: KeycapThemePalette ) -> [KeycapItem] { var result = Self.modifierItems( - currentFlags: modifierFlags.subtracting(.function), + currentFlags: modifierFlags, releasedFlags: [], palette: palette ) @@ -48,6 +48,21 @@ extension KeycapItemFactory { return result } + /// The `fn` keycap. `fn` only ever reaches us as a modifier flag, never as a key + /// event, so it is built here instead of going through the keystroke path. + static func functionItem( + isPressed: Bool, + palette: KeycapThemePalette + ) -> KeycapItem { + let identity = KeycapIdentity.keyCode(KeyboardKeyCode.function.rawValue) + return KeycapItem( + identity: identity, + legend: .function, + state: KeycapState(isPressed: isPressed), + appearance: palette.appearance(for: identity) + ) + } + // Legend for a key, with the two keys whose keycap styling differs from // what the resolved legend alone describes. private static func keycapLegend(for keyCode: UInt16, legend: EventLegend) -> KeycapLegend { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Modifiers.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Modifiers.swift index 6314bf0..60ff2a9 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Modifiers.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Modifiers.swift @@ -45,9 +45,6 @@ extension KeycapItemFactory { } } - if currentFlags.contains(.function) || releasedFlags.contains(.function) { - items.append(Self.functionItem(isPressed: currentFlags.contains(.function), palette: palette)) - } return items } @@ -76,17 +73,4 @@ extension KeycapItemFactory { .map { KeyboardModifierKey(modifier, location: $0) } .filter { keys.contains($0) } } - - private static func functionItem( - isPressed: Bool, - palette: KeycapThemePalette - ) -> KeycapItem { - let identity = KeycapIdentity.keyCode(KeyboardKeyCode.function.rawValue) - return KeycapItem( - identity: identity, - legend: .function, - state: KeycapState(isPressed: isPressed), - appearance: palette.appearance(for: identity) - ) - } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapCategory.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapCategory.swift index c56dce1..a607bfc 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapCategory.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapCategory.swift @@ -28,11 +28,7 @@ enum KeycapCategory: CaseIterable { case .mouse: self = .mouse case let .keyCode(code): - // `fn` is emitted as a keyCode from the modifier path and is shown beside the - // modifiers, so it follows the modifier theme rather than the special theme. - if code == KeyboardKeyCode.function.rawValue { - self = .modifier - } else if KeyboardSpecialKeyResolver.specialKey(for: code) != nil { + if KeyboardSpecialKeyResolver.specialKey(for: code) != nil { self = .special } else { self = .regular diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift index fe7cfa0..64b3375 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift @@ -60,6 +60,29 @@ final class KeyboardVisualizerTests: XCTestCase { XCTAssertTrue(self.settings.isEnabled) } + func testFunctionKeyPressAndReleaseRenderOneGroup() { + self.settings.showSpecialKeys = true + self.visualizer.isPresentationActive = true + + self.visualizer.display(.modifierStateChanged([.function])) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + + self.visualizer.display(.modifierStateChanged([])) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + } + + func testFunctionKeyIsHiddenWhenSpecialKeysAreHidden() { + self.settings.showSpecialKeys = false + self.visualizer.isPresentationActive = true + + self.visualizer.display(.modifierStateChanged([.function])) + self.visualizer.display(.modifierStateChanged([])) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 0) + } + func testOnlyShowModifiedKeystrokesStillShowsStandaloneSpecialKeys() { self.settings.onlyShowModifiedKeystrokes = true self.settings.showSpecialKeys = true 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 f5e47f4..e1461af 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift @@ -21,18 +21,25 @@ final class KeycapItemFactoryTests: XCTestCase { XCTAssertEqual(KeyboardModifierKey.keys(in: flags), [.leftCommand, .rightCommand]) } - func testModifierItemsIncludeFunctionKey() { + func testModifierItemsExcludeFunctionKey() { let items = KeycapItemFactory.modifierItems( currentFlags: [.function], - releasedFlags: [], + releasedFlags: [.function], palette: Self.makePalette() ) - XCTAssertEqual(items.count, 1) - XCTAssertEqual(items.first?.identity, .keyCode(KeyboardKeyCode.function.rawValue)) - XCTAssertEqual(items.first?.label, "fn") - XCTAssertEqual(items.first?.sfSymbolName, "globe") - XCTAssertEqual(items.first?.isPressed, true) + XCTAssertTrue(items.isEmpty) + } + + func testFunctionItemIsAStandaloneKeycap() { + let item = KeycapItemFactory.functionItem(isPressed: true, palette: Self.makePalette()) + + XCTAssertEqual(item.identity, .keyCode(KeyboardKeyCode.function.rawValue)) + XCTAssertEqual(item.label, "fn") + XCTAssertEqual(item.sfSymbolName, "globe") + XCTAssertEqual(item.isPressed, true) + XCTAssertFalse(item.identity.isModifier) + XCTAssertEqual(KeycapCategory(identity: item.identity), .special) } func testModifierItemsUseLocationSpecificModifierKeys() {