diff --git a/Apps/Keyty/Sources/Keyty/Domain/Events/InputEvent.swift b/Apps/Keyty/Sources/Keyty/Domain/Events/InputEvent.swift index a5e5990..5df8bae 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Events/InputEvent.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Events/InputEvent.swift @@ -35,7 +35,4 @@ public enum InputEvent { } } - public var displayString: String { - EventTransformer.shared.transform(self) - } } diff --git a/Apps/Keyty/Sources/Keyty/Domain/Events/MediaKeyEvent.swift b/Apps/Keyty/Sources/Keyty/Domain/Events/MediaKeyEvent.swift index 636ef83..7e4ba7c 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Events/MediaKeyEvent.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Events/MediaKeyEvent.swift @@ -70,7 +70,4 @@ public struct MediaKeyEvent { .mediaKey(self) } - public var displayString: String { - EventTransformer.shared.transform(inputEvent) - } } diff --git a/Apps/Keyty/Sources/Keyty/Domain/Events/MouseEvent.swift b/Apps/Keyty/Sources/Keyty/Domain/Events/MouseEvent.swift index 785f8b7..950a0b6 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Events/MouseEvent.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Events/MouseEvent.swift @@ -47,10 +47,6 @@ public struct MouseEvent { .mouse(self) } - public var displayString: String { - EventTransformer.shared.transform(inputEvent) - } - public var hasZeroScrollDelta: Bool { self.scrollingDeltaX == 0.0 && self.scrollingDeltaY == 0.0 } diff --git a/Apps/Keyty/Sources/Keyty/Domain/Events/StandardKeyEvent.swift b/Apps/Keyty/Sources/Keyty/Domain/Events/StandardKeyEvent.swift index 55079dd..3fbd74b 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Events/StandardKeyEvent.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Events/StandardKeyEvent.swift @@ -15,6 +15,16 @@ public struct StandardKeyEvent { public let characters: String? public let charactersIgnoringModifiers: String? + /// A key identified only by its code, for previews and samples that have no + /// event behind them. + public init(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags = []) { + self.type = .keyDown + self.modifierFlags = modifierFlags + self.keyCode = keyCode + self.characters = nil + self.charactersIgnoringModifiers = nil + } + public init(nsEvent event: NSEvent) { self.type = event.type self.modifierFlags = event.modifierFlags @@ -27,10 +37,6 @@ public struct StandardKeyEvent { .keystroke(self) } - public var displayString: String { - EventTransformer.shared.transform(self.inputEvent) - } - public var isModified: Bool { !self.modifierFlags.intersection([.control, .command, .option, .shift]).isEmpty } diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift index 1deb1cf..d8aac80 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardGlyphCatalog.swift @@ -23,9 +23,6 @@ enum KeyboardGlyphCatalog { static let tab = UnicodeToken.tab.string - /// Glyphs that can prefix a chord in display strings. - static let modifierSymbols: [String] = KeyboardModifierKey.Kind.allCases.map(\.glyph) - /// Physical key codes for left and right command, shift, option, and control keys. static let modifierKeyCodes: Set = Set(KeyboardModifierKey.Kind.allCases.flatMap(\.keyCodes)) @@ -39,26 +36,4 @@ enum KeyboardGlyphCatalog { key.displayText } - /// Returns the normalized display symbol we show for fallback display text. - static func displaySymbol(for text: String) -> String { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - switch trimmed.uppercased() { - case "ESC": - return UnicodeToken.escape.string - case "DELETE", "BACKSPACE": - return UnicodeToken.delete.string - case "DEL", "FORWARD DELETE": - return UnicodeToken.forwardDelete.string - case "RETURN": - return UnicodeToken.returnKey.string - case "ENTER": - return UnicodeToken.keypadEnter.string - case "TAB": - return tab - case "SPACE": - return UnicodeToken.visibleSpace.string - default: - return trimmed.uppercased() - } - } } diff --git a/Apps/Keyty/Sources/Keyty/Domain/Models/EventLegend.swift b/Apps/Keyty/Sources/Keyty/Domain/Models/EventLegend.swift new file mode 100644 index 0000000..a179a40 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Domain/Models/EventLegend.swift @@ -0,0 +1,56 @@ +// +// EventLegend.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Foundation + +/// The resolved visual form of a single input event. +/// +/// Names what to draw without deciding how large to draw it: every case +/// identifies a resource, and renderers pick their own sizes, fonts, and colors. +struct EventLegend: Equatable { + /// Modifiers held for this event (in canonical display order) + let modifiers: [KeyboardModifierKey.Kind] + + /// How the key itself should be drawn. + let kind: Kind + + /// The key's textual form, always populated so it can stand in when `kind` cannot be rendered. + /// (e.g. the "tab" key will store `⇥` here). + let text: String + + /// Secondary label for keycap styles that pair a symbol with a name. + /// (e.g. the "tab" key will store "tab" here). + let label: String? + + init( + modifiers: [KeyboardModifierKey.Kind] = [], + kind: Kind = .text, + text: String, + label: String? = nil + ) { + self.modifiers = modifiers + self.kind = kind + self.text = text + self.label = label + } +} + +extension EventLegend { + /// The three ways a legend can be drawn, matching the branches every + /// keycap renderer already has. + enum Kind: Equatable { + /// Draw `text`. + case text + + /// Draw the named SF Symbol. + case symbol(String) + + /// Draw the icon for this mouse action. + case mouseIcon(MouseEvent.Kind) + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane+PreviewGroup.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane+PreviewGroup.swift index 42be512..5cc01ad 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane+PreviewGroup.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane+PreviewGroup.swift @@ -109,13 +109,13 @@ extension KeyboardSettingsPane.PreviewGroup { [ .key( keyCode: KeyboardKeyCode.a.rawValue, - displayString: "A" + legend: Self.previewLegend(for: .a) ) ], [ .key( keyCode: KeyboardKeyCode.space.rawValue, - displayString: "Space" + legend: Self.previewLegend(for: .space) ) ], ] @@ -129,7 +129,7 @@ extension KeyboardSettingsPane.PreviewGroup { ), .key( keyCode: 0x28, - displayString: "K" + legend: Self.previewLegend(for: .k) ), ], [ @@ -138,7 +138,7 @@ extension KeyboardSettingsPane.PreviewGroup { ), .key( keyCode: KeyboardKeyCode.escape.rawValue, - displayString: KeyboardGlyphCatalog.symbol(for: KeyboardSpecialKey.escape) + legend: Self.previewLegend(for: .escape) ), ], ] @@ -152,13 +152,13 @@ extension KeyboardSettingsPane.PreviewGroup { ), .key( keyCode: KeyboardKeyCode.tab.rawValue, - displayString: KeyboardGlyphCatalog.tab + legend: Self.previewLegend(for: .tab) ) ], [ .key( keyCode: KeyboardKeyCode.returnKey.rawValue, - displayString: KeyboardGlyphCatalog.symbol(for: KeyboardSpecialKey.returnKey) + legend: Self.previewLegend(for: .returnKey) ) ], ] @@ -217,3 +217,11 @@ extension KeyboardSettingsPane { var id: String { "\(self.category.rawValue)-\(self.variant)" } } } + +// MARK: - Preview Legends +private extension KeyboardSettingsPane.PreviewGroup { + static func previewLegend(for keyCode: KeyboardKeyCode) -> EventLegend { + EventLegendResolver(keyboardLayout: KeyboardInputSourceManager.shared.currentInputSource) + .legend(forKeyCode: keyCode) + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift index 9fcbc35..370cd75 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift @@ -7,6 +7,7 @@ // import AppKit +import Carbon import Combine final class KeyboardVisualizer { @@ -29,6 +30,9 @@ final class KeyboardVisualizer { private let visualizerSettings: KeyboardVisualizerSettings private let visualizerWindow: KeyboardVisualizerWindow private let eventCoordinator = KeycapEventCoordinator() + + // Rebuilt only when the selected keyboard layout changes. + private var cachedLegendResolver: (source: TISInputSource, resolver: EventLegendResolver)? private var cancellables = Set() private var currentModifierFlags: NSEvent.ModifierFlags = [] private var lastModifierFlags: NSEvent.ModifierFlags = [] @@ -141,7 +145,11 @@ final class KeyboardVisualizer { return } - let items = KeycapItemFactory.keycapItems(for: keystroke, palette: self.visualizerSettings.palette) + let items = KeycapItemFactory.keycapItems( + for: keystroke, + legend: self.legendResolver.legend(for: .keystroke(keystroke)), + palette: self.visualizerSettings.palette + ) guard !items.isEmpty else { return } self.prepareForNextContentEvent() @@ -239,3 +247,17 @@ final class KeyboardVisualizer { self.currentModifierFlags.intersection(Self.trackedModifierFlags) } } + +// MARK: - Legend Resolution +private extension KeyboardVisualizer { + var legendResolver: EventLegendResolver { + let source = KeyboardInputSourceManager.shared.currentInputSource + if let cached = self.cachedLegendResolver, cached.source === source { + return cached.resolver + } + + let resolver = EventLegendResolver(keyboardLayout: source) + self.cachedLegendResolver = (source, resolver) + return resolver + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift index ebb4ce1..57de734 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift @@ -11,7 +11,7 @@ import AppKit enum KeycapPreviewSample { case key( keyCode: UInt16, - displayString: String, + legend: EventLegend, modifierFlags: NSEvent.ModifierFlags = [], isPressed: Bool = false ) @@ -32,10 +32,14 @@ enum KeycapItemFactory { private static let mouseIconHeight: CGFloat = 44 private static let orderedModifierLocations: [KeyboardModifierKey.Location] = [.left, .right] - static func keycapItems(for keystroke: StandardKeyEvent, palette: KeycapThemePalette) -> [KeycapItem] { + static func keycapItems( + for keystroke: StandardKeyEvent, + legend: EventLegend, + palette: KeycapThemePalette + ) -> [KeycapItem] { Self.keycapItems( keyCode: keystroke.keyCode, - displayString: keystroke.displayString, + legend: legend, modifierFlags: keystroke.modifierFlags, isPressed: keystroke.type != .keyUp, palette: palette @@ -44,7 +48,7 @@ enum KeycapItemFactory { static func keycapItems( keyCode: UInt16, - displayString: String, + legend: EventLegend, modifierFlags: NSEvent.ModifierFlags, isPressed: Bool, palette: KeycapThemePalette @@ -54,34 +58,14 @@ enum KeycapItemFactory { releasedFlags: [], palette: palette ) - let stripped = Self.stripModifierPrefixes(from: displayString) - guard !stripped.isEmpty else { return result } - - if let symbolName = InputEventSymbolMapper.keystrokeSymbolName(for: keyCode) { - let identity = KeycapIdentity.keyCode(keyCode) - result.append(KeycapItem( - identity: identity, - legend: KeycapLegend(sfSymbolName: symbolName), - state: KeycapState(isPressed: isPressed), - appearance: palette.appearance(for: identity) - )) - return result - } - - if let specialItem = Self.specialKeycapItem( - keyCode: keyCode, - isPressed: isPressed, - palette: palette - ) { - result.append(specialItem) - return result - } + guard !legend.text.isEmpty || legend.kind != .text else { return result } let identity = KeycapIdentity.keyCode(keyCode) result.append(KeycapItem( identity: identity, - legend: KeycapLegend(symbol: KeyboardGlyphCatalog.displaySymbol(for: stripped)), + legend: Self.keycapLegend(for: keyCode, legend: legend), state: KeycapState(isPressed: isPressed), + layoutHints: Self.layoutHints(forKeyCode: keyCode), appearance: palette.appearance(for: identity) )) return result @@ -190,10 +174,10 @@ enum KeycapItemFactory { static func items(for sample: KeycapPreviewSample, palette: KeycapThemePalette) -> [KeycapItem] { switch sample { - case let .key(keyCode, displayString, modifierFlags, isPressed): + case let .key(keyCode, legend, modifierFlags, isPressed): return Self.keycapItems( keyCode: keyCode, - displayString: displayString, + legend: legend, modifierFlags: modifierFlags, isPressed: isPressed, palette: palette @@ -207,77 +191,28 @@ enum KeycapItemFactory { } } - private static func specialKeycapItem( - keyCode: UInt16, - isPressed: Bool, - palette: KeycapThemePalette - ) -> KeycapItem? { - guard let specialKey = KeyboardSpecialKeyResolver.specialKey(for: keyCode) else { return nil } - - let identity = KeycapIdentity.keyCode(keyCode) - let appearance = palette.appearance(for: identity) - switch specialKey { + // 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 { + switch KeyboardSpecialKeyResolver.specialKey(for: keyCode) { case .function: - return KeycapItem( - identity: identity, - legend: .function, - state: KeycapState(isPressed: isPressed), - appearance: appearance - ) - case .tab: - return KeycapItem( - identity: identity, - legend: .tab, - state: KeycapState(isPressed: isPressed), - layoutHints: KeycapLayoutHints(alignment: .left), - appearance: appearance - ) - case .escape: - return KeycapItem( - identity: identity, - legend: .escape, - state: KeycapState(isPressed: isPressed), - layoutHints: KeycapLayoutHints(alignment: .left), - appearance: appearance - ) - case .delete: - return KeycapItem( - identity: identity, - legend: .delete, - state: KeycapState(isPressed: isPressed), - appearance: appearance - ) - case .forwardDelete: - return KeycapItem( - identity: identity, - legend: .forwardDelete, - state: KeycapState(isPressed: isPressed), - appearance: appearance - ) - case .returnKey: - return KeycapItem( - identity: identity, - legend: .return, - state: KeycapState(isPressed: isPressed), - appearance: appearance - ) - case .keypadEnter: - return KeycapItem( - identity: identity, - legend: .enter, - state: KeycapState(isPressed: isPressed), - appearance: appearance - ) + return .function + case .capsLock: + return .capsLock + default: + return KeycapLegend(legend, mouseIconHeight: Self.mouseIconHeight) + } + } + + // Layout is a rendering concern, so it stays here rather than in the legend. + private static func layoutHints(forKeyCode keyCode: UInt16) -> KeycapLayoutHints { + switch KeyboardSpecialKeyResolver.specialKey(for: keyCode) { + case .tab, .escape: + return KeycapLayoutHints(alignment: .left) case .space: - return KeycapItem( - identity: identity, - legend: .space, - state: KeycapState(isPressed: isPressed), - layoutHints: KeycapLayoutHints(fixedWidth: 256), - appearance: appearance - ) + return KeycapLayoutHints(fixedWidth: 256) default: - return nil + return KeycapLayoutHints() } } @@ -331,18 +266,6 @@ enum KeycapItemFactory { ) } - private static func stripModifierPrefixes(from text: String) -> String { - var remainder = text.trimmingCharacters(in: .whitespacesAndNewlines) - var changed = true - while changed { - changed = false - for sym in KeyboardGlyphCatalog.modifierSymbols where remainder.hasPrefix(sym) { - remainder.removeFirst(sym.count) - changed = true - } - } - return remainder.trimmingCharacters(in: .whitespacesAndNewlines) - } } private extension KeyboardModifierKey { 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 c27ad5f..a6cbb89 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift @@ -38,6 +38,23 @@ struct KeycapLegend { // MARK: - Helpers extension KeycapLegend { + /// Renders a resolved legend, loading its resource at this renderer's size. + init(_ legend: EventLegend, mouseIconHeight: CGFloat) { + switch legend.kind { + case .text: + self.init(symbol: legend.text, label: legend.label) + case .symbol(let name): + self.init(sfSymbolName: name, label: legend.label) + case .mouseIcon(let kind): + self.init( + symbol: legend.text, + image: MouseEventDisplayRenderer.templateIconImage(for: kind, height: mouseIconHeight), + imageBadgeText: kind.otherButtonNumber.map(String.init), + label: legend.label + ) + } + } + static func modifier(_ modifier: KeyboardModifierKey.Kind) -> KeycapLegend { KeycapLegend(symbol: modifier.glyph, label: modifier.label) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/Core/MouseEventDisplayRenderer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/Core/MouseEventDisplayRenderer.swift index bb1d902..a40ce31 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/Core/MouseEventDisplayRenderer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Mouse/Core/MouseEventDisplayRenderer.swift @@ -34,37 +34,6 @@ enum MouseEventDisplayRenderer { return image } - static func attributedRepresentation(forBezel event: MouseEvent, font: NSFont, color textColor: NSColor) -> NSAttributedString { - let targetHeight = font.ascender + (-font.descender) - guard let icon = Self.iconImage(for: event, height: targetHeight, color: textColor) else { - return NSAttributedString() - } - - let attachment = NSTextAttachment() - attachment.image = icon - attachment.bounds = NSRect(x: 0, y: font.descender, width: icon.size.width, height: icon.size.height) - - if event.kind.isScroll { - return NSAttributedString(attachment: attachment) - } - - let text = event.displayString - let splitIndex: String.Index - if let asciiIndex = text.firstIndex(where: { character in - character.unicodeScalars.contains { $0.value < 128 } - }) { - splitIndex = asciiIndex - } else { - splitIndex = text.endIndex - } - - let result = NSMutableAttributedString() - if splitIndex > text.startIndex { - result.append(NSAttributedString(string: String(text[.. NSImage? { switch kind { diff --git a/Apps/Keyty/Sources/Keyty/Services/Display/EventLegendResolver.swift b/Apps/Keyty/Sources/Keyty/Services/Display/EventLegendResolver.swift new file mode 100644 index 0000000..ce82ba9 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Services/Display/EventLegendResolver.swift @@ -0,0 +1,108 @@ +// +// EventLegendResolver.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit +import Carbon + +/// Resolves an input event into the legend that represents it on screen. +/// +/// This is the single place that decides what a key shows: renderers consume +/// the result without re-deriving anything from it. +struct EventLegendResolver { + private let uchrData: UnsafePointer + + init(keyboardLayout: TISInputSource) { + self.uchrData = keyboardLayout.unicodeKeyboardLayout + } + + func legend(for event: InputEvent) -> EventLegend { + switch event { + case .keystroke(let keystroke): + return self.legend(for: keystroke) + case .mouse(let mouseEvent): + return self.legend(for: mouseEvent) + case .mediaKey(let mediaKey): + return self.legend(for: mediaKey) + } + } + + /// Resolves a key with no event behind it, for previews and samples. + /// + /// Keys told apart by their characters rather than their key code — Help + /// versus Insert — resolve to the key code's meaning here. + func legend(forKeyCode keyCode: KeyboardKeyCode, modifiers: NSEvent.ModifierFlags = []) -> EventLegend { + self.legend(for: .keystroke(StandardKeyEvent(keyCode: keyCode.rawValue, modifierFlags: modifiers))) + } +} + +// MARK: - Per-Event Resolution +private extension EventLegendResolver { + func legend(for keystroke: StandardKeyEvent) -> EventLegend { + let modifiers = Self.modifiers(in: keystroke.modifierFlags) + let specialKey = KeyboardSpecialKeyResolver.specialKey(for: keystroke) + + if let symbolName = InputEventSymbolMapper.keystrokeSymbolName(for: keystroke.keyCode) { + return EventLegend( + modifiers: modifiers, + kind: .symbol(symbolName), + text: specialKey?.displayText ?? self.translatedKey(keystroke), + label: specialKey?.label + ) + } + + if let specialKey { + return EventLegend( + modifiers: modifiers, + kind: .text, + text: specialKey.displayText, + label: specialKey.label + ) + } + + return EventLegend( + modifiers: modifiers, + kind: .text, + text: self.translatedKey(keystroke) + ) + } + + func legend(for mouseEvent: MouseEvent) -> EventLegend { + EventLegend( + modifiers: Self.modifiers(in: mouseEvent.modifierFlags), + kind: .mouseIcon(mouseEvent.kind), + text: InputEventGlyphMapper.mouseDisplayText(for: mouseEvent.kind) + ) + } + + func legend(for mediaKey: MediaKeyEvent) -> EventLegend { + let symbolName = InputEventSymbolMapper.mediaKeySymbolName(for: mediaKey.kind) + return EventLegend( + kind: symbolName.map(EventLegend.Kind.symbol) ?? .text, + text: InputEventGlyphMapper.mediaKeyGlyph(for: mediaKey) + ) + } +} + +// MARK: - Layout Translation +private extension EventLegendResolver { + // The legend printed on the physical key, cased for a modified chord. + func translatedKey(_ keystroke: StandardKeyEvent) -> String { + let translated = self.uchrData.translatedKeyCode(keystroke.keyCode) + return keystroke.isModified ? Self.legendCased(translated) : translated + } + + // Uppercases a legend only when the result keeps its length. + static func legendCased(_ text: String) -> String { + let uppercased = text.uppercased() + return uppercased.count == text.count ? uppercased : text + } + + static func modifiers(in flags: NSEvent.ModifierFlags) -> [KeyboardModifierKey.Kind] { + KeyboardModifierKey.Kind.canonicalDisplayOrder.filter { flags.contains($0.flag) } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Services/EventPipeline/EventTransformer.swift b/Apps/Keyty/Sources/Keyty/Services/EventPipeline/EventTransformer.swift deleted file mode 100644 index 68a9619..0000000 --- a/Apps/Keyty/Sources/Keyty/Services/EventPipeline/EventTransformer.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// EventTransformer.swift -// Keyty -// -// SPDX-FileCopyrightText: 2026 Serhii Bykov -// SPDX-License-Identifier: BSD-3-Clause -// - -import Carbon -import Foundation -import AppKit - -public final class EventTransformer { - private let keyboardLayout: TISInputSource - private let uchrData: UnsafePointer - - public static var shared: EventTransformer { - EventTransformer(keyboardLayout: KeyboardInputSourceManager.shared.currentInputSource) - } - - init(keyboardLayout: TISInputSource) { - self.keyboardLayout = keyboardLayout - self.uchrData = keyboardLayout.unicodeKeyboardLayout - } - - public func transform(_ event: InputEvent) -> String { - switch event { - case .keystroke(let keystroke): - return self.transform(keystroke) - case .mouse(let mouseEvent): - return self.transform(mouseEvent) - case .mediaKey(let mediaKey): - return self.transform(mediaKey) - } - } -} - -// MARK: - Event Transforms -private extension EventTransformer { - func transform(_ keystroke: StandardKeyEvent) -> String { - if let glyph = InputEventGlyphMapper.glyph(for: keystroke.inputEvent) { - return glyph - } - - var response = self.modifierPrefix(for: keystroke.modifierFlags) - - if let specialKeyString = KeyboardSpecialKeyResolver.displayText(for: keystroke) { - return response + specialKeyString - } - - response += self.uchrData.translatedKeyCode(keystroke.keyCode) - - if keystroke.isModified { - response = self.legendCased(response) - } - - return response - } - - func transform(_ mouseEvent: MouseEvent) -> String { - var response = self.modifierPrefix(for: mouseEvent.modifierFlags) - response += InputEventGlyphMapper.mouseDisplayText(for: mouseEvent.kind) - return response - } - - func transform(_ mediaKey: MediaKeyEvent) -> String { - InputEventGlyphMapper.mediaKeyGlyph(for: mediaKey) - } -} - -// MARK: - Display Formatting -private extension EventTransformer { - // Uppercases a legend only when the result keeps its length. - private func legendCased(_ text: String) -> String { - let uppercased = text.uppercased() - return uppercased.count == text.count ? uppercased : text - } - - - // The glyphs for the held modifiers, in Apple's canonical display order. - private func modifierPrefix(for modifiers: NSEvent.ModifierFlags) -> String { - KeyboardModifierKey.Kind.canonicalDisplayOrder - .filter { modifiers.contains($0.flag) } - .map(\.glyph) - .joined() - } -} diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerGroupViewSnapshotTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerGroupViewSnapshotTests.swift index c44bbd0..aa3cd93 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerGroupViewSnapshotTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerGroupViewSnapshotTests.swift @@ -15,14 +15,14 @@ import XCTest final class KeyboardVisualizerGroupViewSnapshotTests: XCTestCase { func testRendersAppleBlackKeycap() { self.assertAppleBlackSnapshot( - items: self.keycapItems(keyCode: KeyboardKeyCode.a.rawValue, displayString: "A"), + items: self.keycapItems(keyCode: KeyboardKeyCode.a.rawValue, legend: EventLegend(text: "A")), named: "apple-black-keycap" ) } func testRendersAppleBlackTabKeycap() { self.assertAppleBlackSnapshot( - items: self.keycapItems(keyCode: KeyboardKeyCode.tab.rawValue, displayString: KeyboardGlyphCatalog.tab), + items: self.keycapItems(keyCode: KeyboardKeyCode.tab.rawValue, legend: EventLegend(text: KeyboardGlyphCatalog.tab, label: KeyboardSpecialKey.tab.label)), named: "apple-black-tab-keycap" ) } @@ -38,7 +38,7 @@ final class KeyboardVisualizerGroupViewSnapshotTests: XCTestCase { self.assertAppleBlackSnapshot( items: self.keycapItems( keyCode: KeyboardKeyCode.escape.rawValue, - displayString: KeyboardGlyphCatalog.symbol(for: .escape) + legend: EventLegend(text: KeyboardGlyphCatalog.symbol(for: .escape), label: KeyboardSpecialKey.escape.label) ), named: "apple-black-escape-keycap" ) @@ -52,12 +52,12 @@ private extension KeyboardVisualizerGroupViewSnapshotTests { func keycapItems( keyCode: UInt16, - displayString: String, + legend: EventLegend, modifierFlags: NSEvent.ModifierFlags = [] ) -> [KeycapItem] { KeycapItemFactory.keycapItems( keyCode: keyCode, - displayString: displayString, + legend: legend, modifierFlags: modifierFlags, isPressed: false, palette: self.appleBlackSettings.palette @@ -73,7 +73,7 @@ private extension KeyboardVisualizerGroupViewSnapshotTests { ) + KeycapItemFactory.keycapItems( keyCode: KeyboardKeyCode.k.rawValue, - displayString: "K", + legend: EventLegend(text: "K"), modifierFlags: [], isPressed: false, palette: settings.palette 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 78c8b8d..bf39a36 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift @@ -7,6 +7,7 @@ // import AppKit +import Carbon import XCTest @testable import Keyty @@ -97,14 +98,14 @@ final class KeycapItemFactoryTests: XCTestCase { let returnItems = KeycapItemFactory.keycapItems( keyCode: KeyboardKeyCode.returnKey.rawValue, - displayString: KeyboardSpecialKey.returnKey.displayText, + legend: EventLegend(text: KeyboardSpecialKey.returnKey.displayText, label: KeyboardSpecialKey.returnKey.label), modifierFlags: [], isPressed: true, palette: palette ) let enterItems = KeycapItemFactory.keycapItems( keyCode: KeyboardKeyCode.keypadEnter.rawValue, - displayString: KeyboardSpecialKey.keypadEnter.displayText, + legend: EventLegend(text: KeyboardSpecialKey.keypadEnter.displayText, label: KeyboardSpecialKey.keypadEnter.label), modifierFlags: [], isPressed: true, palette: palette @@ -123,14 +124,14 @@ final class KeycapItemFactoryTests: XCTestCase { let deleteItems = KeycapItemFactory.keycapItems( keyCode: KeyboardKeyCode.delete.rawValue, - displayString: KeyboardSpecialKey.delete.displayText, + legend: EventLegend(text: KeyboardSpecialKey.delete.displayText, label: KeyboardSpecialKey.delete.label), modifierFlags: [], isPressed: true, palette: palette ) let forwardDeleteItems = KeycapItemFactory.keycapItems( keyCode: KeyboardKeyCode.forwardDelete.rawValue, - displayString: KeyboardSpecialKey.forwardDelete.displayText, + legend: EventLegend(text: KeyboardSpecialKey.forwardDelete.displayText, label: KeyboardSpecialKey.forwardDelete.label), modifierFlags: [], isPressed: true, palette: palette @@ -177,6 +178,28 @@ final class KeycapItemFactoryTests: XCTestCase { XCTAssertFalse(upItem.isPressed) } + /// The runtime path: resolver to factory. Guards the labels that hand-built + /// legends in the tests above do not exercise. + func testResolvedLegendsCarryTheirLabelsThroughToKeycaps() throws { + let resolver = EventLegendResolver(keyboardLayout: try TISInputSource.usEnglish()) + let palette = Self.makePalette() + let expected: [(KeyboardKeyCode, String)] = [ + (.tab, "tab"), (.escape, "esc"), (.delete, "delete"), + (.returnKey, "return"), (.keypadEnter, "enter") + ] + + for (keyCode, label) in expected { + let keystroke = StandardKeyEvent.stub(keyCode: keyCode) + let items = KeycapItemFactory.keycapItems( + for: keystroke, + legend: resolver.legend(for: InputEvent.keystroke(keystroke)), + palette: palette + ) + + XCTAssertEqual(items.first?.label, label, "for \(keyCode)") + } + } + private static func makePalette( style: KeycapStyle = .minimal, theme: KeyboardVisualizerTheme = .citrus diff --git a/Apps/Keyty/Tests/KeytyTests/Services/Display/EventLegendResolverMouseEventTests.swift b/Apps/Keyty/Tests/KeytyTests/Services/Display/EventLegendResolverMouseEventTests.swift new file mode 100644 index 0000000..eadf9a3 --- /dev/null +++ b/Apps/Keyty/Tests/KeytyTests/Services/Display/EventLegendResolverMouseEventTests.swift @@ -0,0 +1,80 @@ +// +// EventLegendResolverMouseEventTests.swift +// KeytyTests +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Carbon +import XCTest +@testable import Keyty + +final class EventLegendResolverMouseEventTests: XCTestCase { + var resolver: EventLegendResolver! + + func legend(_ event: MouseEvent) -> EventLegend { + self.resolver.legend(for: InputEvent.mouse(event)) + } + + override func setUpWithError() throws { + try super.setUpWithError() + self.resolver = EventLegendResolver(keyboardLayout: try TISInputSource.usEnglish()) + } +} + +// MARK: - Buttons + +extension EventLegendResolverMouseEventTests { + func testButtonsResolveToTheirIconAndName() { + let cases: [(NSEvent.EventType, Int, MouseEvent.Kind, String)] = [ + (.leftMouseDown, 0, .leftButton, "LMB"), + (.rightMouseDown, 1, .rightButton, "RMB"), + (.otherMouseDown, 2, .middleButton, "MMB"), + (.otherMouseDown, 3, .otherButton(4), "MB4"), + (.otherMouseDown, 4, .otherButton(5), "MB5") + ] + + for (type, buttonNumber, kind, text) in cases { + let legend = self.legend(.stub(type: type, buttonNumber: buttonNumber)) + + XCTAssertEqual(legend.kind, .mouseIcon(kind), "for \(kind)") + XCTAssertEqual(legend.text, text, "for \(kind)") + XCTAssertTrue(legend.modifiers.isEmpty, "for \(kind)") + } + } + + func testScrollDirectionsResolveToTheirOwnIcons() { + let cases: [(Int32, Int32, MouseEvent.Kind, String)] = [ + (0, 1, .wheelUp, "MWHEELUP"), + (0, -1, .wheelDown, "MWHEELDOWN"), + (1, 0, .wheelRight, "MWHEELRIGHT"), + (-1, 0, .wheelLeft, "MWHEELLEFT") + ] + + for (deltaX, deltaY, kind, text) in cases { + let legend = self.legend(.scrollStub(deltaX: deltaX, deltaY: deltaY)) + + XCTAssertEqual(legend.kind, .mouseIcon(kind), "for \(kind)") + XCTAssertEqual(legend.text, text, "for \(kind)") + } + } +} + +// MARK: - Modifiers + +extension EventLegendResolverMouseEventTests { + func testHeldModifiersAccompanyTheButton() { + let commandClick = self.legend(.stub(type: .leftMouseDown, modifiers: .command)) + XCTAssertEqual(commandClick.modifiers, [.command]) + XCTAssertEqual(commandClick.text, "LMB") + + let optionShiftClick = self.legend(.stub( + type: .rightMouseDown, + buttonNumber: 1, + modifiers: [.option, .shift] + )) + XCTAssertEqual(optionShiftClick.modifiers, [.option, .shift]) + XCTAssertEqual(optionShiftClick.text, "RMB") + } +} diff --git a/Apps/Keyty/Tests/KeytyTests/Services/Display/EventLegendResolverStandardKeyEventTests.swift b/Apps/Keyty/Tests/KeytyTests/Services/Display/EventLegendResolverStandardKeyEventTests.swift new file mode 100644 index 0000000..94cae81 --- /dev/null +++ b/Apps/Keyty/Tests/KeytyTests/Services/Display/EventLegendResolverStandardKeyEventTests.swift @@ -0,0 +1,203 @@ +// +// EventLegendResolverStandardKeyEventTests.swift +// KeytyTests +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Carbon +import XCTest +@testable import Keyty + +final class EventLegendResolverStandardKeyEventTests: XCTestCase { + var resolver: EventLegendResolver! + + func legend(_ event: StandardKeyEvent) -> EventLegend { + self.resolver.legend(for: InputEvent.keystroke(event)) + } + + override func setUpWithError() throws { + try super.setUpWithError() + self.resolver = EventLegendResolver(keyboardLayout: try TISInputSource.usEnglish()) + } +} + +// MARK: - Modifiers + +extension EventLegendResolverStandardKeyEventTests { + /// Written out independently of the `canonicalDisplayOrder` the resolver + /// reads, so a reordering there fails here. + private static let expectedModifierOrder: [KeyboardModifierKey.Kind] = [.control, .option, .shift, .command] + + private static var allModifierCombinations: [(modifiers: NSEvent.ModifierFlags, kinds: [KeyboardModifierKey.Kind], name: String)] { + (0..<16).map { combination in + var kinds: [KeyboardModifierKey.Kind] = [] + var flags: NSEvent.ModifierFlags = [] + for (index, kind) in Self.expectedModifierOrder.enumerated() where combination & (1 << index) != 0 { + kinds.append(kind) + flags.insert(kind.flag) + } + return (.recorded(flags), kinds, kinds.isEmpty ? "no modifiers" : kinds.map(\.glyph).joined()) + } + } + + func testModifiersAreReportedInCanonicalOrder() { + for (modifiers, kinds, name) in Self.allModifierCombinations { + let legend = self.legend(.stub(keyCode: .digit7, modifiers: modifiers)) + + XCTAssertEqual(legend.modifiers, kinds, "for \(name)") + XCTAssertEqual(legend.text, "7", "for \(name)") + } + } + + /// A digit has no distinct uppercase form, so only letters show the casing rule. + func testLetterTextIsUppercasedOnlyWhenModified() { + for (modifiers, kinds, name) in Self.allModifierCombinations { + let legend = self.legend(.stub(keyCode: .a, modifiers: modifiers)) + + XCTAssertEqual(legend.text, kinds.isEmpty ? "a" : "A", "for \(name)") + XCTAssertEqual(legend.kind, .text, "for \(name)") + } + } + + /// Option produces a dead key or an alternate character, but the legend stays + /// the one printed on the key. + func testOptionTextIgnoresTheAlternateCharacter() { + for (keyCode, expected) in [(KeyboardKeyCode.u, "U"), (.e, "E"), (.grave, "`")] { + let legend = self.legend(.stub(keyCode: keyCode, modifiers: .recorded(.option))) + + XCTAssertEqual(legend.text, expected, "for \(keyCode)") + XCTAssertEqual(legend.modifiers, [.option], "for \(keyCode)") + } + } +} + +// MARK: - Legend Casing + +extension EventLegendResolverStandardKeyEventTests { + /// German ß uppercases to "SS", which would misreport the key's legend. + func testTextThatExpandsWhenUppercasedIsLeftAlone() throws { + let german = EventLegendResolver(keyboardLayout: try TISInputSource.german()) + let legend = german.legend(for: InputEvent.keystroke(.stub(keyCode: .minus, modifiers: .recorded(.command)))) + + XCTAssertEqual(legend.text, "ß") + XCTAssertEqual(legend.modifiers, [.command]) + } +} + +// MARK: - Keys Named by the Layout + +extension EventLegendResolverStandardKeyEventTests { + func testEditingKeysUseDistinctSymbols() { + let cases: [(KeyboardKeyCode, String)] = [ + (.tab, KeyboardGlyphCatalog.tab), + (.returnKey, UnicodeToken.returnKey.string), + (.keypadEnter, UnicodeToken.keypadEnter.string), + (.delete, UnicodeToken.delete.string), + (.forwardDelete, UnicodeToken.forwardDelete.string) + ] + + for (keyCode, expected) in cases { + XCTAssertEqual(self.legend(.stub(keyCode: keyCode)).text, expected, "for \(keyCode)") + } + } + + func testArrowKeysUseFilledTriangleSymbols() { + let cases: [(KeyboardKeyCode, String)] = [ + (.leftArrow, UnicodeToken.leftArrow.string), + (.upArrow, UnicodeToken.upArrow.string), + (.downArrow, UnicodeToken.downArrow.string), + (.rightArrow, UnicodeToken.rightArrow.string) + ] + + for (keyCode, expected) in cases { + XCTAssertEqual(self.legend(.stub(keyCode: keyCode)).text, expected, "for \(keyCode)") + } + } + + func testModifiersAccompanyASpecialKeySymbol() { + let character = String.functionKey(NSUpArrowFunctionKey) + let legend = self.legend(.stub( + keyCode: .upArrow, + modifiers: .recorded([.function, .option, .shift, .numericPad]), + characters: character, + charactersIgnoringModifiers: character + )) + + XCTAssertEqual(legend.modifiers, [.option, .shift]) + XCTAssertEqual(legend.text, UnicodeToken.upArrow.string) + } + + /// System keys prefer an SF Symbol, and keep a short name behind it. + func testSystemKeysResolveToSymbolsWithATextFallback() { + let cases: [(KeyboardKeyCode, String, String)] = [ + (.brightnessDown, "sun.min.fill", "dimmer"), + (.brightnessUp, "sun.max.fill", "brighter"), + (.missionControl, "square.grid.2x2.fill", "mission"), + (.launchpad, "square.grid.3x3.fill", "launchpad") + ] + + for (keyCode, symbolName, text) in cases { + let legend = self.legend(.stub(keyCode: keyCode, modifiers: .recorded(.function))) + + XCTAssertEqual(legend.kind, .symbol(symbolName), "for \(keyCode)") + XCTAssertEqual(legend.text, text, "for \(keyCode)") + } + } + + func testJapaneseInputKeysUseTheirOwnLabels() { + for (keyCode, specialKey) in [(KeyboardKeyCode.eisu, KeyboardSpecialKey.eisu), (.kana, .kana)] { + XCTAssertEqual(self.legend(.stub(keyCode: keyCode)).text, specialKey.displayText, "for \(keyCode)") + } + } +} + +// MARK: - Keys Named by the Event + +extension EventLegendResolverStandardKeyEventTests { + /// macOS reuses the Help key code for Insert on many external keyboards, so + /// these three are told apart by the event's characters, not the key code. + func testHelpKeyCodeResolvesFromTheEventCharacters() { + let insertCharacter = String.functionKey(NSInsertFunctionKey) + let insert = self.legend(.stub( + keyCode: .help, + characters: insertCharacter, + charactersIgnoringModifiers: insertCharacter + )) + XCTAssertEqual(insert.text, KeyboardSpecialKey.insert.displayText) + + let helpCharacter = String.functionKey(NSHelpFunctionKey) + let help = self.legend(.stub( + keyCode: .help, + characters: helpCharacter, + charactersIgnoringModifiers: helpCharacter + )) + XCTAssertEqual(help.text, "help") + + let withoutCharacters = self.legend(.stub(keyCode: .help)) + XCTAssertEqual(withoutCharacters.text, KeyboardSpecialKey.insert.displayText) + } +} + +// MARK: - Key-Code-Only Resolution + +extension EventLegendResolverStandardKeyEventTests { + /// Previews resolve without an event behind them; they must match the + /// visualizer, which is what the settings samples rely on. + func testKeyCodeOnlyResolutionMatchesTheEventPath() { + for keyCode: KeyboardKeyCode in [.a, .space, .escape, .tab, .returnKey, .brightnessUp] { + let fromEvent = self.legend(.stub(keyCode: keyCode)) + let fromKeyCode = self.resolver.legend(forKeyCode: keyCode) + + XCTAssertEqual(fromKeyCode, fromEvent, "for \(keyCode)") + } + } + + func testSpaceResolvesToItsSymbolRatherThanItsName() { + let legend = self.resolver.legend(forKeyCode: .space) + + XCTAssertEqual(legend.text, UnicodeToken.visibleSpace.string) + XCTAssertEqual(legend.kind, .text) + } +} diff --git a/Apps/Keyty/Tests/KeytyTests/Services/EventPipeline/EventTransformerMouseEventTests.swift b/Apps/Keyty/Tests/KeytyTests/Services/EventPipeline/EventTransformerMouseEventTests.swift deleted file mode 100644 index 1442a49..0000000 --- a/Apps/Keyty/Tests/KeytyTests/Services/EventPipeline/EventTransformerMouseEventTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// EventTransformerMouseEventTests.swift -// KeytyTests -// -// SPDX-FileCopyrightText: 2026 Serhii Bykov -// SPDX-License-Identifier: BSD-3-Clause -// - -import Carbon -import XCTest -@testable import Keyty - -final class EventTransformerMouseEventTests: XCTestCase { - var keyboardLayout: TISInputSource! - - func transform(_ event: MouseEvent) -> String { - EventTransformer(keyboardLayout: keyboardLayout).transform(.mouse(event)) - } - - override func setUpWithError() throws { - try super.setUpWithError() - self.keyboardLayout = try TISInputSource.usEnglish() - } - - // MARK: - Mouse buttons - - func testLeftMouseDownIsLMB() { - let event = MouseEvent.stub(type: .leftMouseDown, buttonNumber: 0, modifiers: []) - XCTAssertEqual(transform(event), "LMB") - } - - func testRightMouseDownIsRMB() { - let event = MouseEvent.stub(type: .rightMouseDown, buttonNumber: 1, modifiers: []) - XCTAssertEqual(transform(event), "RMB") - } - - func testMiddleMouseDownIsMMB() { - let event = MouseEvent.stub(type: .otherMouseDown, buttonNumber: 2, modifiers: []) - XCTAssertEqual(transform(event), "MMB") - } - - func testFourthButtonIsMB4() { - let event = MouseEvent.stub(type: .otherMouseDown, buttonNumber: 3, modifiers: []) - XCTAssertEqual(transform(event), "MB4") - } - - func testFifthButtonIsMB5() { - let event = MouseEvent.stub(type: .otherMouseDown, buttonNumber: 4, modifiers: []) - XCTAssertEqual(transform(event), "MB5") - } - - func testCommandLeftClickShowsCommandLMB() { - let event = MouseEvent.stub(type: .leftMouseDown, buttonNumber: 0, modifiers: .command) - XCTAssertEqual(transform(event), KeyboardGlyphCatalog.command + "LMB") - } - - func testOptionShiftRightClickShowsModifiersWithRMB() { - let event = MouseEvent.stub(type: .rightMouseDown, buttonNumber: 1, modifiers: [.option, .shift]) - XCTAssertEqual(transform(event), KeyboardGlyphCatalog.option + KeyboardGlyphCatalog.shift + "RMB") - } -} diff --git a/Apps/Keyty/Tests/KeytyTests/Services/EventPipeline/EventTransformerStandardKeyEventTests.swift b/Apps/Keyty/Tests/KeytyTests/Services/EventPipeline/EventTransformerStandardKeyEventTests.swift deleted file mode 100644 index 73b8228..0000000 --- a/Apps/Keyty/Tests/KeytyTests/Services/EventPipeline/EventTransformerStandardKeyEventTests.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// EventTransformerStandardKeyEventTests.swift -// KeytyTests -// -// SPDX-FileCopyrightText: 2026 Serhii Bykov -// SPDX-License-Identifier: BSD-3-Clause -// - -import Carbon -import XCTest -@testable import Keyty - -final class EventTransformerStandardKeyEventTests: XCTestCase { - var transformer: EventTransformer! - - func transform(_ event: StandardKeyEvent) -> String { - self.transformer.transform(.keystroke(event)) - } - - override func setUpWithError() throws { - try super.setUpWithError() - self.transformer = EventTransformer(keyboardLayout: try TISInputSource.usEnglish()) - } -} - -// MARK: - Modifier Glyphs - -extension EventTransformerStandardKeyEventTests { - /// The order the glyphs are expected in, written out independently of the - /// `canonicalDisplayOrder` the transformer reads, so a reordering there fails here. - private static let expectedGlyphOrder: [(flag: NSEvent.ModifierFlags, glyph: String)] = [ - (.control, KeyboardGlyphCatalog.control), - (.option, KeyboardGlyphCatalog.option), - (.shift, KeyboardGlyphCatalog.shift), - (.command, KeyboardGlyphCatalog.command) - ] - - /// Every combination of the four modifiers, as flags plus a readable name. - private static var allModifierCombinations: [(modifiers: NSEvent.ModifierFlags, name: String)] { - (0..<16).map { combination in - var modifiers: NSEvent.ModifierFlags = [] - var names: [String] = [] - for (index, entry) in Self.expectedGlyphOrder.enumerated() where combination & (1 << index) != 0 { - modifiers.insert(entry.flag) - names.append(entry.glyph) - } - return (modifiers, names.isEmpty ? "no modifiers" : names.joined()) - } - } - - private func expectedPrefix(for modifiers: NSEvent.ModifierFlags) -> String { - Self.expectedGlyphOrder - .filter { modifiers.contains($0.flag) } - .map(\.glyph) - .joined() - } - - func testModifierGlyphsPrecedeTheLegendInCanonicalOrder() { - for (modifiers, name) in Self.allModifierCombinations { - let keystroke = StandardKeyEvent.stub(keyCode: .digit7, modifiers: modifiers) - - XCTAssertEqual( - self.transform(keystroke), - self.expectedPrefix(for: modifiers) + "7", - "for \(name)" - ) - } - } - - /// A digit has no distinct uppercase form, so only letters show the casing rule. - func testLetterLegendIsUppercasedOnlyWhenModified() { - for (modifiers, name) in Self.allModifierCombinations { - let keystroke = StandardKeyEvent.stub(keyCode: .a, modifiers: modifiers) - let expectedLegend = modifiers.isEmpty ? "a" : "A" - - XCTAssertEqual( - self.transform(keystroke), - self.expectedPrefix(for: modifiers) + expectedLegend, - "for \(name)" - ) - } - } - - /// Option produces a dead key or an alternate character, but the legend stays - /// the one printed on the key. - func testOptionLegendIgnoresTheAlternateCharacter() { - let cases: [(KeyboardKeyCode, String)] = [(.u, "U"), (.e, "E"), (.grave, "`")] - - for (keyCode, expectedLegend) in cases { - let keystroke = StandardKeyEvent.stub(keyCode: keyCode, modifiers: .recorded(.option)) - - XCTAssertEqual( - self.transform(keystroke), - KeyboardGlyphCatalog.option + expectedLegend, - "for \(keyCode)" - ) - } - } -} - -// MARK: - Legend Casing - -extension EventTransformerStandardKeyEventTests { - /// German ß uppercases to "SS", which would misreport the key's legend. - func testLegendThatExpandsWhenUppercasedIsLeftAlone() throws { - let german = EventTransformer(keyboardLayout: try TISInputSource.german()) - let keystroke = StandardKeyEvent.stub(keyCode: .minus, modifiers: .recorded(.command)) - - XCTAssertEqual(german.transform(.keystroke(keystroke)), KeyboardGlyphCatalog.command + "ß") - } -} - -// MARK: - Keys Named by the Layout - -extension EventTransformerStandardKeyEventTests { - func testEditingKeysUseDistinctSymbols() { - let cases: [(KeyboardKeyCode, String)] = [ - (.tab, KeyboardGlyphCatalog.tab), - (.returnKey, UnicodeToken.returnKey.string), - (.keypadEnter, UnicodeToken.keypadEnter.string), - (.delete, UnicodeToken.delete.string), - (.forwardDelete, UnicodeToken.forwardDelete.string) - ] - - for (keyCode, expected) in cases { - let keystroke = StandardKeyEvent.stub(keyCode: keyCode) - XCTAssertEqual(self.transform(keystroke), expected, "for \(keyCode)") - } - } - - func testArrowKeysUseFilledTriangleSymbols() { - let cases: [(KeyboardKeyCode, String)] = [ - (.leftArrow, UnicodeToken.leftArrow.string), - (.upArrow, UnicodeToken.upArrow.string), - (.downArrow, UnicodeToken.downArrow.string), - (.rightArrow, UnicodeToken.rightArrow.string) - ] - - for (keyCode, expected) in cases { - let keystroke = StandardKeyEvent.stub(keyCode: keyCode) - XCTAssertEqual(self.transform(keystroke), expected, "for \(keyCode)") - } - } - - func testModifiersPrefixASpecialKeySymbol() { - let character = String.functionKey(NSUpArrowFunctionKey) - let keystroke = StandardKeyEvent.stub( - keyCode: .upArrow, - modifiers: .recorded([.function, .option, .shift, .numericPad]), - characters: character, - charactersIgnoringModifiers: character - ) - - XCTAssertEqual( - self.transform(keystroke), - KeyboardGlyphCatalog.option + KeyboardGlyphCatalog.shift + UnicodeToken.upArrow.string - ) - } - - func testSystemKeysUseTheirOwnNames() { - let cases: [(KeyboardKeyCode, String)] = [ - (.brightnessDown, "dimmer"), - (.brightnessUp, "brighter"), - (.missionControl, "mission"), - (.launchpad, "launchpad") - ] - - for (keyCode, expected) in cases { - let keystroke = StandardKeyEvent.stub(keyCode: keyCode, modifiers: .recorded(.function)) - XCTAssertEqual(self.transform(keystroke), expected, "for \(keyCode)") - } - } - - func testJapaneseInputKeysUseTheirOwnLabels() { - let cases: [(KeyboardKeyCode, KeyboardSpecialKey)] = [(.eisu, .eisu), (.kana, .kana)] - - for (keyCode, specialKey) in cases { - let keystroke = StandardKeyEvent.stub(keyCode: keyCode) - XCTAssertEqual(self.transform(keystroke), specialKey.displayText, "for \(keyCode)") - } - } -} - -// MARK: - Keys Named by the Event - -extension EventTransformerStandardKeyEventTests { - /// macOS reuses the Help key code for Insert on many external keyboards, so - /// these three keys are told apart by the event's characters, not the key code. - func testHelpKeyCodeResolvesFromTheEventCharacters() { - let insertCharacter = String.functionKey(NSInsertFunctionKey) - let insert = StandardKeyEvent.stub( - keyCode: .help, - characters: insertCharacter, - charactersIgnoringModifiers: insertCharacter - ) - XCTAssertEqual(self.transform(insert), KeyboardSpecialKey.insert.displayText) - - let helpCharacter = String.functionKey(NSHelpFunctionKey) - let help = StandardKeyEvent.stub( - keyCode: .help, - characters: helpCharacter, - charactersIgnoringModifiers: helpCharacter - ) - XCTAssertEqual(self.transform(help), "help") - - let withoutCharacters = StandardKeyEvent.stub(keyCode: .help) - XCTAssertEqual(self.transform(withoutCharacters), KeyboardSpecialKey.insert.displayText) - } -}