diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift index d8aac80..f8dc29a 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift @@ -23,14 +23,6 @@ enum KeyboardGlyphCatalog { static let tab = UnicodeToken.tab.string - /// Physical key codes for left and right command, shift, option, and control keys. - static let modifierKeyCodes: Set = Set(KeyboardModifierKey.Kind.allCases.flatMap(\.keyCodes)) - - static func isModifierKeyCode(_ rawValue: UInt16) -> Bool { - guard let keyCode = KeyboardKeyCode(rawValue: rawValue) else { return false } - return modifierKeyCodes.contains(keyCode) - } - /// Returns the glyph we show for a typed keyboard key when it has a semantic visual representation. static func symbol(for key: KeyboardSpecialKey) -> String { key.displayText diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift index d24cb34..0f8ddf1 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift @@ -123,3 +123,25 @@ enum KeyboardKeyCode: UInt16 { case dictation = 0xB0 case doNotDisturb = 0xB2 } + +extension KeyboardKeyCode { + /// Keys whose press/release lifecycle is visualized from modifier-state updates + /// rather than the normal keystroke path. + var isFlagsChangedDriven: Bool { + switch self { + case .commandRight, .commandLeft, + .shiftLeft, .shiftRight, + .optionLeft, .optionRight, + .controlLeft, .controlRight, + .function: + return true + default: + return false + } + } + + static func isFlagsChangedDriven(_ rawValue: UInt16) -> Bool { + guard let keyCode = Self(rawValue: rawValue) else { return false } + return keyCode.isFlagsChangedDriven + } +} diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift index b8931cb..ddfa1e3 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift @@ -8,7 +8,7 @@ import AppKit -/// A physical left or right key for a paired keyboard modifier. +/// A physical key for a keyboard modifier. public struct KeyboardModifierKey: Hashable { let kind: Kind let location: Location @@ -20,10 +20,11 @@ public struct KeyboardModifierKey: Hashable { } extension KeyboardModifierKey { - /// The physical side of a paired keyboard modifier key. + /// The physical position of a modifier key. enum Location: CaseIterable, Hashable { case left case right + case single var canonicalDisplayOrderIndex: Int { switch self { @@ -31,6 +32,8 @@ extension KeyboardModifierKey { return 0 case .right: return 1 + case .single: + return 2 } } } @@ -41,6 +44,8 @@ extension KeyboardModifierKey { return .right case .right: return .left + case .single: + return .center } } } @@ -57,12 +62,17 @@ extension KeyboardModifierKey { case .optionRight: return .rightOption case .controlLeft: return .leftControl case .controlRight: return .rightControl + case .function: return .function default: return nil } } static func keys(in flags: NSEvent.ModifierFlags) -> Set { - Set(Self.all.filter { flags.rawValue & $0.deviceMask != 0 }) + var keys = Set(Self.all.filter { flags.rawValue & $0.deviceMask != 0 }) + if flags.contains(.function) { + keys.insert(.function) + } + return keys } } @@ -76,6 +86,7 @@ extension KeyboardModifierKey { static let rightOption = KeyboardModifierKey(.option, location: .right) static let leftControl = KeyboardModifierKey(.control, location: .left) static let rightControl = KeyboardModifierKey(.control, location: .right) + static let function = KeyboardModifierKey(.function, location: .single) static let all: [KeyboardModifierKey] = [ .leftCommand, .rightCommand, @@ -95,6 +106,11 @@ extension KeyboardModifierKey { case (.option, .right): return UInt(NX_DEVICERALTKEYMASK) case (.control, .left): return UInt(NX_DEVICELCTLKEYMASK) case (.control, .right): return UInt(NX_DEVICERCTLKEYMASK) + case (.function, .single): return 0 + case (.command, .single), (.shift, .single), (.option, .single), (.control, .single): + return 0 + case (.function, .left), (.function, .right): + return 0 } } } diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKeyKind.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKeyKind.swift index 2b63b72..fb06392 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKeyKind.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKeyKind.swift @@ -15,10 +15,11 @@ extension KeyboardModifierKey { case shift case option case control + case function /// Modifier order guidance from Apple style guide: /// https://support.apple.com/guide/applestyleguide/k-apsgf9067ae8/1.0/web/1.0 - static let canonicalDisplayOrder: [Self] = [.control, .option, .shift, .command] + static let canonicalDisplayOrder: [Self] = [.control, .option, .shift, .command, .function] var glyph: String { switch self { @@ -26,6 +27,7 @@ extension KeyboardModifierKey { case .shift: return UnicodeToken.shift.string case .option: return UnicodeToken.option.string case .control: return UnicodeToken.control.string + case .function: return "" } } @@ -35,6 +37,7 @@ extension KeyboardModifierKey { case .shift: return "shift" case .option: return "option" case .control: return "control" + case .function: return "fn" } } @@ -44,16 +47,18 @@ extension KeyboardModifierKey { case .shift: return .shift case .option: return .option case .control: return .control + case .function: return .function } } - /// Physical key codes for the left and right keys for this modifier. + /// Physical key codes for this modifier. var keyCodes: Set { switch self { case .command: return [.commandLeft, .commandRight] case .shift: return [.shiftLeft, .shiftRight] case .option: return [.optionLeft, .optionRight] case .control: return [.controlLeft, .controlRight] + case .function: return [.function] } } @@ -67,6 +72,11 @@ extension KeyboardModifierKey { case (.option, .right): return .optionRight case (.control, .left): return .controlLeft case (.control, .right): return .controlRight + case (.function, .single): return .function + case (.command, .single), (.shift, .single), (.option, .single), (.control, .single): + preconditionFailure("Single-location modifier requested for a two-sided modifier") + case (.function, .left), (.function, .right): + preconditionFailure("Side-specific location requested for the single fn key") } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift index 14b1b2f..7589590 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift @@ -11,7 +11,7 @@ import Carbon import Combine final class KeyboardVisualizer { - private static let trackedModifierFlags: NSEvent.ModifierFlags = [.command, .shift, .option, .control, .function] + private static let trackedModifierFlags: NSEvent.ModifierFlags = [.command, .shift, .option, .control] var isPresentationActive: Bool = false { didSet { @@ -145,9 +145,9 @@ extension KeyboardVisualizer { // MARK: - Event Display private extension KeyboardVisualizer { private func displayKeystroke(_ keystroke: StandardKeyEvent) { - // Track command/shift/option/control exclusively through flagsChanged so a modifier + // Track modifier-driven keys exclusively through flagsChanged so a modifier // release does not create a separate keystroke group after the chord ends. - if KeyboardGlyphCatalog.isModifierKeyCode(keystroke.keyCode) { + if KeyboardKeyCode.isFlagsChangedDriven(keystroke.keyCode) { return } @@ -161,9 +161,12 @@ private extension KeyboardVisualizer { return } + let legend = self.legendResolver.legend(for: .keystroke(keystroke)) let items = KeycapItemFactory.keycapItems( - for: keystroke, - legend: self.legendResolver.legend(for: .keystroke(keystroke)), + keyCode: keystroke.keyCode, + legend: legend, + modifierFlags: keystroke.modifierFlags.subtracting(.function), + isPressed: keystroke.type != .keyUp, palette: self.visualizerSettings.palette ) guard !items.isEmpty else { return } @@ -188,6 +191,8 @@ private extension KeyboardVisualizer { let previousTrackedFlags = self.lastModifierFlags.intersection(Self.trackedModifierFlags) let releasedTrackedFlags = previousTrackedFlags.subtracting(currentTrackedFlags) let releasedModifierFlags = self.lastModifierFlags.subtracting(modifierFlags) + let functionNow = modifierFlags.contains(.function) + let functionWas = self.lastModifierFlags.contains(.function) // Caps Lock: one-shot flash, lit when turning on, dim when turning off let capsNow = modifierFlags.contains(.capsLock) @@ -208,6 +213,20 @@ private extension KeyboardVisualizer { self.finalizeGroupIfNeeded(group) } + if self.visualizerSettings.showSpecialKeys, functionNow != functionWas { + let group = self.eventCoordinator.handleIndependentTrackedKey( + keyCode: KeyboardKeyCode.function.rawValue, + isKeyDown: functionNow, + items: [self.functionKeycapItem(isPressed: functionNow)], + appendGroup: { self.visualizerWindow.appendGroup(with: $0, defersMaxCount: self.visualizerSettings.collapseRepeatedGroups) }, + updateGroup: { group, items in self.visualizerWindow.updateGroup(group, with: items) } + ) + self.collapseActiveRepeatIfNeeded(group) + if !functionNow { + self.finalizeGroupIfNeeded(group) + } + } + self.lastModifierFlags = modifierFlags let items = KeycapItemFactory.modifierItems( @@ -216,7 +235,7 @@ private extension KeyboardVisualizer { palette: self.visualizerSettings.palette ) guard !items.isEmpty else { - if currentTrackedFlags.isEmpty { + if currentTrackedFlags.isEmpty, !functionNow { self.eventCoordinator.reset() } return @@ -240,6 +259,19 @@ private extension KeyboardVisualizer { self.finalizeGroupIfNeeded(group) } } + + private func functionKeycapItem(isPressed: Bool) -> KeycapItem { + KeycapItemFactory.keycapItems( + keyCode: KeyboardKeyCode.function.rawValue, + legend: EventLegend( + text: KeyboardSpecialKey.function.displayText, + label: KeyboardSpecialKey.function.label + ), + modifierFlags: [], + isPressed: isPressed, + palette: self.visualizerSettings.palette + ).first! + } } // MARK: - State diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift index e418ed5..7e07aa4 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift @@ -154,6 +154,41 @@ final class KeycapEventCoordinator return group } + @discardableResult + func handleIndependentTrackedKey( + keyCode: UInt16, + isKeyDown: Bool, + items: [Item], + appendGroup: ([Item]) -> GroupView, + updateGroup: (GroupView, [Item]) -> Void + ) -> GroupView? { + guard !items.isEmpty else { return nil } + + 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) + self.pendingModifierGroup = activeGroup + self.completedModifierGroup = activeGroup + if !isKeyDown { + self.activeKeyGroups[keyCode] = nil + } + return activeGroup + } + + let orderedItems = self.ordered(items: items) + let group = appendGroup(orderedItems) + self.groupItems[ObjectIdentifier(group)] = orderedItems + self.pendingModifierGroup = group + self.completedModifierGroup = group + if isKeyDown { + self.activeKeyGroups[keyCode] = group + } + return group + } + @discardableResult func handleMouseButton( kind: MouseEvent.Kind, 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..10a5cac 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 @@ -14,6 +14,8 @@ extension KeycapItemFactory { releasedFlags: NSEvent.ModifierFlags, palette: KeycapThemePalette ) -> [KeycapItem] { + let currentFlags = currentFlags.subtracting(.function) + let releasedFlags = releasedFlags.subtracting(.function) var items: [KeycapItem] = [] let currentModifierKeys = KeyboardModifierKey.keys(in: currentFlags) let releasedModifierKeys = KeyboardModifierKey.keys(in: releasedFlags) @@ -44,10 +46,6 @@ extension KeycapItemFactory { )) } } - - if currentFlags.contains(.function) || releasedFlags.contains(.function) { - items.append(Self.functionItem(isPressed: currentFlags.contains(.function), palette: palette)) - } return items } @@ -59,7 +57,7 @@ extension KeycapItemFactory { let identity = KeycapIdentity.modifier(modifierKey) return KeycapItem( identity: identity, - legend: KeycapLegend(symbol: modifierKey.kind.glyph, label: modifierKey.kind.label), + legend: .modifier(modifierKey.kind), state: KeycapState(isPressed: isPressed), layoutHints: KeycapLayoutHints(alignment: modifierKey.legendAlignment), appearance: palette.appearance(for: identity) @@ -76,17 +74,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/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift index 31aad36..8e9f220 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift @@ -59,7 +59,12 @@ extension KeycapLegend { } static func modifier(_ modifier: KeyboardModifierKey.Kind) -> KeycapLegend { - KeycapLegend(symbol: modifier.glyph, label: modifier.label) + switch modifier { + case .function: + return .function + case .command, .shift, .option, .control: + return KeycapLegend(symbol: modifier.glyph, label: modifier.label) + } } static func character(_ symbol: String) -> KeycapLegend { 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..4b0a9c0 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift @@ -93,6 +93,97 @@ final class KeyboardVisualizerTests: XCTestCase { XCTAssertEqual(self.visualizer.visibleGroupCount, 0) } + func testFunctionFlagsChangedCreatesStandaloneGroup() { + self.settings.showSpecialKeys = true + self.visualizer.isPresentationActive = true + + self.visualizer.display(.modifierStateChanged([.function])) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + } + + func testFunctionKeyKeystrokeIsIgnoredWhenFunctionStateIsDrivenByFlagsChanged() { + self.settings.showSpecialKeys = true + self.visualizer.isPresentationActive = true + + self.visualizer.display(.keystroke(.stub( + keyCode: .function, + type: .keyDown, + modifiers: [.function] + ))) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 0) + } + + func testFunctionKeyPressAndReleaseStaysInASingleGroup() { + self.settings.showSpecialKeys = true + self.visualizer.isPresentationActive = true + + self.visualizer.display(.modifierStateChanged([.function])) + self.visualizer.display(.modifierStateChanged([])) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + } + + func testFunctionTransformedKeyRepeatsAppendNewGroupsWhileFnIsHeld() { + self.settings.collapseRepeatedGroups = true + self.settings.showSpecialKeys = true + self.visualizer.isPresentationActive = true + + let pageUpCharacter = String.functionKey(NSPageUpFunctionKey) + + self.visualizer.display(.modifierStateChanged([.function])) + self.visualizer.display(.keystroke(.stub( + keyCode: .upArrow, + type: .keyDown, + modifiers: [.function], + characters: pageUpCharacter, + charactersIgnoringModifiers: pageUpCharacter + ))) + self.visualizer.display(.keystroke(.stub( + keyCode: .upArrow, + type: .keyUp, + modifiers: [.function], + characters: pageUpCharacter, + charactersIgnoringModifiers: pageUpCharacter + ))) + self.visualizer.display(.keystroke(.stub( + keyCode: .upArrow, + type: .keyDown, + modifiers: [.function], + characters: pageUpCharacter, + charactersIgnoringModifiers: pageUpCharacter + ))) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 2) + } + + func testFunctionTransformedKeyDoesNotRenderFunctionModifierPreviewOnRelease() { + self.settings.showSpecialKeys = true + self.visualizer.isPresentationActive = true + + let pageUpCharacter = String.functionKey(NSPageUpFunctionKey) + + self.visualizer.display(.modifierStateChanged([.function])) + self.visualizer.display(.keystroke(.stub( + keyCode: .upArrow, + type: .keyDown, + modifiers: [.function], + characters: pageUpCharacter, + charactersIgnoringModifiers: pageUpCharacter + ))) + self.visualizer.display(.keystroke(.stub( + keyCode: .upArrow, + type: .keyUp, + modifiers: [.function], + characters: pageUpCharacter, + charactersIgnoringModifiers: pageUpCharacter + ))) + self.visualizer.display(.modifierStateChanged([])) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 2) + } + func testCollapseRepeatedGroupsReusesPreviousStandaloneKeyGroup() { self.settings.collapseRepeatedGroups = true self.visualizer.isPresentationActive = 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..e0e5444 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,14 @@ final class KeycapItemFactoryTests: XCTestCase { XCTAssertEqual(KeyboardModifierKey.keys(in: flags), [.leftCommand, .rightCommand]) } - func testModifierItemsIncludeFunctionKey() { + func testModifierItemsIgnoreFunctionFlag() { let items = KeycapItemFactory.modifierItems( currentFlags: [.function], releasedFlags: [], 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 testModifierItemsUseLocationSpecificModifierKeys() {