From 99af7bd0c1bc5281460214130e785003abfa5731 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Tue, 25 Aug 2026 18:02:05 +0200 Subject: [PATCH 1/6] fix(keyboard): avoid duplicate fn key display --- .../Domain/Keyboard/KeyboardGlyphCatalog.swift | 5 +++-- .../Keycaps/KeyboardVisualizerTests.swift | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift index d8aac801..745ea6b1 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift @@ -23,8 +23,9 @@ 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)) + /// Physical key codes for modifier-only keys handled by the flagsChanged path. + static let modifierKeyCodes: Set = + Set(KeyboardModifierKey.Kind.allCases.flatMap(\.keyCodes)).union([.function]) static func isModifierKeyCode(_ rawValue: UInt16) -> Bool { guard let keyCode = KeyboardKeyCode(rawValue: rawValue) else { return false } 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 fe7cfa01..97d1ed60 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,23 @@ final class KeyboardVisualizerTests: XCTestCase { XCTAssertEqual(self.visualizer.visibleGroupCount, 0) } + func testFunctionKeyKeystrokeDoesNotCreateDuplicateGroupAfterModifierPreview() { + self.settings.showSpecialKeys = true + self.visualizer.isPresentationActive = true + + self.visualizer.display(.modifierStateChanged([.function])) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + + self.visualizer.display(.keystroke(.stub( + keyCode: .function, + type: .keyDown, + modifiers: [.function] + ))) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + } + func testCollapseRepeatedGroupsReusesPreviousStandaloneKeyGroup() { self.settings.collapseRepeatedGroups = true self.visualizer.isPresentationActive = true From 7fb8562d156bd7000203a54c4ef6454992bd75d3 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Tue, 25 Aug 2026 18:08:23 +0200 Subject: [PATCH 2/6] refactor(keyboard): move modifier-only key classification to KeyboardKeyCode --- .../Domain/Keyboard/KeyboardKeyCode.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift index d24cb34e..589380b4 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 isModifierOnly: Bool { + switch self { + case .commandRight, .commandLeft, + .shiftLeft, .shiftRight, + .optionLeft, .optionRight, + .controlLeft, .controlRight, + .function: + return true + default: + return false + } + } + + static func isModifierOnly(_ rawValue: UInt16) -> Bool { + guard let keyCode = Self(rawValue: rawValue) else { return false } + return keyCode.isModifierOnly + } +} From 6deb1ec70f7332b5635da7aa7f677f83cd772909 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Tue, 25 Aug 2026 18:49:49 +0200 Subject: [PATCH 3/6] refactor(keyboard): model fn as a first-class modifier --- .../Keyboard/KeyboardGlyphCatalog.swift | 9 ------- .../Domain/Keyboard/KeyboardModifierKey.swift | 21 ++++++++++++--- .../Keyboard/KeyboardModifierKeyKind.swift | 13 +++++++-- .../Keyboard/KeyboardVisualizer.swift | 2 +- .../KeycapItemFactory+Modifiers.swift | 27 +++++-------------- .../Keyboard/Models/KeycapLegend.swift | 7 ++++- .../Keycaps/KeyboardVisualizerTests.swift | 20 ++++++++++++++ .../Keycaps/KeycapItemFactoryTests.swift | 2 +- 8 files changed, 64 insertions(+), 37 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift index 745ea6b1..f8dc29aa 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift @@ -23,15 +23,6 @@ enum KeyboardGlyphCatalog { static let tab = UnicodeToken.tab.string - /// Physical key codes for modifier-only keys handled by the flagsChanged path. - static let modifierKeyCodes: Set = - Set(KeyboardModifierKey.Kind.allCases.flatMap(\.keyCodes)).union([.function]) - - 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/KeyboardModifierKey.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift index b8931cba..240bd05b 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,10 @@ 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), + (.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 2b63b723..9bba5a31 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,10 @@ 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), + (.function, .left), (.function, .right): + return .function } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift index 14b1b2fd..ee61f9ed 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift @@ -147,7 +147,7 @@ private extension KeyboardVisualizer { private func displayKeystroke(_ keystroke: StandardKeyEvent) { // Track command/shift/option/control 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.isModifierOnly(keystroke.keyCode) { return } 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 6314bf01..40ba28dd 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 @@ -36,7 +36,10 @@ extension KeycapItemFactory { } if currentFlags.contains(modifier.flag) || releasedFlags.contains(modifier.flag) { - let modifierKey = KeyboardModifierKey(modifier, location: .left) + let modifierKey = KeyboardModifierKey( + modifier, + location: modifier == .function ? .single : .left + ) items.append(Self.modifierItem( modifierKey, isPressed: currentFlags.contains(modifier.flag), @@ -44,10 +47,6 @@ extension KeycapItemFactory { )) } } - - if currentFlags.contains(.function) || releasedFlags.contains(.function) { - items.append(Self.functionItem(isPressed: currentFlags.contains(.function), palette: palette)) - } return items } @@ -59,7 +58,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) @@ -72,21 +71,9 @@ extension KeycapItemFactory { releasedModifierKeys: Set ) -> [KeyboardModifierKey] { let keys = currentModifierKeys.union(releasedModifierKeys) - return Self.orderedModifierLocations + let locations: [KeyboardModifierKey.Location] = modifier == .function ? [.single] : Self.orderedModifierLocations + return locations .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/KeycapLegend.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift index 31aad36a..8e9f220e 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 97d1ed60..3dbaeb08 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift @@ -110,6 +110,26 @@ final class KeyboardVisualizerTests: XCTestCase { XCTAssertEqual(self.visualizer.visibleGroupCount, 1) } + func testFunctionKeyPressAndReleaseStaysInASingleGroup() { + self.settings.showSpecialKeys = true + self.visualizer.isPresentationActive = true + + self.visualizer.display(.modifierStateChanged([.function])) + self.visualizer.display(.keystroke(.stub( + keyCode: .function, + type: .keyDown, + modifiers: [.function] + ))) + self.visualizer.display(.keystroke(.stub( + keyCode: .function, + type: .keyUp, + modifiers: [.function] + ))) + self.visualizer.display(.modifierStateChanged([])) + + XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + } + 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 f5e47f47..258ca6bc 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift @@ -29,7 +29,7 @@ final class KeycapItemFactoryTests: XCTestCase { ) XCTAssertEqual(items.count, 1) - XCTAssertEqual(items.first?.identity, .keyCode(KeyboardKeyCode.function.rawValue)) + XCTAssertEqual(items.first?.identity, .modifier(.function)) XCTAssertEqual(items.first?.label, "fn") XCTAssertEqual(items.first?.sfSymbolName, "globe") XCTAssertEqual(items.first?.isPressed, true) From 1ca20474b21e636eed65bb0ecfdde26ee9893927 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Tue, 25 Aug 2026 19:56:35 +0200 Subject: [PATCH 4/6] fix(keyboard): render fn from a single state-driven path --- .../Domain/Keyboard/KeyboardModifierKey.swift | 12 +--- .../Keyboard/KeyboardModifierKeyKind.swift | 13 +--- .../Keyboard/KeyboardVisualizer.swift | 40 +++++++++-- .../Keyboard/KeycapEventCoordinator.swift | 35 ++++++++++ .../KeycapItemFactory+Modifiers.swift | 10 ++- .../Keyboard/Models/KeycapCategory.swift | 6 +- .../Keyboard/Models/KeycapLegend.swift | 7 +- .../Keycaps/KeyboardVisualizerTests.swift | 66 +++++++++++++++++-- .../Keycaps/KeycapItemFactoryTests.swift | 8 +-- 9 files changed, 144 insertions(+), 53 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift index 240bd05b..458f7648 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift @@ -62,17 +62,12 @@ 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 { - var keys = Set(Self.all.filter { flags.rawValue & $0.deviceMask != 0 }) - if flags.contains(.function) { - keys.insert(.function) - } - return keys + Set(Self.all.filter { flags.rawValue & $0.deviceMask != 0 }) } } @@ -86,7 +81,6 @@ 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, @@ -106,9 +100,7 @@ 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), - (.function, .left), (.function, .right): + case (.command, .single), (.shift, .single), (.option, .single), (.control, .single): return 0 } } diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKeyKind.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKeyKind.swift index 9bba5a31..44ec8a13 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKeyKind.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKeyKind.swift @@ -15,11 +15,10 @@ 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, .function] + static let canonicalDisplayOrder: [Self] = [.control, .option, .shift, .command] var glyph: String { switch self { @@ -27,7 +26,6 @@ extension KeyboardModifierKey { case .shift: return UnicodeToken.shift.string case .option: return UnicodeToken.option.string case .control: return UnicodeToken.control.string - case .function: return "" } } @@ -37,7 +35,6 @@ extension KeyboardModifierKey { case .shift: return "shift" case .option: return "option" case .control: return "control" - case .function: return "fn" } } @@ -47,7 +44,6 @@ extension KeyboardModifierKey { case .shift: return .shift case .option: return .option case .control: return .control - case .function: return .function } } @@ -58,7 +54,6 @@ extension KeyboardModifierKey { case .shift: return [.shiftLeft, .shiftRight] case .option: return [.optionLeft, .optionRight] case .control: return [.controlLeft, .controlRight] - case .function: return [.function] } } @@ -72,10 +67,8 @@ 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), - (.function, .left), (.function, .right): - return .function + case (.command, .single), (.shift, .single), (.option, .single), (.control, .single): + preconditionFailure("Single-location modifier requested for a two-sided modifier") } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift index ee61f9ed..650b4273 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 { @@ -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, + 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 e418ed56..7e07aa4f 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 40ba28dd..10a5cac6 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) @@ -36,10 +38,7 @@ extension KeycapItemFactory { } if currentFlags.contains(modifier.flag) || releasedFlags.contains(modifier.flag) { - let modifierKey = KeyboardModifierKey( - modifier, - location: modifier == .function ? .single : .left - ) + let modifierKey = KeyboardModifierKey(modifier, location: .left) items.append(Self.modifierItem( modifierKey, isPressed: currentFlags.contains(modifier.flag), @@ -71,8 +70,7 @@ extension KeycapItemFactory { releasedModifierKeys: Set ) -> [KeyboardModifierKey] { let keys = currentModifierKeys.union(releasedModifierKeys) - let locations: [KeyboardModifierKey.Location] = modifier == .function ? [.single] : Self.orderedModifierLocations - return locations + return Self.orderedModifierLocations .map { KeyboardModifierKey(modifier, location: $0) } .filter { keys.contains($0) } } 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 c56dce1b..a607bfc4 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 8e9f220e..31aad36a 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift @@ -59,12 +59,7 @@ extension KeycapLegend { } static func modifier(_ modifier: KeyboardModifierKey.Kind) -> KeycapLegend { - switch modifier { - case .function: - return .function - case .command, .shift, .option, .control: - return KeycapLegend(symbol: modifier.glyph, label: modifier.label) - } + 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 3dbaeb08..3b421ad9 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift @@ -93,13 +93,18 @@ final class KeyboardVisualizerTests: XCTestCase { XCTAssertEqual(self.visualizer.visibleGroupCount, 0) } - func testFunctionKeyKeystrokeDoesNotCreateDuplicateGroupAfterModifierPreview() { + 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, @@ -107,23 +112,72 @@ final class KeyboardVisualizerTests: XCTestCase { modifiers: [.function] ))) - XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + 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 testFunctionTransformedKeyRepeatsCollapseLikeStandaloneSpecialKeys() { + 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: .function, + keyCode: .upArrow, type: .keyDown, - modifiers: [.function] + modifiers: [.function], + characters: pageUpCharacter, + charactersIgnoringModifiers: pageUpCharacter ))) self.visualizer.display(.keystroke(.stub( - keyCode: .function, + keyCode: .upArrow, type: .keyUp, - modifiers: [.function] + 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, 1) + } + + 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([])) 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 258ca6bc..e0e5444a 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, .modifier(.function)) - XCTAssertEqual(items.first?.label, "fn") - XCTAssertEqual(items.first?.sfSymbolName, "globe") - XCTAssertEqual(items.first?.isPressed, true) + XCTAssertTrue(items.isEmpty) } func testModifierItemsUseLocationSpecificModifierKeys() { From 6a4d0414b70d55ec3844bbf844a9b4165e3b1101 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Tue, 25 Aug 2026 20:07:26 +0200 Subject: [PATCH 5/6] refactor(keyboard): simplify fn state-driven rendering --- .../Domain/Keyboard/KeyboardKeyCode.swift | 6 +-- .../Domain/Keyboard/KeyboardModifierKey.swift | 11 +++++- .../Keyboard/KeyboardModifierKeyKind.swift | 10 ++++- .../Keyboard/KeyboardVisualizer.swift | 37 ++----------------- .../Keyboard/KeycapEventCoordinator.swift | 35 ------------------ .../KeycapItemFactory+Modifiers.swift | 10 +++-- .../Keyboard/Models/KeycapLegend.swift | 7 +++- .../Keycaps/KeyboardVisualizerTests.swift | 4 +- .../Keycaps/KeycapItemFactoryTests.swift | 8 +++- 9 files changed, 46 insertions(+), 82 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift index 589380b4..0f8ddf16 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift @@ -127,7 +127,7 @@ enum KeyboardKeyCode: UInt16 { extension KeyboardKeyCode { /// Keys whose press/release lifecycle is visualized from modifier-state updates /// rather than the normal keystroke path. - var isModifierOnly: Bool { + var isFlagsChangedDriven: Bool { switch self { case .commandRight, .commandLeft, .shiftLeft, .shiftRight, @@ -140,8 +140,8 @@ extension KeyboardKeyCode { } } - static func isModifierOnly(_ rawValue: UInt16) -> Bool { + static func isFlagsChangedDriven(_ rawValue: UInt16) -> Bool { guard let keyCode = Self(rawValue: rawValue) else { return false } - return keyCode.isModifierOnly + return keyCode.isFlagsChangedDriven } } diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift index 458f7648..ddfa1e35 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift @@ -62,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 } } @@ -81,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, @@ -100,8 +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 44ec8a13..fb063922 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,6 +47,7 @@ extension KeyboardModifierKey { case .shift: return .shift case .option: return .option case .control: return .control + case .function: return .function } } @@ -54,6 +58,7 @@ extension KeyboardModifierKey { case .shift: return [.shiftLeft, .shiftRight] case .option: return [.optionLeft, .optionRight] case .control: return [.controlLeft, .controlRight] + case .function: return [.function] } } @@ -67,8 +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 650b4273..d58facb0 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] + private static let trackedModifierFlags: NSEvent.ModifierFlags = [.command, .shift, .option, .control, .function] 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 KeyboardKeyCode.isModifierOnly(keystroke.keyCode) { + if KeyboardKeyCode.isFlagsChangedDriven(keystroke.keyCode) { return } @@ -191,8 +191,6 @@ 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) @@ -213,20 +211,6 @@ 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( @@ -235,7 +219,7 @@ private extension KeyboardVisualizer { palette: self.visualizerSettings.palette ) guard !items.isEmpty else { - if currentTrackedFlags.isEmpty, !functionNow { + if currentTrackedFlags.isEmpty { self.eventCoordinator.reset() } return @@ -259,19 +243,6 @@ 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 7e07aa4f..e418ed56 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapEventCoordinator.swift @@ -154,41 +154,6 @@ 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 10a5cac6..40ba28dd 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,8 +14,6 @@ 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) @@ -38,7 +36,10 @@ extension KeycapItemFactory { } if currentFlags.contains(modifier.flag) || releasedFlags.contains(modifier.flag) { - let modifierKey = KeyboardModifierKey(modifier, location: .left) + let modifierKey = KeyboardModifierKey( + modifier, + location: modifier == .function ? .single : .left + ) items.append(Self.modifierItem( modifierKey, isPressed: currentFlags.contains(modifier.flag), @@ -70,7 +71,8 @@ extension KeycapItemFactory { releasedModifierKeys: Set ) -> [KeyboardModifierKey] { let keys = currentModifierKeys.union(releasedModifierKeys) - return Self.orderedModifierLocations + let locations: [KeyboardModifierKey.Location] = modifier == .function ? [.single] : Self.orderedModifierLocations + return locations .map { KeyboardModifierKey(modifier, location: $0) } .filter { keys.contains($0) } } 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 31aad36a..8e9f220e 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 3b421ad9..e2d647a5 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift @@ -125,7 +125,7 @@ final class KeyboardVisualizerTests: XCTestCase { XCTAssertEqual(self.visualizer.visibleGroupCount, 1) } - func testFunctionTransformedKeyRepeatsCollapseLikeStandaloneSpecialKeys() { + func testFunctionTransformedKeyRepeatsAppendNewGroupsWhileFnIsHeld() { self.settings.collapseRepeatedGroups = true self.settings.showSpecialKeys = true self.visualizer.isPresentationActive = true @@ -155,7 +155,7 @@ final class KeyboardVisualizerTests: XCTestCase { charactersIgnoringModifiers: pageUpCharacter ))) - XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + XCTAssertEqual(self.visualizer.visibleGroupCount, 2) } func testFunctionTransformedKeyDoesNotRenderFunctionModifierPreviewOnRelease() { 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 e0e5444a..258ca6bc 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift @@ -21,14 +21,18 @@ final class KeycapItemFactoryTests: XCTestCase { XCTAssertEqual(KeyboardModifierKey.keys(in: flags), [.leftCommand, .rightCommand]) } - func testModifierItemsIgnoreFunctionFlag() { + func testModifierItemsIncludeFunctionKey() { let items = KeycapItemFactory.modifierItems( currentFlags: [.function], releasedFlags: [], palette: Self.makePalette() ) - XCTAssertTrue(items.isEmpty) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items.first?.identity, .modifier(.function)) + XCTAssertEqual(items.first?.label, "fn") + XCTAssertEqual(items.first?.sfSymbolName, "globe") + XCTAssertEqual(items.first?.isPressed, true) } func testModifierItemsUseLocationSpecificModifierKeys() { From 998a09ff06a22d6d49da817f4dee894fe92a355f Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Tue, 25 Aug 2026 20:28:54 +0200 Subject: [PATCH 6/6] refactor(keyboard): fn is non modifier --- .../Keyboard/KeyboardVisualizer.swift | 35 +++++++++++++++++-- .../Keyboard/KeycapEventCoordinator.swift | 35 +++++++++++++++++++ .../KeycapItemFactory+Modifiers.swift | 10 +++--- .../Keycaps/KeyboardVisualizerTests.swift | 2 +- .../Keycaps/KeycapItemFactoryTests.swift | 8 ++--- 5 files changed, 74 insertions(+), 16 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift index d58facb0..75895909 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 { @@ -165,7 +165,7 @@ private extension KeyboardVisualizer { let items = KeycapItemFactory.keycapItems( keyCode: keystroke.keyCode, legend: legend, - modifierFlags: keystroke.modifierFlags, + modifierFlags: keystroke.modifierFlags.subtracting(.function), isPressed: keystroke.type != .keyUp, palette: self.visualizerSettings.palette ) @@ -191,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) @@ -211,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( @@ -219,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 @@ -243,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 e418ed56..7e07aa4f 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 40ba28dd..10a5cac6 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) @@ -36,10 +38,7 @@ extension KeycapItemFactory { } if currentFlags.contains(modifier.flag) || releasedFlags.contains(modifier.flag) { - let modifierKey = KeyboardModifierKey( - modifier, - location: modifier == .function ? .single : .left - ) + let modifierKey = KeyboardModifierKey(modifier, location: .left) items.append(Self.modifierItem( modifierKey, isPressed: currentFlags.contains(modifier.flag), @@ -71,8 +70,7 @@ extension KeycapItemFactory { releasedModifierKeys: Set ) -> [KeyboardModifierKey] { let keys = currentModifierKeys.union(releasedModifierKeys) - let locations: [KeyboardModifierKey.Location] = modifier == .function ? [.single] : Self.orderedModifierLocations - return locations + return Self.orderedModifierLocations .map { KeyboardModifierKey(modifier, location: $0) } .filter { keys.contains($0) } } 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 e2d647a5..4b0a9c05 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerTests.swift @@ -181,7 +181,7 @@ final class KeyboardVisualizerTests: XCTestCase { ))) self.visualizer.display(.modifierStateChanged([])) - XCTAssertEqual(self.visualizer.visibleGroupCount, 1) + XCTAssertEqual(self.visualizer.visibleGroupCount, 2) } func testCollapseRepeatedGroupsReusesPreviousStandaloneKeyGroup() { 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 258ca6bc..e0e5444a 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, .modifier(.function)) - XCTAssertEqual(items.first?.label, "fn") - XCTAssertEqual(items.first?.sfSymbolName, "globe") - XCTAssertEqual(items.first?.isPressed, true) + XCTAssertTrue(items.isEmpty) } func testModifierItemsUseLocationSpecificModifierKeys() {