From 435161d1191e59e879c966148fad28f3a9eb93c0 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 22 Aug 2026 21:26:59 +0200 Subject: [PATCH 1/5] fix(keyboard): stack page up and page down labels --- .../Keyboard/KeycapItemFactory.swift | 12 ++++++++ .../Keyboard/Models/KeycapItem.swift | 1 + .../Keyboard/Models/KeycapLegend.swift | 5 +++- .../Renderers/KeycapLegendRenderer.swift | 29 +++++++++++++++++++ .../Keyboard/Renderers/KeycapMetrics.swift | 4 +++ .../Keycaps/KeycapItemFactoryTests.swift | 26 +++++++++++++++++ 6 files changed, 76 insertions(+), 1 deletion(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift index 57de7346..e5f007f4 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift @@ -199,6 +199,14 @@ enum KeycapItemFactory { return .function case .capsLock: return .capsLock + case .home: + return Self.navigationKeyLegend("home") + case .end: + return Self.navigationKeyLegend("end") + case .pageUp: + return Self.navigationKeyLegend("page\nup") + case .pageDown: + return Self.navigationKeyLegend("page\ndown") default: return KeycapLegend(legend, mouseIconHeight: Self.mouseIconHeight) } @@ -266,6 +274,10 @@ enum KeycapItemFactory { ) } + private static func navigationKeyLegend(_ label: String) -> KeycapLegend { + KeycapLegend(label: label, rendersCenteredLabel: true) + } + } private extension KeyboardModifierKey { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapItem.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapItem.swift index 814a0b5b..f3e2f06c 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapItem.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapItem.swift @@ -41,6 +41,7 @@ struct KeycapItem { var sfSymbolName: String? { self.legend.sfSymbolName } var label: String? { self.legend.label } var rendersSymbolWithLabel: Bool { self.legend.rendersSymbolWithLabel } + var rendersCenteredLabel: Bool { self.legend.rendersCenteredLabel } var isPressed: Bool { self.state.isPressed } var fixedWidth: CGFloat? { self.layoutHints.fixedWidth } } 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 a6cbb89b..fce18ef8 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift @@ -18,6 +18,7 @@ struct KeycapLegend { let sfSymbolName: String? let label: String? let rendersSymbolWithLabel: Bool + let rendersCenteredLabel: Bool init( symbol: String = "", @@ -25,7 +26,8 @@ struct KeycapLegend { imageBadgeText: String? = nil, sfSymbolName: String? = nil, label: String? = nil, - rendersSymbolWithLabel: Bool = false + rendersSymbolWithLabel: Bool = false, + rendersCenteredLabel: Bool = false ) { self.symbol = symbol self.image = image @@ -33,6 +35,7 @@ struct KeycapLegend { self.sfSymbolName = sfSymbolName self.label = label self.rendersSymbolWithLabel = rendersSymbolWithLabel + self.rendersCenteredLabel = rendersCenteredLabel } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapLegendRenderer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapLegendRenderer.swift index 27c02e8f..699c9458 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapLegendRenderer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapLegendRenderer.swift @@ -14,6 +14,35 @@ struct KeycapLegendRenderer { centerPara.alignment = .center if let label = item.label { + if item.rendersCenteredLabel { + let attrs: [NSAttributedString.Key: Any] = [ + .font: CommonKeycapMetrics.labelFont, + .foregroundColor: textColor, + .paragraphStyle: centerPara, + ] + let bounds = NSRect( + x: keycapRect.minX + CommonKeycapMetrics.horizontalPadding, + y: keycapRect.minY, + width: keycapRect.width - 2 * CommonKeycapMetrics.horizontalPadding, + height: keycapRect.height + ) + let size = label.boundingRect( + with: bounds.size, + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: attrs + ).integral.size + label.draw( + in: NSRect( + x: bounds.minX, + y: keycapRect.midY - size.height / 2, + width: bounds.width, + height: size.height + ), + withAttributes: attrs + ) + return + } + let leftPara = NSMutableParagraphStyle() leftPara.alignment = .left diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapMetrics.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapMetrics.swift index a3c29a04..dbc0543c 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapMetrics.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapMetrics.swift @@ -27,6 +27,10 @@ enum CommonKeycapMetrics { .keyCode(KeyboardKeyCode.escape.rawValue): 112, .keyCode(KeyboardKeyCode.delete.rawValue): 112, .keyCode(KeyboardKeyCode.forwardDelete.rawValue): 112, + .keyCode(KeyboardKeyCode.home.rawValue): AppleKeycapMetrics.minWidth, + .keyCode(KeyboardKeyCode.end.rawValue): AppleKeycapMetrics.minWidth, + .keyCode(KeyboardKeyCode.pageUp.rawValue): AppleKeycapMetrics.minWidth, + .keyCode(KeyboardKeyCode.pageDown.rawValue): AppleKeycapMetrics.minWidth, .keyCode(KeyboardKeyCode.returnKey.rawValue): 128, .keyCode(KeyboardKeyCode.keypadEnter.rawValue): 128, .keyCode(KeyboardKeyCode.capsLock.rawValue): 144, 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 bf39a36f..d35c725d 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift @@ -145,6 +145,32 @@ final class KeycapItemFactoryTests: XCTestCase { XCTAssertEqual(forwardDeleteItems.first?.label, "delete") } + func testNavigationKeysUseCenteredTextLegendsAtStandardWidth() { + let palette = Self.makePalette() + let expected: [(KeyboardKeyCode, String)] = [ + (.home, "home"), + (.end, "end"), + (.pageUp, "page\nup"), + (.pageDown, "page\ndown"), + ] + + for (keyCode, label) in expected { + let items = KeycapItemFactory.keycapItems( + keyCode: keyCode.rawValue, + legend: EventLegend(text: KeyboardSpecialKeyResolver.specialKey(for: keyCode.rawValue)?.displayText ?? ""), + modifierFlags: [], + isPressed: true, + palette: palette + ) + + XCTAssertEqual(items.map(\.identity), [.keyCode(keyCode.rawValue)]) + XCTAssertEqual(items.first?.symbol, "") + XCTAssertEqual(items.first?.label, label) + XCTAssertEqual(items.first?.rendersCenteredLabel, true) + XCTAssertEqual(items.first.map(keycapWidth(for:)), AppleKeycapMetrics.minWidth) + } + } + func testMouseItemUsesPressedStateFromMouseEventType() { let palette = Self.makePalette() From bbbd12af60c44fcc7b37364f0367e28659b22c96 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 23 Aug 2026 02:39:11 +0200 Subject: [PATCH 2/5] fix(keyboard): wrap apple page navigation labels --- .../Keyboard/KeycapItemFactory.swift | 4 ++-- .../Renderers/Apple/AppleKeycapMetrics.swift | 1 + .../Renderers/Apple/AppleKeycapRenderer.swift | 16 ++++++++++++- .../Renderers/KeycapLegendRenderer.swift | 15 +++++++++--- .../Keycaps/KeycapItemFactoryTests.swift | 23 +++++++++++++++++-- 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift index e5f007f4..615897e8 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift @@ -204,9 +204,9 @@ enum KeycapItemFactory { case .end: return Self.navigationKeyLegend("end") case .pageUp: - return Self.navigationKeyLegend("page\nup") + return Self.navigationKeyLegend("page up") case .pageDown: - return Self.navigationKeyLegend("page\ndown") + return Self.navigationKeyLegend("page down") default: return KeycapLegend(legend, mouseIconHeight: Self.mouseIconHeight) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Apple/AppleKeycapMetrics.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Apple/AppleKeycapMetrics.swift index 951c78cc..f98e754e 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Apple/AppleKeycapMetrics.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Apple/AppleKeycapMetrics.swift @@ -11,6 +11,7 @@ import AppKit enum AppleKeycapMetrics { static let height: CGFloat = 74 static let minWidth: CGFloat = 74 + static let pageNavigationLabelWidth: CGFloat = 42 static let itemSpacing: CGFloat = 6 static let groupPadding = NSEdgeInsets(top: 10, left: 10, bottom: 18, right: 10) static let repeatBadgeInset = CGPoint(x: 6, y: 6) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Apple/AppleKeycapRenderer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Apple/AppleKeycapRenderer.swift index fe29a1c7..265e48e8 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Apple/AppleKeycapRenderer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Apple/AppleKeycapRenderer.swift @@ -44,6 +44,20 @@ struct AppleKeycapRenderer: KeycapRendering { path.lineWidth = StrokeWidth.standard path.stroke() - self.legendRenderer.draw(item: item, in: keycapRect, textColor: appearance.textColor) + self.legendRenderer.draw( + item: item, + in: keycapRect, + textColor: appearance.textColor, + centeredLabelWidth: self.centeredLabelWidth(for: item) + ) + } + + private func centeredLabelWidth(for item: KeycapItem) -> CGFloat? { + switch item.identity { + case .keyCode(KeyboardKeyCode.pageUp.rawValue), .keyCode(KeyboardKeyCode.pageDown.rawValue): + return AppleKeycapMetrics.pageNavigationLabelWidth + default: + return nil + } } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapLegendRenderer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapLegendRenderer.swift index 699c9458..d19a2d23 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapLegendRenderer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapLegendRenderer.swift @@ -9,7 +9,12 @@ import AppKit struct KeycapLegendRenderer { - func draw(item: KeycapItem, in keycapRect: NSRect, textColor: NSColor) { + func draw( + item: KeycapItem, + in keycapRect: NSRect, + textColor: NSColor, + centeredLabelWidth: CGFloat? = nil + ) { let centerPara = NSMutableParagraphStyle() centerPara.alignment = .center @@ -20,10 +25,14 @@ struct KeycapLegendRenderer { .foregroundColor: textColor, .paragraphStyle: centerPara, ] + let centeredWidth = min( + centeredLabelWidth ?? (keycapRect.width - 2 * CommonKeycapMetrics.horizontalPadding), + keycapRect.width - 2 * CommonKeycapMetrics.horizontalPadding + ) let bounds = NSRect( - x: keycapRect.minX + CommonKeycapMetrics.horizontalPadding, + x: keycapRect.midX - centeredWidth / 2, y: keycapRect.minY, - width: keycapRect.width - 2 * CommonKeycapMetrics.horizontalPadding, + width: centeredWidth, height: keycapRect.height ) let size = label.boundingRect( 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 d35c725d..f5e47f47 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift @@ -150,8 +150,8 @@ final class KeycapItemFactoryTests: XCTestCase { let expected: [(KeyboardKeyCode, String)] = [ (.home, "home"), (.end, "end"), - (.pageUp, "page\nup"), - (.pageDown, "page\ndown"), + (.pageUp, "page up"), + (.pageDown, "page down"), ] for (keyCode, label) in expected { @@ -171,6 +171,25 @@ final class KeycapItemFactoryTests: XCTestCase { } } + func testAppleRendererKeepsPageNavigationKeysAtStandardWidth() throws { + let palette = Self.makePalette() + let settings = KeyboardVisualizerSettings(store: InMemoryKeyValueStore()) + let renderer = AppleKeycapRenderer() + + for keyCode in [KeyboardKeyCode.pageUp, .pageDown] { + let items = KeycapItemFactory.keycapItems( + keyCode: keyCode.rawValue, + legend: EventLegend(text: KeyboardSpecialKeyResolver.specialKey(for: keyCode.rawValue)?.displayText ?? ""), + modifierFlags: [], + isPressed: true, + palette: palette + ) + + let context = KeycapContext(item: try XCTUnwrap(items.first), settings: settings) + XCTAssertEqual(renderer.size(for: context).width, AppleKeycapMetrics.minWidth) + } + } + func testMouseItemUsesPressedStateFromMouseEventType() { let palette = Self.makePalette() From d3c2bfb7406ff2a1fc9d54166a621fbb64bf5884 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 23 Aug 2026 03:08:58 +0200 Subject: [PATCH 3/5] refactor(keyboard): promote navigation legends to shared keycap presets --- .../Visualizers/Keyboard/KeycapItemFactory.swift | 12 ++++-------- .../Visualizers/Keyboard/Models/KeycapLegend.swift | 4 ++++ .../Keyboard/Renderers/CommonKeycapMetrics.swift | 2 -- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift index 615897e8..9f2b334f 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift @@ -200,13 +200,13 @@ enum KeycapItemFactory { case .capsLock: return .capsLock case .home: - return Self.navigationKeyLegend("home") + return .home case .end: - return Self.navigationKeyLegend("end") + return .end case .pageUp: - return Self.navigationKeyLegend("page up") + return .pageUp case .pageDown: - return Self.navigationKeyLegend("page down") + return .pageDown default: return KeycapLegend(legend, mouseIconHeight: Self.mouseIconHeight) } @@ -274,10 +274,6 @@ enum KeycapItemFactory { ) } - private static func navigationKeyLegend(_ label: String) -> KeycapLegend { - KeycapLegend(label: label, rendersCenteredLabel: true) - } - } 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 fce18ef8..31aad36a 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift @@ -77,5 +77,9 @@ extension KeycapLegend { static let `return` = KeycapLegend(symbol: KeyboardGlyphCatalog.symbol(for: .returnKey), label: KeyboardSpecialKey.returnKey.label) static let enter = KeycapLegend(symbol: KeyboardGlyphCatalog.symbol(for: .keypadEnter), label: KeyboardSpecialKey.keypadEnter.label) static let space = KeycapLegend(symbol: KeyboardGlyphCatalog.symbol(for: .space)) + static let home = KeycapLegend(label: "home", rendersCenteredLabel: true) + static let end = KeycapLegend(label: "end", rendersCenteredLabel: true) + static let pageUp = KeycapLegend(label: "page up", rendersCenteredLabel: true) + static let pageDown = KeycapLegend(label: "page down", rendersCenteredLabel: true) static let capsLock = KeycapLegend(label: KeyboardSpecialKey.capsLock.label) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/CommonKeycapMetrics.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/CommonKeycapMetrics.swift index bea6e2f3..e7e9c625 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/CommonKeycapMetrics.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/CommonKeycapMetrics.swift @@ -27,8 +27,6 @@ enum CommonKeycapMetrics { .keyCode(KeyboardKeyCode.escape.rawValue): 112, .keyCode(KeyboardKeyCode.delete.rawValue): 112, .keyCode(KeyboardKeyCode.forwardDelete.rawValue): 112, - .keyCode(KeyboardKeyCode.home.rawValue): AppleKeycapMetrics.minWidth, - .keyCode(KeyboardKeyCode.end.rawValue): AppleKeycapMetrics.minWidth, .keyCode(KeyboardKeyCode.pageUp.rawValue): AppleKeycapMetrics.minWidth, .keyCode(KeyboardKeyCode.pageDown.rawValue): AppleKeycapMetrics.minWidth, .keyCode(KeyboardKeyCode.returnKey.rawValue): 128, From 152f09d12bc395e11b1d33bbb87af8827f359d0b Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 23 Aug 2026 03:10:22 +0200 Subject: [PATCH 4/5] refactor(keyboard): move modifier legend alignment into domain model --- .../Keyty/Domain/Keyboard/KeyboardModifierKey.swift | 9 +++++++++ .../Visualizers/Keyboard/KeycapItemFactory.swift | 12 ------------ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift index 3e3cbe3a..b8931cba 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardModifierKey.swift @@ -34,6 +34,15 @@ extension KeyboardModifierKey { } } } + + var legendAlignment: KeycapLegendAlignment { + switch self.location { + case .left: + return .right + case .right: + return .left + } + } } // MARK: - Helpers diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift index 9f2b334f..70b6381c 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift @@ -273,16 +273,4 @@ enum KeycapItemFactory { appearance: palette.appearance(for: identity) ) } - -} - -private extension KeyboardModifierKey { - var legendAlignment: KeycapLegendAlignment { - switch self.location { - case .left: - return .right - case .right: - return .left - } - } } From f9c8f374c9686ec79805316c68088750f6d5d36e Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 23 Aug 2026 03:21:21 +0200 Subject: [PATCH 5/5] refactor(keyboard): group keycap item factory files --- .../Keyboard/KeycapItemFactory.swift | 276 ------------------ .../KeycapItemFactory+Keyboard.swift | 83 ++++++ .../KeycapItems/KeycapItemFactory+Media.swift | 31 ++ .../KeycapItemFactory+Modifiers.swift | 92 ++++++ .../KeycapItems/KeycapItemFactory+Mouse.swift | 61 ++++ .../KeycapItemFactory+Preview.swift | 30 ++ .../KeycapItems/KeycapItemFactory.swift | 19 ++ .../KeycapItems/KeycapPreviewSample.swift | 24 ++ 8 files changed, 340 insertions(+), 276 deletions(-) delete mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift create mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Keyboard.swift create mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Media.swift create mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Modifiers.swift create mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Mouse.swift create mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Preview.swift create mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory.swift create mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapPreviewSample.swift diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift deleted file mode 100644 index 70b6381c..00000000 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItemFactory.swift +++ /dev/null @@ -1,276 +0,0 @@ -// -// KeycapItemFactory.swift -// Keyty -// -// SPDX-FileCopyrightText: 2026 Serhii Bykov -// SPDX-License-Identifier: BSD-3-Clause -// - -import AppKit - -enum KeycapPreviewSample { - case key( - keyCode: UInt16, - legend: EventLegend, - modifierFlags: NSEvent.ModifierFlags = [], - isPressed: Bool = false - ) - case mouse(MouseEvent.Kind, isPressed: Bool = false) - case media(MediaKeyEvent.Kind, isPressed: Bool = false) - case modifiers( - current: NSEvent.ModifierFlags = [], - released: NSEvent.ModifierFlags = [] - ) -} - -/// Centralizes conversion into `KeycapItem` so runtime input events -/// and settings previews are built from the same rules. -/// -/// Each item resolves its own appearance from the `KeycapThemePalette` by its -/// `KeycapIdentity`, so per-key-type theming applies uniformly across every branch. -enum KeycapItemFactory { - private static let mouseIconHeight: CGFloat = 44 - private static let orderedModifierLocations: [KeyboardModifierKey.Location] = [.left, .right] - - static func keycapItems( - for keystroke: StandardKeyEvent, - legend: EventLegend, - palette: KeycapThemePalette - ) -> [KeycapItem] { - Self.keycapItems( - keyCode: keystroke.keyCode, - legend: legend, - modifierFlags: keystroke.modifierFlags, - isPressed: keystroke.type != .keyUp, - palette: palette - ) - } - - static func keycapItems( - keyCode: UInt16, - legend: EventLegend, - modifierFlags: NSEvent.ModifierFlags, - isPressed: Bool, - palette: KeycapThemePalette - ) -> [KeycapItem] { - var result = Self.modifierItems( - currentFlags: modifierFlags.subtracting(.function), - releasedFlags: [], - palette: palette - ) - guard !legend.text.isEmpty || legend.kind != .text else { return result } - - let identity = KeycapIdentity.keyCode(keyCode) - result.append(KeycapItem( - identity: identity, - legend: Self.keycapLegend(for: keyCode, legend: legend), - state: KeycapState(isPressed: isPressed), - layoutHints: Self.layoutHints(forKeyCode: keyCode), - appearance: palette.appearance(for: identity) - )) - return result - } - - static func mouseItem(for mouseEvent: MouseEvent, palette: KeycapThemePalette) -> KeycapItem { - Self.mouseItem( - for: mouseEvent.kind, - isPressed: Self.isPressedMouseEvent(mouseEvent), - palette: palette - ) - } - - static func mouseItem( - for kind: MouseEvent.Kind, - isPressed: Bool, - palette: KeycapThemePalette - ) -> KeycapItem { - let identity = KeycapIdentity.mouse(kind) - let appearance = palette.appearance(for: identity) - if let icon = MouseEventDisplayRenderer.templateIconImage( - for: kind, - height: mouseIconHeight - ) { - return KeycapItem( - identity: identity, - legend: KeycapLegend( - image: icon, - imageBadgeText: kind.otherButtonNumber.map(String.init) - ), - state: KeycapState(isPressed: isPressed), - appearance: appearance - ) - } - - return KeycapItem( - identity: identity, - legend: KeycapLegend(symbol: InputEventGlyphMapper.mouseDisplayText(for: kind)), - state: KeycapState(isPressed: isPressed), - layoutHints: KeycapLayoutHints(fixedWidth: 112), - appearance: appearance - ) - } - - static func mediaKeyItem(for mediaKey: MediaKeyEvent, palette: KeycapThemePalette) -> KeycapItem { - Self.mediaKeyItem(for: mediaKey.kind, isPressed: mediaKey.isPressed, palette: palette) - } - - static func mediaKeyItem( - for kind: MediaKeyEvent.Kind, - isPressed: Bool, - palette: KeycapThemePalette - ) -> KeycapItem { - let identity = KeycapIdentity.media(kind) - return KeycapItem( - identity: identity, - legend: KeycapLegend( - sfSymbolName: InputEventSymbolMapper.mediaKeySymbolName(for: kind) - ), - state: KeycapState(isPressed: isPressed), - appearance: palette.appearance(for: identity) - ) - } - - static func modifierItems( - currentFlags: NSEvent.ModifierFlags, - releasedFlags: NSEvent.ModifierFlags, - palette: KeycapThemePalette - ) -> [KeycapItem] { - var items: [KeycapItem] = [] - let currentModifierKeys = KeyboardModifierKey.keys(in: currentFlags) - let releasedModifierKeys = KeyboardModifierKey.keys(in: releasedFlags) - - for modifier in KeyboardModifierKey.Kind.canonicalDisplayOrder { - let modifierKeys = Self.orderedModifierKeys( - for: modifier, - currentModifierKeys: currentModifierKeys, - releasedModifierKeys: releasedModifierKeys - ) - if !modifierKeys.isEmpty { - for modifierKey in modifierKeys { - items.append(Self.modifierItem( - modifierKey, - isPressed: currentModifierKeys.contains(modifierKey), - palette: palette - )) - } - continue - } - - if currentFlags.contains(modifier.flag) || releasedFlags.contains(modifier.flag) { - let modifierKey = KeyboardModifierKey(modifier, location: .left) - items.append(Self.modifierItem( - modifierKey, - isPressed: currentFlags.contains(modifier.flag), - palette: palette - )) - } - } - - if currentFlags.contains(.function) || releasedFlags.contains(.function) { - items.append(Self.functionItem(isPressed: currentFlags.contains(.function), palette: palette)) - } - return items - } - - static func items(for sample: KeycapPreviewSample, palette: KeycapThemePalette) -> [KeycapItem] { - switch sample { - case let .key(keyCode, legend, modifierFlags, isPressed): - return Self.keycapItems( - keyCode: keyCode, - legend: legend, - modifierFlags: modifierFlags, - isPressed: isPressed, - palette: palette - ) - case let .mouse(kind, isPressed): - return [Self.mouseItem(for: kind, isPressed: isPressed, palette: palette)] - case let .media(kind, isPressed): - return [Self.mediaKeyItem(for: kind, isPressed: isPressed, palette: palette)] - case let .modifiers(current, released): - return Self.modifierItems(currentFlags: current, releasedFlags: released, palette: palette) - } - } - - // 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 .function - case .capsLock: - return .capsLock - case .home: - return .home - case .end: - return .end - case .pageUp: - return .pageUp - case .pageDown: - return .pageDown - 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 KeycapLayoutHints(fixedWidth: 256) - default: - return KeycapLayoutHints() - } - } - - private static func isPressedMouseEvent(_ mouseEvent: MouseEvent) -> Bool { - switch mouseEvent.type { - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - return true - case .leftMouseUp, .rightMouseUp, .otherMouseUp: - return false - default: - return false - } - } - - private static func modifierItem( - _ modifierKey: KeyboardModifierKey, - isPressed: Bool, - palette: KeycapThemePalette - ) -> KeycapItem { - let identity = KeycapIdentity.modifier(modifierKey) - return KeycapItem( - identity: identity, - legend: KeycapLegend(symbol: modifierKey.kind.glyph, label: modifierKey.kind.label), - state: KeycapState(isPressed: isPressed), - layoutHints: KeycapLayoutHints(alignment: modifierKey.legendAlignment), - appearance: palette.appearance(for: identity) - ) - } - - private static func orderedModifierKeys( - for modifier: KeyboardModifierKey.Kind, - currentModifierKeys: Set, - releasedModifierKeys: Set - ) -> [KeyboardModifierKey] { - let keys = currentModifierKeys.union(releasedModifierKeys) - return Self.orderedModifierLocations - .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/KeycapItems/KeycapItemFactory+Keyboard.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Keyboard.swift new file mode 100644 index 00000000..e74c748c --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Keyboard.swift @@ -0,0 +1,83 @@ +// +// KeycapItemFactory+Keyboard.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +extension KeycapItemFactory { + static func keycapItems( + for keystroke: StandardKeyEvent, + legend: EventLegend, + palette: KeycapThemePalette + ) -> [KeycapItem] { + Self.keycapItems( + keyCode: keystroke.keyCode, + legend: legend, + modifierFlags: keystroke.modifierFlags, + isPressed: keystroke.type != .keyUp, + palette: palette + ) + } + + static func keycapItems( + keyCode: UInt16, + legend: EventLegend, + modifierFlags: NSEvent.ModifierFlags, + isPressed: Bool, + palette: KeycapThemePalette + ) -> [KeycapItem] { + var result = Self.modifierItems( + currentFlags: modifierFlags.subtracting(.function), + releasedFlags: [], + palette: palette + ) + guard !legend.text.isEmpty || legend.kind != .text else { return result } + + let identity = KeycapIdentity.keyCode(keyCode) + result.append(KeycapItem( + identity: identity, + legend: Self.keycapLegend(for: keyCode, legend: legend), + state: KeycapState(isPressed: isPressed), + layoutHints: Self.layoutHints(forKeyCode: keyCode), + appearance: palette.appearance(for: identity) + )) + return result + } + + // 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 .function + case .capsLock: + return .capsLock + case .home: + return .home + case .end: + return .end + case .pageUp: + return .pageUp + case .pageDown: + return .pageDown + 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 KeycapLayoutHints(fixedWidth: 256) + default: + return KeycapLayoutHints() + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Media.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Media.swift new file mode 100644 index 00000000..7984950f --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Media.swift @@ -0,0 +1,31 @@ +// +// KeycapItemFactory+Media.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +extension KeycapItemFactory { + static func mediaKeyItem(for mediaKey: MediaKeyEvent, palette: KeycapThemePalette) -> KeycapItem { + Self.mediaKeyItem(for: mediaKey.kind, isPressed: mediaKey.isPressed, palette: palette) + } + + static func mediaKeyItem( + for kind: MediaKeyEvent.Kind, + isPressed: Bool, + palette: KeycapThemePalette + ) -> KeycapItem { + let identity = KeycapIdentity.media(kind) + return KeycapItem( + identity: identity, + legend: KeycapLegend( + sfSymbolName: InputEventSymbolMapper.mediaKeySymbolName(for: kind) + ), + state: KeycapState(isPressed: isPressed), + appearance: palette.appearance(for: identity) + ) + } +} 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 new file mode 100644 index 00000000..6314bf01 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Modifiers.swift @@ -0,0 +1,92 @@ +// +// KeycapItemFactory+Modifiers.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +extension KeycapItemFactory { + static func modifierItems( + currentFlags: NSEvent.ModifierFlags, + releasedFlags: NSEvent.ModifierFlags, + palette: KeycapThemePalette + ) -> [KeycapItem] { + var items: [KeycapItem] = [] + let currentModifierKeys = KeyboardModifierKey.keys(in: currentFlags) + let releasedModifierKeys = KeyboardModifierKey.keys(in: releasedFlags) + + for modifier in KeyboardModifierKey.Kind.canonicalDisplayOrder { + let modifierKeys = Self.orderedModifierKeys( + for: modifier, + currentModifierKeys: currentModifierKeys, + releasedModifierKeys: releasedModifierKeys + ) + if !modifierKeys.isEmpty { + for modifierKey in modifierKeys { + items.append(Self.modifierItem( + modifierKey, + isPressed: currentModifierKeys.contains(modifierKey), + palette: palette + )) + } + continue + } + + if currentFlags.contains(modifier.flag) || releasedFlags.contains(modifier.flag) { + let modifierKey = KeyboardModifierKey(modifier, location: .left) + items.append(Self.modifierItem( + modifierKey, + isPressed: currentFlags.contains(modifier.flag), + palette: palette + )) + } + } + + if currentFlags.contains(.function) || releasedFlags.contains(.function) { + items.append(Self.functionItem(isPressed: currentFlags.contains(.function), palette: palette)) + } + return items + } + + private static func modifierItem( + _ modifierKey: KeyboardModifierKey, + isPressed: Bool, + palette: KeycapThemePalette + ) -> KeycapItem { + let identity = KeycapIdentity.modifier(modifierKey) + return KeycapItem( + identity: identity, + legend: KeycapLegend(symbol: modifierKey.kind.glyph, label: modifierKey.kind.label), + state: KeycapState(isPressed: isPressed), + layoutHints: KeycapLayoutHints(alignment: modifierKey.legendAlignment), + appearance: palette.appearance(for: identity) + ) + } + + private static func orderedModifierKeys( + for modifier: KeyboardModifierKey.Kind, + currentModifierKeys: Set, + releasedModifierKeys: Set + ) -> [KeyboardModifierKey] { + let keys = currentModifierKeys.union(releasedModifierKeys) + return Self.orderedModifierLocations + .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/KeycapItems/KeycapItemFactory+Mouse.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Mouse.swift new file mode 100644 index 00000000..a8114669 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Mouse.swift @@ -0,0 +1,61 @@ +// +// KeycapItemFactory+Mouse.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +extension KeycapItemFactory { + static func mouseItem(for mouseEvent: MouseEvent, palette: KeycapThemePalette) -> KeycapItem { + Self.mouseItem( + for: mouseEvent.kind, + isPressed: Self.isPressedMouseEvent(mouseEvent), + palette: palette + ) + } + + static func mouseItem( + for kind: MouseEvent.Kind, + isPressed: Bool, + palette: KeycapThemePalette + ) -> KeycapItem { + let identity = KeycapIdentity.mouse(kind) + let appearance = palette.appearance(for: identity) + if let icon = MouseEventDisplayRenderer.templateIconImage( + for: kind, + height: Self.mouseIconHeight + ) { + return KeycapItem( + identity: identity, + legend: KeycapLegend( + image: icon, + imageBadgeText: kind.otherButtonNumber.map(String.init) + ), + state: KeycapState(isPressed: isPressed), + appearance: appearance + ) + } + + return KeycapItem( + identity: identity, + legend: KeycapLegend(symbol: InputEventGlyphMapper.mouseDisplayText(for: kind)), + state: KeycapState(isPressed: isPressed), + layoutHints: KeycapLayoutHints(fixedWidth: 112), + appearance: appearance + ) + } + + private static func isPressedMouseEvent(_ mouseEvent: MouseEvent) -> Bool { + switch mouseEvent.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + case .leftMouseUp, .rightMouseUp, .otherMouseUp: + return false + default: + return false + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Preview.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Preview.swift new file mode 100644 index 00000000..190b2504 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Preview.swift @@ -0,0 +1,30 @@ +// +// KeycapItemFactory+Preview.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +extension KeycapItemFactory { + static func items(for sample: KeycapPreviewSample, palette: KeycapThemePalette) -> [KeycapItem] { + switch sample { + case let .key(keyCode, legend, modifierFlags, isPressed): + return Self.keycapItems( + keyCode: keyCode, + legend: legend, + modifierFlags: modifierFlags, + isPressed: isPressed, + palette: palette + ) + case let .mouse(kind, isPressed): + return [Self.mouseItem(for: kind, isPressed: isPressed, palette: palette)] + case let .media(kind, isPressed): + return [Self.mediaKeyItem(for: kind, isPressed: isPressed, palette: palette)] + case let .modifiers(current, released): + return Self.modifierItems(currentFlags: current, releasedFlags: released, palette: palette) + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory.swift new file mode 100644 index 00000000..c4e30ec0 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory.swift @@ -0,0 +1,19 @@ +// +// KeycapItemFactory.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +/// Centralizes conversion into `KeycapItem` so runtime input events +/// and settings previews are built from the same rules. +/// +/// Each item resolves its own appearance from the `KeycapThemePalette` by its +/// `KeycapIdentity`, so per-key-type theming applies uniformly across every branch. +enum KeycapItemFactory { + static let mouseIconHeight: CGFloat = 44 + static let orderedModifierLocations: [KeyboardModifierKey.Location] = [.left, .right] +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapPreviewSample.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapPreviewSample.swift new file mode 100644 index 00000000..e387e429 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapPreviewSample.swift @@ -0,0 +1,24 @@ +// +// KeycapPreviewSample.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +enum KeycapPreviewSample { + case key( + keyCode: UInt16, + legend: EventLegend, + modifierFlags: NSEvent.ModifierFlags = [], + isPressed: Bool = false + ) + case mouse(MouseEvent.Kind, isPressed: Bool = false) + case media(MediaKeyEvent.Kind, isPressed: Bool = false) + case modifiers( + current: NSEvent.ModifierFlags = [], + released: NSEvent.ModifierFlags = [] + ) +}