diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift index 584a996f..5167e3dd 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift @@ -262,7 +262,7 @@ struct KeyboardSettingsPane: View { subtitle: L10n.KeyboardVisualizer.themeSubtitle ) { Picker("", selection: self.$model.themeSelection) { - ForEach(Array(KeyboardVisualizerTheme.pickerSections.enumerated()), id: \.offset) { index, section in + ForEach(Array(self.model.allowedThemeSections.enumerated()), id: \.offset) { index, section in if index > 0 { Divider() } @@ -341,7 +341,7 @@ struct KeyboardSettingsPane: View { ) -> some View { SettingsControlRow(title: title, subtitle: subtitle) { Picker("", selection: selection) { - ForEach(Array(KeyboardVisualizerTheme.pickerSections.enumerated()), id: \.offset) { index, section in + ForEach(Array(self.model.allowedThemeSections.enumerated()), id: \.offset) { index, section in if index > 0 { Divider() } @@ -381,7 +381,7 @@ struct KeyboardSettingsPane: View { } .labelsHidden() .accessibilityLabel(L10n.KeyboardVisualizer.styleLabel) - .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) + .frame(width: Size.Control.keyboardStylePickerWidth, alignment: .trailing) } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift index f4a2681d..1630dcf0 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift @@ -46,7 +46,15 @@ final class KeyboardSettingsPaneViewModel: ObservableObject { } @Published var theme: KeyboardVisualizerTheme { - didSet { self.settings.theme = self.theme } + didSet { + guard self.theme != oldValue else { return } + let sanitized = self.style.sanitize(theme: self.theme) + guard sanitized == self.theme else { + self.theme = sanitized + return + } + self.settings.theme = sanitized + } } @Published var legendColorMode: KeyboardLegendColorMode { @@ -77,27 +85,71 @@ final class KeyboardSettingsPaneViewModel: ObservableObject { } @Published var modifierTheme: KeyboardVisualizerTheme { - didSet { self.settings.modifierTheme = self.modifierTheme } + didSet { + guard self.modifierTheme != oldValue else { return } + let sanitized = self.style.sanitize(theme: self.modifierTheme) + guard sanitized == self.modifierTheme else { + self.modifierTheme = sanitized + return + } + self.settings.modifierTheme = sanitized + } } @Published var specialTheme: KeyboardVisualizerTheme { - didSet { self.settings.specialTheme = self.specialTheme } + didSet { + guard self.specialTheme != oldValue else { return } + let sanitized = self.style.sanitize(theme: self.specialTheme) + guard sanitized == self.specialTheme else { + self.specialTheme = sanitized + return + } + self.settings.specialTheme = sanitized + } } @Published var mediaTheme: KeyboardVisualizerTheme { - didSet { self.settings.mediaTheme = self.mediaTheme } + didSet { + guard self.mediaTheme != oldValue else { return } + let sanitized = self.style.sanitize(theme: self.mediaTheme) + guard sanitized == self.mediaTheme else { + self.mediaTheme = sanitized + return + } + self.settings.mediaTheme = sanitized + } } @Published var mouseTheme: KeyboardVisualizerTheme { - didSet { self.settings.mouseTheme = self.mouseTheme } + didSet { + guard self.mouseTheme != oldValue else { return } + let sanitized = self.style.sanitize(theme: self.mouseTheme) + guard sanitized == self.mouseTheme else { + self.mouseTheme = sanitized + return + } + self.settings.mouseTheme = sanitized + } } @Published var groupBackgroundTheme: KeyboardVisualizerTheme { - didSet { self.settings.groupBackgroundTheme = self.groupBackgroundTheme } + didSet { + guard self.groupBackgroundTheme != oldValue else { return } + let sanitized = self.style.sanitize(theme: self.groupBackgroundTheme) + guard sanitized == self.groupBackgroundTheme else { + self.groupBackgroundTheme = sanitized + return + } + self.settings.groupBackgroundTheme = sanitized + } } @Published var style: KeycapStyle { - didSet { self.settings.style = self.style } + didSet { + guard self.style != oldValue else { return } + self.settings.style = self.style + self.syncThemeStateFromSettings() + } } @Published var scale: Double { @@ -175,6 +227,11 @@ final class KeyboardSettingsPaneViewModel: ObservableObject { ].joined(separator: "-") } + /// Theme picker sections filtered to the themes allowed by the selected keycap style. + var allowedThemeSections: [[KeyboardVisualizerTheme]] { + KeyboardVisualizerTheme.pickerSections(for: self.style.allowedThemes) + } + var previewRenderSettings: KeyboardVisualizerSettings { let settings = KeyboardVisualizerSettings(store: InMemoryKeyValueStore()) settings.registerDefaults() @@ -246,6 +303,15 @@ final class KeyboardSettingsPaneViewModel: ObservableObject { self.customLegendColor = color self.legendColorSelectionOverride = Self.customLegendColorSelectionID } + + private func syncThemeStateFromSettings() { + self.theme = self.settings.theme + self.modifierTheme = self.settings.modifierTheme + self.specialTheme = self.settings.specialTheme + self.mediaTheme = self.settings.mediaTheme + self.mouseTheme = self.settings.mouseTheme + self.groupBackgroundTheme = self.settings.groupBackgroundTheme + } } extension KeyboardSettingsPaneViewModel { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+Apple.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+Apple.swift new file mode 100644 index 00000000..3677fdbc --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+Apple.swift @@ -0,0 +1,34 @@ +// +// KeycapAppearance+Apple.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +extension KeycapAppearance { + struct Apple: KeycapAppearanceProviding { + let shared: Shared + let strokeColor: NSColor + let undersideGradient: NSGradient? + let mainGradient: NSGradient? + + init(tokens: KeycapThemeTokens) { + self.shared = Shared(tokens: tokens) + self.strokeColor = tokens.surfaceBorderColor + let undersideEdgeColor = tokens.undersideEdgeColor ?? tokens.recessColor + let undersideCenterColor = tokens.undersideCenterColor ?? tokens.surfaceBaseColor + self.undersideGradient = NSGradient(colorsAndLocations: + (undersideEdgeColor, 0.0), + (undersideCenterColor, 0.5), + (undersideEdgeColor, 1.0) + ) + self.mainGradient = NSGradient(colorsAndLocations: + (tokens.surfaceBaseColor, 0.0), + (tokens.surfaceShadowColor, 1.0) + ) + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+M0116.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+M0116.swift new file mode 100644 index 00000000..4f67c154 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+M0116.swift @@ -0,0 +1,64 @@ +// +// KeycapAppearance+M0116.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +extension KeycapAppearance { + /// Sculpted beige-era keycap: a lit body, an inset top face, and a front skirt, + /// all sitting in a near-black well like the plate of an Apple Standard Keyboard. + struct M0116: KeycapAppearanceProviding { + let shared: Shared + let wellColor: NSColor + let bodyGradient: NSGradient? + let skirtGradient: NSGradient? + let faceGradient: NSGradient? + let creaseColor: NSColor + let bodyStrokeColor: NSColor + + init(tokens: KeycapThemeTokens) { + let bodyEdge = tokens.surfaceShadowColor.darkened(by: 0.28) + let bodyHighlight = tokens.surfaceHighlightColor.blended( + withFraction: 0.35, + of: tokens.surfaceBaseColor + ) ?? tokens.surfaceHighlightColor + let legendTextColor = tokens.textColor.darkened(by: 0.18) + + self.shared = Shared( + badgeFillColor: tokens.badgeFillColor, + badgeHighlightColor: tokens.badgeHighlightColor, + badgeStrokeColor: tokens.badgeStrokeColor, + badgeTextColor: tokens.badgeTextColor, + groupBackgroundColor: tokens.groupBackgroundColor, + groupStrokeColor: tokens.groupStrokeColor, + textColor: legendTextColor + ) + self.wellColor = tokens.recessColor.darkened(by: 0.45) + // Lit from the left: bright edge, base across the middle, shaded right edge. + self.bodyGradient = NSGradient(colorsAndLocations: + (tokens.surfaceHighlightColor, 0.0), + (bodyHighlight, 0.14), + (tokens.surfaceBaseColor, 0.55), + (tokens.surfaceShadowColor, 0.92), + (bodyEdge, 1.0) + ) + // The front skirt catches more light at its lower edge than the face above it. + self.skirtGradient = NSGradient(colorsAndLocations: + (bodyEdge, 0.0), + (tokens.surfaceBaseColor, 0.45), + (tokens.surfaceHighlightColor, 1.0) + ) + self.faceGradient = NSGradient(colorsAndLocations: + (tokens.surfaceHighlightColor, 0.0), + (tokens.surfaceBaseColor, 0.58), + (tokens.surfaceShadowColor, 1.0) + ) + self.creaseColor = tokens.surfaceBorderColor.withAlphaComponent(0.42) + self.bodyStrokeColor = tokens.surfaceBorderColor + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+Minimal.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+Minimal.swift new file mode 100644 index 00000000..470c94a9 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+Minimal.swift @@ -0,0 +1,19 @@ +// +// KeycapAppearance+Minimal.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +extension KeycapAppearance { + struct Minimal: KeycapAppearanceProviding { + let shared: Shared + + init(tokens: KeycapThemeTokens) { + self.shared = Shared(tokens: tokens) + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+PBT.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+PBT.swift new file mode 100644 index 00000000..1e5f9c98 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+PBT.swift @@ -0,0 +1,42 @@ +// +// KeycapAppearance+PBT.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +extension KeycapAppearance { + struct PBT: KeycapAppearanceProviding { + let shared: Shared + let bodyGradient: NSGradient? + let bodyStrokeColor: NSColor + let underDishColor: NSColor + let dishGradient: NSGradient? + + init(tokens: KeycapThemeTokens) { + let bodyTop = tokens.surfaceBaseColor.lightened(by: 0.08) + let bodyMid = tokens.surfaceShadowColor.lightened(by: 0.03) + let bodyBottom = tokens.surfaceShadowColor.darkened(by: 0.08) + let dishTop = tokens.surfaceBaseColor.lightened(by: 0.05) + let dishMid = tokens.surfaceBaseColor.lightened(by: 0.06) + let dishBottom = tokens.surfaceShadowColor.lightened(by: 0.05) + + self.shared = Shared(tokens: tokens) + self.bodyGradient = NSGradient(colorsAndLocations: + (bodyTop, 0.0), + (bodyMid, 0.48), + (bodyBottom, 1.0) + ) + self.bodyStrokeColor = tokens.surfaceBorderColor + self.underDishColor = tokens.surfaceBaseColor.lightened(by: 0.12) + self.dishGradient = NSGradient(colorsAndLocations: + (dishTop, 0.0), + (dishMid, 0.32), + (dishBottom, 1.0) + ) + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+Retro.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+Retro.swift new file mode 100644 index 00000000..d86942c9 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance+Retro.swift @@ -0,0 +1,81 @@ +// +// KeycapAppearance+Retro.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +extension KeycapAppearance { + struct Retro: KeycapAppearanceProviding { + let shared: Shared + let lipGradient: NSGradient? + let bodyShadowColor: NSColor + let bodyGradient: NSGradient? + let bodyStrokeColor: NSColor + let faceShadowGradient: NSGradient? + let darkFaceGradient: NSGradient? + let darkFaceStrokeColor: NSColor + let lightFaceGradient: NSGradient? + let lightFaceStrokeColor: NSColor + let glossColor: NSColor + + init(tokens: KeycapThemeTokens) { + let bodyEdge = tokens.surfaceShadowColor.darkened(by: 0.08) + let bodyCenter = tokens.surfaceBaseColor.darkened(by: 0.10) + let darkFaceTop = tokens.surfaceBaseColor.lightened(by: 0.03) + let darkFaceBottom = tokens.surfaceBaseColor.darkened(by: 0.01) + let lightFaceTop = tokens.surfaceHighlightColor.lightened(by: 0.08) + let lightFaceBottom = tokens.surfaceBaseColor + let groupBackgroundColor = tokens.recessColor.darkened(by: 0.40) + let groupStrokeColor = tokens.surfaceBaseColor.withAlphaComponent(0.70) + let lipEdge = tokens.recessColor.darkened(by: 0.34).withAlphaComponent(0.96) + let lipCenter = tokens.recessColor.darkened(by: 0.12).withAlphaComponent(0.86) + let bodyStrokeColor = tokens.surfaceBorderColor.withAlphaComponent(0.20) + let darkFaceStrokeColor = tokens.surfaceBorderColor.withAlphaComponent(0.20) + let faceShadowEdge = tokens.recessColor.darkened(by: 0.34).withAlphaComponent(0.46) + let faceShadowCenter = tokens.recessColor.darkened(by: 0.12).withAlphaComponent(0.26) + + self.shared = Shared( + badgeFillColor: tokens.badgeFillColor, + badgeHighlightColor: tokens.badgeHighlightColor, + badgeStrokeColor: tokens.badgeStrokeColor, + badgeTextColor: tokens.badgeTextColor, + groupBackgroundColor: groupBackgroundColor, + groupStrokeColor: groupStrokeColor, + textColor: tokens.textColor + ) + self.lipGradient = NSGradient(colorsAndLocations: + (lipEdge, 0.0), + (lipCenter, 0.5), + (lipEdge, 1.0) + ) + self.bodyShadowColor = NSColor.black.withAlphaComponent(0.6) + self.bodyGradient = NSGradient(colorsAndLocations: + (bodyEdge, 0.0), + (bodyCenter, 0.32), + (bodyCenter, 0.68), + (bodyEdge, 1.0) + ) + self.bodyStrokeColor = bodyStrokeColor + self.faceShadowGradient = NSGradient(colorsAndLocations: + (faceShadowEdge, 0.0), + (faceShadowCenter, 0.5), + (faceShadowEdge, 1.0) + ) + self.darkFaceGradient = NSGradient(colorsAndLocations: + (darkFaceTop, 0.0), + (darkFaceBottom, 1.0) + ) + self.darkFaceStrokeColor = darkFaceStrokeColor + self.lightFaceGradient = NSGradient(colorsAndLocations: + (lightFaceTop, 0.0), + (lightFaceBottom, 1.0) + ) + self.lightFaceStrokeColor = tokens.surfaceBorderColor.withAlphaComponent(0.55) + self.glossColor = NSColor(white: 1, alpha: 0.08) + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance.swift new file mode 100644 index 00000000..7e34fb7f --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapAppearance.swift @@ -0,0 +1,111 @@ +// +// KeycapAppearance.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +/// Exposes the shared visual attributes every keycap appearance variant provides. +protocol KeycapAppearanceProviding { + var shared: KeycapAppearance.Shared { get } +} + +/// Enumerates the concrete keycap appearance variants the keyboard visualizer can render. +enum KeycapAppearance { + case apple(Apple) + case pbt(PBT) + case minimal(Minimal) + case retro(Retro) + case m0116(M0116) + + static let dotColor = NSColor(calibratedRed: 0.0, green: 1.0, blue: 0.1, alpha: 1) + static let inactiveDotColor = NSColor(calibratedWhite: 0.42, alpha: 0.7) +} + +// MARK: - Shared Appearance +extension KeycapAppearance { + /// Holds the color values reused across all keycap appearance variants. + struct Shared { + let badgeFillColor: NSColor + let badgeHighlightColor: NSColor + let badgeStrokeColor: NSColor + let badgeTextColor: NSColor + let groupBackgroundColor: NSColor + let groupStrokeColor: NSColor + let textColor: NSColor + + init( + badgeFillColor: NSColor, + badgeHighlightColor: NSColor, + badgeStrokeColor: NSColor, + badgeTextColor: NSColor, + groupBackgroundColor: NSColor, + groupStrokeColor: NSColor, + textColor: NSColor + ) { + self.badgeFillColor = badgeFillColor + self.badgeHighlightColor = badgeHighlightColor + self.badgeStrokeColor = badgeStrokeColor + self.badgeTextColor = badgeTextColor + self.groupBackgroundColor = groupBackgroundColor + self.groupStrokeColor = groupStrokeColor + self.textColor = textColor + } + + init(tokens: KeycapThemeTokens) { + self.init( + badgeFillColor: tokens.badgeFillColor, + badgeHighlightColor: tokens.badgeHighlightColor, + badgeStrokeColor: tokens.badgeStrokeColor, + badgeTextColor: tokens.badgeTextColor, + groupBackgroundColor: tokens.groupBackgroundColor, + groupStrokeColor: tokens.groupStrokeColor, + textColor: tokens.textColor + ) + } + } +} + +// MARK: - Shared Accessors +extension KeycapAppearance { + var shared: Shared { + switch self { + case .apple(let appearance): appearance.shared + case .pbt(let appearance): appearance.shared + case .minimal(let appearance): appearance.shared + case .retro(let appearance): appearance.shared + case .m0116(let appearance): appearance.shared + } + } +} + +// MARK: - Case Accessors +extension KeycapAppearance { + var apple: Apple? { + guard case .apple(let appearance) = self else { return nil } + return appearance + } + + var pbt: PBT? { + guard case .pbt(let appearance) = self else { return nil } + return appearance + } + + var minimal: Minimal? { + guard case .minimal(let appearance) = self else { return nil } + return appearance + } + + var retro: Retro? { + guard case .retro(let appearance) = self else { return nil } + return appearance + } + + var m0116: M0116? { + guard case .m0116(let appearance) = self else { return nil } + return appearance + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapThemeTokens.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapThemeTokens.swift new file mode 100644 index 00000000..8fb1ef00 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Appearance/KeycapThemeTokens.swift @@ -0,0 +1,68 @@ +// +// KeycapThemeTokens.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +/// Carries the base colors used to construct a concrete keycap appearance. +struct KeycapThemeTokens { + let groupBackgroundColor: NSColor + let groupStrokeColor: NSColor + + let badgeFillColor: NSColor + let badgeHighlightColor: NSColor + let badgeStrokeColor: NSColor + let badgeTextColor: NSColor + + let surfaceBaseColor: NSColor + let surfaceBorderColor: NSColor + let surfaceHighlightColor: NSColor + let surfaceShadowColor: NSColor + + let recessColor: NSColor + /// Optional Apple-style underside center color override. Falls back to `surfaceBaseColor`. + let undersideCenterColor: NSColor? + /// Optional Apple-style underside edge color override. Falls back to `recessColor`. + let undersideEdgeColor: NSColor? + + let swatchColor: NSColor + let textColor: NSColor + + init( + swatchColor: NSColor, + textColor: NSColor, + groupBackgroundColor: NSColor, + groupStrokeColor: NSColor, + badgeFillColor: NSColor, + badgeStrokeColor: NSColor, + badgeHighlightColor: NSColor, + badgeTextColor: NSColor, + surfaceHighlightColor: NSColor, + surfaceBaseColor: NSColor, + surfaceShadowColor: NSColor, + surfaceBorderColor: NSColor, + recessColor: NSColor, + undersideEdgeColor: NSColor?, + undersideCenterColor: NSColor? + ) { + self.groupBackgroundColor = groupBackgroundColor + self.groupStrokeColor = groupStrokeColor + self.badgeFillColor = badgeFillColor + self.badgeHighlightColor = badgeHighlightColor + self.badgeStrokeColor = badgeStrokeColor + self.badgeTextColor = badgeTextColor + self.surfaceBaseColor = surfaceBaseColor + self.surfaceBorderColor = surfaceBorderColor + self.surfaceHighlightColor = surfaceHighlightColor + self.surfaceShadowColor = surfaceShadowColor + self.recessColor = recessColor + self.undersideCenterColor = undersideCenterColor + self.undersideEdgeColor = undersideEdgeColor + self.swatchColor = swatchColor + self.textColor = textColor + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift index ca0c2553..a29eb9b9 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizer.swift @@ -213,11 +213,12 @@ private extension KeyboardVisualizer { let capsNow = modifierFlags.contains(.capsLock) let capsWas = self.lastModifierFlags.contains(.capsLock) if self.visualizerSettings.showSpecialKeys, capsNow != capsWas { + let showsCapsLockDot = self.visualizerSettings.palette.style.presentationPolicy.showsCapsLockDot let group = self.eventCoordinator.handleStandalone( items: [KeycapItem( identity: .keyCode(KeyboardKeyCode.capsLock.rawValue), legend: .capsLock, - state: KeycapState(isPressed: false, showsDot: true, isDotActive: capsNow), + state: KeycapState(isPressed: false, showsDot: showsCapsLockDot, isDotActive: capsNow), layoutHints: KeycapLayoutHints(alignment: .left), appearance: self.visualizerSettings.palette.appearance(for: .keyCode(KeyboardKeyCode.capsLock.rawValue)) )], diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift index 1670afdc..976a4d37 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift @@ -142,6 +142,8 @@ private extension KeyboardVisualizerGroupView { fallbackHeight = MinimalKeycapMetrics.height case .retro: fallbackHeight = RetroKeycapMetrics.height + case .m0116: + fallbackHeight = M0116KeycapMetrics.height case .apple, .pbt: fallbackHeight = AppleKeycapMetrics.height } @@ -165,6 +167,8 @@ private extension KeyboardVisualizerGroupView { return MinimalKeycapMetrics.groupPadding case .retro: return RetroKeycapMetrics.groupPadding + case .m0116: + return M0116KeycapMetrics.groupPadding case .apple: return AppleKeycapMetrics.groupPadding case .pbt: @@ -178,6 +182,8 @@ private extension KeyboardVisualizerGroupView { return MinimalKeycapMetrics.itemSpacing case .retro: return RetroKeycapMetrics.itemSpacing + case .m0116: + return M0116KeycapMetrics.itemSpacing case .apple, .pbt: return AppleKeycapMetrics.itemSpacing } @@ -189,6 +195,8 @@ private extension KeyboardVisualizerGroupView { return MinimalKeycapMetrics.repeatBadgeInset case .retro: return RetroKeycapMetrics.repeatBadgeInset + case .m0116: + return M0116KeycapMetrics.repeatBadgeInset case .apple: return AppleKeycapMetrics.repeatBadgeInset case .pbt: @@ -208,14 +216,16 @@ private extension KeyboardVisualizerGroupView { radius = insetBounds.height / 2 case .retro: radius = 24 + case .m0116: + radius = 12 case .apple, .pbt: radius = 18 } let path = NSBezierPath(roundedRect: insetBounds, xRadius: radius, yRadius: radius) let appearance = self.settings.groupAppearance - appearance.groupBackgroundColor.setFill() + appearance.shared.groupBackgroundColor.setFill() path.fill() - appearance.groupStrokeColor.setStroke() + appearance.shared.groupStrokeColor.setStroke() path.lineWidth = StrokeWidth.standard path.stroke() } @@ -228,7 +238,7 @@ private extension KeyboardVisualizerGroupView { let appearance = self.settings.groupAppearance let attributes: [NSAttributedString.Key: Any] = [ .font: font, - .foregroundColor: appearance.badgeTextColor, + .foregroundColor: appearance.shared.badgeTextColor, .paragraphStyle: paragraphStyle, ] let labelSize = badgeText.size(withAttributes: attributes) @@ -247,9 +257,9 @@ private extension KeyboardVisualizerGroupView { xRadius: badgeHeight / 2, yRadius: badgeHeight / 2 ) - appearance.badgeFillColor.setFill() + appearance.shared.badgeFillColor.setFill() badgePath.fill() - appearance.badgeStrokeColor.setStroke() + appearance.shared.badgeStrokeColor.setStroke() badgePath.lineWidth = StrokeWidth.standard badgePath.stroke() @@ -259,7 +269,7 @@ private extension KeyboardVisualizerGroupView { xRadius: highlightRect.height / 2, yRadius: highlightRect.height / 2 ) - appearance.badgeHighlightColor.setStroke() + appearance.shared.badgeHighlightColor.setStroke() highlightPath.lineWidth = 1 highlightPath.stroke() diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift index 3b9944cd..d5231fc6 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift @@ -184,7 +184,12 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS /// Base theme. Applies to regular keys and — when `usesCustomThemePalette` is off — every key. @Stored(.enum(KeyboardVisualizerSettingsKeys.theme, default: .black)) - var theme: KeyboardVisualizerTheme + private var storedTheme: KeyboardVisualizerTheme + + var theme: KeyboardVisualizerTheme { + get { self.style.sanitize(theme: self.storedTheme) } + set { self.storedTheme = self.style.sanitize(theme: newValue) } + } /// Whether keycap legend drawing should use the theme-default color or a custom override. @Stored(.enum(KeyboardVisualizerSettingsKeys.legendColorMode, default: .automatic)) @@ -199,19 +204,44 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS var usesCustomThemePalette: Bool @Stored(.enum(KeyboardVisualizerSettingsKeys.modifierTheme, default: .black)) - var modifierTheme: KeyboardVisualizerTheme + private var storedModifierTheme: KeyboardVisualizerTheme + + var modifierTheme: KeyboardVisualizerTheme { + get { self.style.sanitize(theme: self.storedModifierTheme) } + set { self.storedModifierTheme = self.style.sanitize(theme: newValue) } + } @Stored(.enum(KeyboardVisualizerSettingsKeys.specialTheme, default: .black)) - var specialTheme: KeyboardVisualizerTheme + private var storedSpecialTheme: KeyboardVisualizerTheme + + var specialTheme: KeyboardVisualizerTheme { + get { self.style.sanitize(theme: self.storedSpecialTheme) } + set { self.storedSpecialTheme = self.style.sanitize(theme: newValue) } + } @Stored(.enum(KeyboardVisualizerSettingsKeys.mediaTheme, default: .black)) - var mediaTheme: KeyboardVisualizerTheme + private var storedMediaTheme: KeyboardVisualizerTheme + + var mediaTheme: KeyboardVisualizerTheme { + get { self.style.sanitize(theme: self.storedMediaTheme) } + set { self.storedMediaTheme = self.style.sanitize(theme: newValue) } + } @Stored(.enum(KeyboardVisualizerSettingsKeys.mouseTheme, default: .black)) - var mouseTheme: KeyboardVisualizerTheme + private var storedMouseTheme: KeyboardVisualizerTheme + + var mouseTheme: KeyboardVisualizerTheme { + get { self.style.sanitize(theme: self.storedMouseTheme) } + set { self.storedMouseTheme = self.style.sanitize(theme: newValue) } + } @Stored(.enum(KeyboardVisualizerSettingsKeys.groupBackgroundTheme, default: .black)) - var groupBackgroundTheme: KeyboardVisualizerTheme + private var storedGroupBackgroundTheme: KeyboardVisualizerTheme + + var groupBackgroundTheme: KeyboardVisualizerTheme { + get { self.style.sanitize(theme: self.storedGroupBackgroundTheme) } + set { self.storedGroupBackgroundTheme = self.style.sanitize(theme: newValue) } + } @Stored(.enum(KeyboardVisualizerSettingsKeys.anchor, default: .default)) var anchor: KeyboardVisualizerAnchor { @@ -309,7 +339,11 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS /// Visual style of the rendered keycaps. @Stored(.enum(KeyboardVisualizerSettingsKeys.style, default: .default)) - var style: KeycapStyle + var style: KeycapStyle { + didSet { + self.sanitizeThemesForStyle() + } + } /// Whether only keystrokes pressed with modifiers should be rendered. @Stored(.bool(KeyboardVisualizerSettingsKeys.onlyShowModifiedKeystrokes, default: false)) @@ -378,4 +412,13 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS } } + private func sanitizeThemesForStyle() { + self.storedTheme = self.style.sanitize(theme: self.storedTheme) + self.storedModifierTheme = self.style.sanitize(theme: self.storedModifierTheme) + self.storedSpecialTheme = self.style.sanitize(theme: self.storedSpecialTheme) + self.storedMediaTheme = self.style.sanitize(theme: self.storedMediaTheme) + self.storedMouseTheme = self.style.sanitize(theme: self.storedMouseTheme) + self.storedGroupBackgroundTheme = self.style.sanitize(theme: self.storedGroupBackgroundTheme) + } + } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerTheme.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerTheme.swift index 313ad0aa..72905cf4 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerTheme.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerTheme.swift @@ -27,6 +27,15 @@ enum KeyboardVisualizerTheme: Int, CaseIterable { // MARK: - Titles extension KeyboardVisualizerTheme { + /// Filters the standard picker grouping down to the themes allowed by the current style. + static func pickerSections(for allowedThemes: [KeyboardVisualizerTheme]) -> [[KeyboardVisualizerTheme]] { + let allowed = Set(allowedThemes) + return Self.pickerSections.compactMap { section in + let filteredSection = section.filter { allowed.contains($0) } + return filteredSection.isEmpty ? nil : filteredSection + } + } + var title: String { switch self { case .black: L10n.KeyboardVisualizer.Theme.dark @@ -47,7 +56,7 @@ extension KeyboardVisualizerTheme { // MARK: - Tokens extension KeyboardVisualizerTheme { - private static func badgeTokens(swatchColor: NSColor, textColor: NSColor) -> (fill: NSColor, stroke: NSColor, highlight: NSColor, text: NSColor) { + static func badgeTokens(swatchColor: NSColor, textColor: NSColor) -> (fill: NSColor, stroke: NSColor, highlight: NSColor, text: NSColor) { let baseFill = swatchColor.relativeLuminance > 0.6 ? swatchColor.darkened(by: 0.12) : swatchColor.lightened(by: 0.08) @@ -375,6 +384,8 @@ extension KeyboardVisualizerTheme { return KeycapAppearance.minimal(KeycapAppearance.Minimal(tokens: tokens)) case .retro: return KeycapAppearance.retro(KeycapAppearance.Retro(tokens: tokens)) + case .m0116: + return KeycapAppearance.m0116(KeycapAppearance.M0116(tokens: tokens)) } } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapAppearance.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapAppearance.swift deleted file mode 100644 index a1e1c5e2..00000000 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapAppearance.swift +++ /dev/null @@ -1,301 +0,0 @@ -// -// KeycapAppearance.swift -// Keyty -// -// SPDX-FileCopyrightText: 2026 Serhii Bykov -// SPDX-License-Identifier: BSD-3-Clause -// - -import AppKit - -struct KeycapThemeTokens { - let swatchColor: NSColor - let textColor: NSColor - - let groupBackgroundColor: NSColor - let groupStrokeColor: NSColor - - let badgeFillColor: NSColor - let badgeStrokeColor: NSColor - let badgeHighlightColor: NSColor - let badgeTextColor: NSColor - - let surfaceHighlightColor: NSColor - let surfaceBaseColor: NSColor - let surfaceShadowColor: NSColor - let surfaceBorderColor: NSColor - - let recessColor: NSColor - /// Optional Apple-style underside edge color override. Falls back to `recessColor`. - let undersideEdgeColor: NSColor? - /// Optional Apple-style underside center color override. Falls back to `surfaceBaseColor`. - let undersideCenterColor: NSColor? -} - -enum KeycapAppearance { - case apple(Apple) - case pbt(PBT) - case minimal(Minimal) - case retro(Retro) - - static let dotColor = NSColor(calibratedRed: 0.0, green: 1.0, blue: 0.1, alpha: 1) - static let inactiveDotColor = NSColor(calibratedWhite: 0.42, alpha: 0.7) - - var textColor: NSColor { - switch self { - case .apple(let appearance): appearance.textColor - case .pbt(let appearance): appearance.textColor - case .minimal(let appearance): appearance.textColor - case .retro(let appearance): appearance.textColor - } - } - - var groupBackgroundColor: NSColor { - switch self { - case .apple(let appearance): appearance.groupBackgroundColor - case .pbt(let appearance): appearance.groupBackgroundColor - case .minimal(let appearance): appearance.groupBackgroundColor - case .retro(let appearance): appearance.groupBackgroundColor - } - } - - var groupStrokeColor: NSColor { - switch self { - case .apple(let appearance): appearance.groupStrokeColor - case .pbt(let appearance): appearance.groupStrokeColor - case .minimal(let appearance): appearance.groupStrokeColor - case .retro(let appearance): appearance.groupStrokeColor - } - } - - var badgeFillColor: NSColor { - switch self { - case .apple(let appearance): appearance.badgeFillColor - case .pbt(let appearance): appearance.badgeFillColor - case .minimal(let appearance): appearance.badgeFillColor - case .retro(let appearance): appearance.badgeFillColor - } - } - - var badgeStrokeColor: NSColor { - switch self { - case .apple(let appearance): appearance.badgeStrokeColor - case .pbt(let appearance): appearance.badgeStrokeColor - case .minimal(let appearance): appearance.badgeStrokeColor - case .retro(let appearance): appearance.badgeStrokeColor - } - } - - var badgeHighlightColor: NSColor { - switch self { - case .apple(let appearance): appearance.badgeHighlightColor - case .pbt(let appearance): appearance.badgeHighlightColor - case .minimal(let appearance): appearance.badgeHighlightColor - case .retro(let appearance): appearance.badgeHighlightColor - } - } - - var badgeTextColor: NSColor { - switch self { - case .apple(let appearance): appearance.badgeTextColor - case .pbt(let appearance): appearance.badgeTextColor - case .minimal(let appearance): appearance.badgeTextColor - case .retro(let appearance): appearance.badgeTextColor - } - } - - var apple: Apple? { - guard case .apple(let appearance) = self else { return nil } - return appearance - } - - var pbt: PBT? { - guard case .pbt(let appearance) = self else { return nil } - return appearance - } - - var minimal: Minimal? { - guard case .minimal(let appearance) = self else { return nil } - return appearance - } - - var retro: Retro? { - guard case .retro(let appearance) = self else { return nil } - return appearance - } -} - -// MARK: - Appearances helpers -extension KeycapAppearance { - struct Apple { - let textColor: NSColor - let groupBackgroundColor: NSColor - let groupStrokeColor: NSColor - let badgeFillColor: NSColor - let badgeStrokeColor: NSColor - let badgeHighlightColor: NSColor - let badgeTextColor: NSColor - let strokeColor: NSColor - let undersideGradient: NSGradient? - let mainGradient: NSGradient? - - init(tokens: KeycapThemeTokens) { - self.textColor = tokens.textColor - self.groupBackgroundColor = tokens.groupBackgroundColor - self.groupStrokeColor = tokens.groupStrokeColor - self.badgeFillColor = tokens.badgeFillColor - self.badgeStrokeColor = tokens.badgeStrokeColor - self.badgeHighlightColor = tokens.badgeHighlightColor - self.badgeTextColor = tokens.badgeTextColor - self.strokeColor = tokens.surfaceBorderColor - let undersideEdgeColor = tokens.undersideEdgeColor ?? tokens.recessColor - let undersideCenterColor = tokens.undersideCenterColor ?? tokens.surfaceBaseColor - self.undersideGradient = NSGradient(colorsAndLocations: - (undersideEdgeColor, 0.0), - (undersideCenterColor, 0.5), - (undersideEdgeColor, 1.0) - ) - self.mainGradient = NSGradient(colorsAndLocations: - (tokens.surfaceBaseColor, 0.0), - (tokens.surfaceShadowColor, 1.0) - ) - } - } - - struct PBT { - let textColor: NSColor - let groupBackgroundColor: NSColor - let groupStrokeColor: NSColor - let badgeFillColor: NSColor - let badgeStrokeColor: NSColor - let badgeHighlightColor: NSColor - let badgeTextColor: NSColor - let bodyGradient: NSGradient? - let bodyStrokeColor: NSColor - let underDishColor: NSColor - let dishGradient: NSGradient? - - init(tokens: KeycapThemeTokens) { - let bodyTop = tokens.surfaceBaseColor.lightened(by: 0.08) - let bodyMid = tokens.surfaceShadowColor.lightened(by: 0.03) - let bodyBottom = tokens.surfaceShadowColor.darkened(by: 0.08) - let dishTop = tokens.surfaceBaseColor.lightened(by: 0.05) - let dishMid = tokens.surfaceBaseColor.lightened(by: 0.06) - let dishBottom = tokens.surfaceShadowColor.lightened(by: 0.05) - - self.textColor = tokens.textColor - self.groupBackgroundColor = tokens.groupBackgroundColor - self.groupStrokeColor = tokens.groupStrokeColor - self.badgeFillColor = tokens.badgeFillColor - self.badgeStrokeColor = tokens.badgeStrokeColor - self.badgeHighlightColor = tokens.badgeHighlightColor - self.badgeTextColor = tokens.badgeTextColor - self.bodyGradient = NSGradient(colorsAndLocations: - (bodyTop, 0.0), - (bodyMid, 0.48), - (bodyBottom, 1.0) - ) - self.bodyStrokeColor = tokens.surfaceBorderColor - self.underDishColor = tokens.surfaceBaseColor.lightened(by: 0.12) - self.dishGradient = NSGradient(colorsAndLocations: - (dishTop, 0.0), - (dishMid, 0.32), - (dishBottom, 1.0) - ) - } - } - - struct Minimal { - let textColor: NSColor - let groupBackgroundColor: NSColor - let groupStrokeColor: NSColor - let badgeFillColor: NSColor - let badgeStrokeColor: NSColor - let badgeHighlightColor: NSColor - let badgeTextColor: NSColor - - init(tokens: KeycapThemeTokens) { - self.textColor = tokens.textColor - self.groupBackgroundColor = tokens.groupBackgroundColor - self.groupStrokeColor = tokens.groupStrokeColor - self.badgeFillColor = tokens.badgeFillColor - self.badgeStrokeColor = tokens.badgeStrokeColor - self.badgeHighlightColor = tokens.badgeHighlightColor - self.badgeTextColor = tokens.badgeTextColor - } - } - - struct Retro { - let textColor: NSColor - let groupBackgroundColor: NSColor - let groupStrokeColor: NSColor - let badgeFillColor: NSColor - let badgeStrokeColor: NSColor - let badgeHighlightColor: NSColor - let badgeTextColor: NSColor - let lipGradient: NSGradient? - let bodyShadowColor: NSColor - let bodyGradient: NSGradient? - let bodyStrokeColor: NSColor - let faceShadowGradient: NSGradient? - let darkFaceGradient: NSGradient? - let darkFaceStrokeColor: NSColor - let lightFaceGradient: NSGradient? - let lightFaceStrokeColor: NSColor - let glossColor: NSColor - - init(tokens: KeycapThemeTokens) { - let bodyEdge = tokens.surfaceShadowColor.darkened(by: 0.08) - let bodyCenter = tokens.surfaceBaseColor.darkened(by: 0.10) - let darkFaceTop = tokens.surfaceBaseColor.lightened(by: 0.03) - let darkFaceBottom = tokens.surfaceBaseColor.darkened(by: 0.01) - let lightFaceTop = tokens.surfaceHighlightColor.lightened(by: 0.08) - let lightFaceBottom = tokens.surfaceBaseColor - let groupBackgroundColor = tokens.recessColor.darkened(by: 0.40) - let groupStrokeColor = tokens.surfaceBaseColor.withAlphaComponent(0.70) - let lipEdge = tokens.recessColor.darkened(by: 0.34).withAlphaComponent(0.96) - let lipCenter = tokens.recessColor.darkened(by: 0.12).withAlphaComponent(0.86) - let bodyStrokeColor = tokens.surfaceBorderColor.withAlphaComponent(0.20) - let darkFaceStrokeColor = tokens.surfaceBorderColor.withAlphaComponent(0.20) - let faceShadowEdge = tokens.recessColor.darkened(by: 0.34).withAlphaComponent(0.46) - let faceShadowCenter = tokens.recessColor.darkened(by: 0.12).withAlphaComponent(0.26) - - self.textColor = tokens.textColor - self.groupBackgroundColor = groupBackgroundColor - self.groupStrokeColor = groupStrokeColor - self.badgeFillColor = tokens.badgeFillColor - self.badgeStrokeColor = tokens.badgeStrokeColor - self.badgeHighlightColor = tokens.badgeHighlightColor - self.badgeTextColor = tokens.badgeTextColor - self.lipGradient = NSGradient(colorsAndLocations: - (lipEdge, 0.0), - (lipCenter, 0.5), - (lipEdge, 1.0) - ) - self.bodyShadowColor = NSColor.black.withAlphaComponent(0.6) - self.bodyGradient = NSGradient(colorsAndLocations: - (bodyEdge, 0.0), - (bodyCenter, 0.32), - (bodyCenter, 0.68), - (bodyEdge, 1.0) - ) - self.bodyStrokeColor = bodyStrokeColor - self.faceShadowGradient = NSGradient(colorsAndLocations: - (faceShadowEdge, 0.0), - (faceShadowCenter, 0.5), - (faceShadowEdge, 1.0) - ) - self.darkFaceGradient = NSGradient(colorsAndLocations: - (darkFaceTop, 0.0), - (darkFaceBottom, 1.0) - ) - self.darkFaceStrokeColor = darkFaceStrokeColor - self.lightFaceGradient = NSGradient(colorsAndLocations: - (lightFaceTop, 0.0), - (lightFaceBottom, 1.0) - ) - self.lightFaceStrokeColor = tokens.surfaceBorderColor.withAlphaComponent(0.55) - self.glossColor = NSColor(white: 1, alpha: 0.08) - } - } -} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Keyboard.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Keyboard.swift index 3d8fb59e..9780dc40 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Keyboard.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapItems/KeycapItemFactory+Keyboard.swift @@ -38,11 +38,16 @@ extension KeycapItemFactory { guard !legend.text.isEmpty || legend.kind != .text else { return result } let identity = KeycapIdentity.keyCode(keyCode) + let presentation = palette.style.presentationPolicy.presentation( + for: keyCode, + legend: Self.keycapLegend(for: keyCode, legend: legend), + layoutHints: Self.layoutHints(forKeyCode: keyCode) + ) result.append(KeycapItem( identity: identity, - legend: Self.keycapLegend(for: keyCode, legend: legend), + legend: presentation.legend, state: KeycapState(isPressed: isPressed), - layoutHints: Self.layoutHints(forKeyCode: keyCode), + layoutHints: presentation.layoutHints, appearance: palette.appearance(for: identity) )) return result @@ -57,7 +62,7 @@ extension KeycapItemFactory { let identity = KeycapIdentity.keyCode(KeyboardKeyCode.function.rawValue) return KeycapItem( identity: identity, - legend: .function, + legend: palette.style.presentationPolicy.functionLegend, 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 index 60ff2a9f..9776303d 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 @@ -56,7 +56,7 @@ extension KeycapItemFactory { let identity = KeycapIdentity.modifier(modifierKey) return KeycapItem( identity: identity, - legend: KeycapLegend(symbol: modifierKey.kind.glyph, label: modifierKey.kind.label), + legend: palette.style.presentationPolicy.modifierLegend(for: modifierKey), state: KeycapState(isPressed: isPressed), layoutHints: KeycapLayoutHints(alignment: modifierKey.legendAlignment), appearance: palette.appearance(for: identity) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapStyle.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapStyle.swift index f69eefac..1c944502 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapStyle.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeycapStyle.swift @@ -5,21 +5,60 @@ // SPDX-FileCopyrightText: 2026 Serhii Bykov // SPDX-License-Identifier: BSD-3-Clause // - enum KeycapStyle: Int, CaseIterable { case apple = 0 case pbt = 1 case minimal = 2 case retro = 3 + case m0116 = 4 static let `default`: KeycapStyle = .apple +} +// MARK: - Titles +extension KeycapStyle { var title: String { switch self { case .apple: L10n.KeyboardVisualizer.Style.apple case .pbt: L10n.KeyboardVisualizer.Style.pbt case .minimal: L10n.KeyboardVisualizer.Style.minimal case .retro: L10n.KeyboardVisualizer.Style.retro + case .m0116: L10n.KeyboardVisualizer.Style.m0116 + } + } +} + +// MARK: - Theme Policy +extension KeycapStyle { + var allowedThemes: [KeyboardVisualizerTheme] { + switch self { + case .m0116: + return [.white] + case .apple, .pbt, .minimal, .retro: + return KeyboardVisualizerTheme.allCases + } + } + + func allows(theme: KeyboardVisualizerTheme) -> Bool { + self.allowedThemes.contains(theme) + } + + func sanitize(theme: KeyboardVisualizerTheme) -> KeyboardVisualizerTheme { + guard self.allows(theme: theme) else { + return self.allowedThemes.first ?? .black + } + return theme + } +} + +// MARK: - Presentation Policy +extension KeycapStyle { + var presentationPolicy: any KeycapPresentationPolicy { + switch self { + case .m0116: + return M0116KeycapPresentationPolicy() + case .apple, .pbt, .minimal, .retro: + return DefaultKeycapPresentationPolicy() } } } 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 f3e2f06c..4dda31d7 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapItem.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapItem.swift @@ -42,6 +42,8 @@ struct KeycapItem { var label: String? { self.legend.label } var rendersSymbolWithLabel: Bool { self.legend.rendersSymbolWithLabel } var rendersCenteredLabel: Bool { self.legend.rendersCenteredLabel } + /// Indicates whether the label should be drawn in a constrained width so it can wrap across lines. + var wrapsLabel: Bool { self.legend.wrapsLabel } 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 31aad36a..9ca9bba1 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapLegend.swift @@ -19,6 +19,7 @@ struct KeycapLegend { let label: String? let rendersSymbolWithLabel: Bool let rendersCenteredLabel: Bool + let wrapsLabel: Bool init( symbol: String = "", @@ -27,7 +28,8 @@ struct KeycapLegend { sfSymbolName: String? = nil, label: String? = nil, rendersSymbolWithLabel: Bool = false, - rendersCenteredLabel: Bool = false + rendersCenteredLabel: Bool = false, + wrapsLabel: Bool = false ) { self.symbol = symbol self.image = image @@ -36,6 +38,7 @@ struct KeycapLegend { self.label = label self.rendersSymbolWithLabel = rendersSymbolWithLabel self.rendersCenteredLabel = rendersCenteredLabel + self.wrapsLabel = wrapsLabel } } @@ -81,5 +84,5 @@ extension KeycapLegend { 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) + static let capsLock = KeycapLegend(label: KeyboardSpecialKey.capsLock.label, wrapsLabel: true) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapPresentation.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapPresentation.swift new file mode 100644 index 00000000..8c37cd75 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Models/KeycapPresentation.swift @@ -0,0 +1,23 @@ +// +// KeycapPresentation.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Foundation + +/// Style-resolved content and layout for a rendered keycap. +struct KeycapPresentation { + let legend: KeycapLegend + let layoutHints: KeycapLayoutHints + + init( + legend: KeycapLegend, + layoutHints: KeycapLayoutHints = KeycapLayoutHints() + ) { + self.legend = legend + self.layoutHints = layoutHints + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/PresentationPolicy/DefaultKeycapPresentationPolicy.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/PresentationPolicy/DefaultKeycapPresentationPolicy.swift new file mode 100644 index 00000000..62bbf2ee --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/PresentationPolicy/DefaultKeycapPresentationPolicy.swift @@ -0,0 +1,26 @@ +// +// DefaultKeycapPresentationPolicy.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Foundation + +struct DefaultKeycapPresentationPolicy: KeycapPresentationPolicy { + var showsCapsLockDot: Bool { true } + var functionLegend: KeycapLegend { .function } + + func modifierLegend(for modifierKey: KeyboardModifierKey) -> KeycapLegend { + KeycapLegend(symbol: modifierKey.kind.glyph, label: modifierKey.kind.label) + } + + func presentation( + for keyCode: UInt16, + legend: KeycapLegend, + layoutHints: KeycapLayoutHints + ) -> KeycapPresentation { + KeycapPresentation(legend: legend, layoutHints: layoutHints) + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/PresentationPolicy/KeycapPresentationPolicy.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/PresentationPolicy/KeycapPresentationPolicy.swift new file mode 100644 index 00000000..6fb130b0 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/PresentationPolicy/KeycapPresentationPolicy.swift @@ -0,0 +1,25 @@ +// +// KeycapPresentationPolicy.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Foundation + +protocol KeycapPresentationPolicy { + /// Indicates whether caps lock should render its indicator dot in this style. + var showsCapsLockDot: Bool { get } + /// Returns the legend used for the function key in this style. + var functionLegend: KeycapLegend { get } + + /// Returns the legend used for a modifier key in this style. + func modifierLegend(for modifierKey: KeyboardModifierKey) -> KeycapLegend + /// Returns the style-adjusted keycap presentation for a keyboard key. + func presentation( + for keyCode: UInt16, + legend: KeycapLegend, + layoutHints: KeycapLayoutHints + ) -> KeycapPresentation +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/PresentationPolicy/M0116KeycapPresentationPolicy.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/PresentationPolicy/M0116KeycapPresentationPolicy.swift new file mode 100644 index 00000000..8ba8318e --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/PresentationPolicy/M0116KeycapPresentationPolicy.swift @@ -0,0 +1,86 @@ +// +// M0116KeycapPresentationPolicy.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Foundation + +struct M0116KeycapPresentationPolicy: KeycapPresentationPolicy { + var showsCapsLockDot: Bool { false } + var functionLegend: KeycapLegend { + KeycapLegend(label: KeyboardSpecialKey.function.label) + } + + func modifierLegend(for modifierKey: KeyboardModifierKey) -> KeycapLegend { + KeycapLegend(label: modifierKey.kind.label) + } + + func presentation( + for keyCode: UInt16, + legend: KeycapLegend, + layoutHints: KeycapLayoutHints + ) -> KeycapPresentation { + guard let specialKey = KeyboardSpecialKeyResolver.specialKey(for: keyCode) else { + return KeycapPresentation(legend: legend, layoutHints: layoutHints) + } + + switch specialKey { + case .escape: + return self.textOnlyPresentation(for: .escape, layoutHints: layoutHints) + case .tab: + return self.textOnlyPresentation(for: .tab, layoutHints: layoutHints) + case .delete: + return self.textOnlyPresentation(for: .delete, layoutHints: layoutHints) + case .returnKey: + return self.textOnlyPresentation(for: .returnKey, layoutHints: layoutHints) + case .home, .end: + return KeycapPresentation( + legend: KeycapLegend(label: legend.label), + layoutHints: layoutHints + ) + case .pageUp, .pageDown: + return KeycapPresentation( + legend: KeycapLegend(label: legend.label, wrapsLabel: true), + layoutHints: layoutHints + ) + case .space: + return KeycapPresentation(legend: KeycapLegend(), layoutHints: layoutHints) + case .leftArrow: + return KeycapPresentation( + legend: KeycapLegend(symbol: UnicodeToken.leftwardsDashedArrow.string), + layoutHints: layoutHints + ) + case .rightArrow: + return KeycapPresentation( + legend: KeycapLegend(symbol: UnicodeToken.rightwardsDashedArrow.string), + layoutHints: layoutHints + ) + case .upArrow: + return KeycapPresentation( + legend: KeycapLegend(symbol: UnicodeToken.upwardsDashedArrow.string), + layoutHints: layoutHints + ) + case .downArrow: + return KeycapPresentation( + legend: KeycapLegend(symbol: UnicodeToken.downwardsDashedArrow.string), + layoutHints: layoutHints + ) + case .forwardDelete, .keypadEnter, .help, .insert, .keypadClear, + .function, .functionRow, .capsLock, .eisu, .kana, .system: + return KeycapPresentation(legend: legend, layoutHints: layoutHints) + } + } + + private func textOnlyPresentation( + for specialKey: KeyboardSpecialKey, + layoutHints: KeycapLayoutHints + ) -> KeycapPresentation { + KeycapPresentation( + legend: KeycapLegend(label: specialKey.label), + layoutHints: layoutHints + ) + } +} 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 265e48e8..d1f1c46d 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 @@ -47,7 +47,7 @@ struct AppleKeycapRenderer: KeycapRendering { self.legendRenderer.draw( item: item, in: keycapRect, - textColor: appearance.textColor, + textColor: appearance.shared.textColor, centeredLabelWidth: self.centeredLabelWidth(for: item) ) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapContext.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapContext.swift index 0816cb92..2fe09dce 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapContext.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapContext.swift @@ -45,4 +45,11 @@ extension KeycapContext { } return self.settings.theme.appearance(for: .retro).retro! } + + var m0116Appearance: KeycapAppearance.M0116 { + if let appearance = self.appearance.m0116 { + return appearance + } + return self.settings.theme.appearance(for: .m0116).m0116! + } } 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 d19a2d23..500d82c0 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapLegendRenderer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapLegendRenderer.swift @@ -170,7 +170,7 @@ struct KeycapLegendRenderer { } } - private func drawKeycapDot(in rect: NSRect, active: Bool) { + func drawKeycapDot(in rect: NSRect, active: Bool) { let dotSize: CGFloat = 8 let dotRect = NSRect( x: rect.minX + CommonKeycapMetrics.horizontalPadding, diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapRendererFactory.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapRendererFactory.swift index b4f41fd6..37b5ac76 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapRendererFactory.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapRendererFactory.swift @@ -19,6 +19,8 @@ enum KeycapRendererFactory { return MinimalKeycapRenderer() case .retro: return RetroKeycapRenderer() + case .m0116: + return M0116KeycapRenderer() } } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapStyle+SizeNormalization.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapStyle+SizeNormalization.swift index d342d53e..c21cbfaa 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapStyle+SizeNormalization.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapStyle+SizeNormalization.swift @@ -25,6 +25,9 @@ extension KeycapStyle { case .retro: return RetroKeycapMetrics.height + RetroKeycapMetrics.groupPadding.top + RetroKeycapMetrics.groupPadding.bottom + case .m0116: + return M0116KeycapMetrics.height + + M0116KeycapMetrics.groupPadding.top + M0116KeycapMetrics.groupPadding.bottom } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/M0116/M0116KeycapMetrics.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/M0116/M0116KeycapMetrics.swift new file mode 100644 index 00000000..aabbf50c --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/M0116/M0116KeycapMetrics.swift @@ -0,0 +1,81 @@ +// +// M0116KeycapMetrics.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +/// Proportions traced from an Apple Standard Keyboard (M0116): square-ish caps on an +/// 82pt pitch, a face inset from the sculpted sides, and a tall lit skirt at the front. +enum M0116KeycapMetrics { + static let height: CGFloat = 88 + static let minWidth: CGFloat = 80 + static let itemSpacing: CGFloat = 6 + static let groupPadding = NSEdgeInsets(top: 11, left: 11, bottom: 11, right: 11) + static let repeatBadgeInset = CGPoint(x: 6, y: 6) + + static let bodyCornerRadius: CGFloat = 9 + static let faceCornerRadius: CGFloat = 7 + static let frontLipCornerRadius: CGFloat = 9 + static let faceSideInset: CGFloat = 5 + static let faceTopInset: CGFloat = 4 + static let faceBottomInset: CGFloat = 15 + static let frontLipOverlap: CGFloat = 4 + static let horizontalPadding: CGFloat = 11 + static let capsLockLabelWidth: CGFloat = 52 + static let pageNavigationLabelWidth: CGFloat = 48 + static let legendBaselineInset: CGFloat = 6 + static let pressedTravel: CGFloat = 2 + + /// The near-black well each cap sits in. Half the item spacing on each side, so + /// neighbouring wells meet and read as one continuous plate. + static let wellSideInset: CGFloat = 3 + static let wellBottomInset: CGFloat = 3.5 + static let wellTopInset: CGFloat = 1.5 + + /// The original caps were printed in Univers 57 Condensed Oblique. Univers is a + /// licensed Linotype family that macOS does not ship, so it is used when the user + /// happens to have it installed and otherwise stood in for by condensing a Helvetica + /// oblique — the closest neo-grotesque available on every Mac. + struct LegendFace { + let name: String + /// Horizontal scale applied to reach Univers 57's condensed width. Faces that are + /// already condensed use 1. + let condensation: CGFloat + } + + static let legendFaces: [LegendFace] = [ + LegendFace(name: "Univers-CondensedOblique", condensation: 1), + LegendFace(name: "UniversLTStd-CnObl", condensation: 1), + LegendFace(name: "HelveticaNeue-Italic", condensation: 0.82), + LegendFace(name: "Helvetica-Oblique", condensation: 0.82), + LegendFace(name: "HelveticaNeue-LightItalic", condensation: 0.82), + LegendFace(name: "Helvetica-LightOblique", condensation: 0.82), + ] + + static func legendFont(ofSize size: CGFloat) -> NSFont { + for face in self.legendFaces { + guard NSFont(name: face.name, size: size) != nil else { continue } + if face.condensation == 1 { + return NSFont(name: face.name, size: size) ?? NSFont.systemFont(ofSize: size) + } + let matrix = AffineTransform(scaleByX: size * face.condensation, byY: size) + let descriptor = NSFontDescriptor(fontAttributes: [.name: face.name, .matrix: matrix]) + if let font = NSFont(descriptor: descriptor, size: 0) { + return font + } + } + + let descriptor = NSFont.systemFont(ofSize: size) + .fontDescriptor + .withSymbolicTraits([.italic, .condensed]) + return NSFont(descriptor: descriptor, size: size) ?? NSFont.systemFont(ofSize: size) + } + + static var charFont: NSFont { self.legendFont(ofSize: 24) } + static var labelFont: NSFont { self.legendFont(ofSize: 19) } + static var symbolFont: NSFont { self.legendFont(ofSize: 23) } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/M0116/M0116KeycapRenderer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/M0116/M0116KeycapRenderer.swift new file mode 100644 index 00000000..24c1e18c --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/M0116/M0116KeycapRenderer.swift @@ -0,0 +1,411 @@ +// +// M0116KeycapRenderer.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +/// Draws a sculpted Apple Standard Keyboard (M0116) keycap: a near-black well, a body lit +/// from the left, an inset top face, and the lit front skirt below it. Text legends sit on a +/// shared baseline in the lower-left corner, the way the originals were printed. +struct M0116KeycapRenderer: KeycapRendering { + private let legendRenderer = KeycapLegendRenderer() + + func size(for context: KeycapContext) -> CGSize { + CGSize( + width: self.m0116KeycapWidth(for: context.item), + height: M0116KeycapMetrics.height + ) + } + + func draw(context: KeycapContext, in rect: NSRect) { + let item = context.item + let appearance = context.m0116Appearance + let press = item.isPressed ? -M0116KeycapMetrics.pressedTravel : 0 + let bodyRect = rect.offsetBy(dx: 0, dy: press) + + self.drawWell(around: rect, appearance: appearance) + + let bodyPath = NSBezierPath( + roundedRect: bodyRect, + xRadius: M0116KeycapMetrics.bodyCornerRadius, + yRadius: M0116KeycapMetrics.bodyCornerRadius + ) + let faceRect = self.faceRect(in: bodyRect) + let frontLipPath = self.frontLipPath(in: bodyRect, faceRect: faceRect) + let facePath = self.facePath(in: faceRect) + + NSGraphicsContext.saveGraphicsState() + bodyPath.addClip() + appearance.bodyGradient?.draw( + from: NSPoint(x: bodyRect.minX, y: bodyRect.midY), + to: NSPoint(x: bodyRect.maxX, y: bodyRect.midY), + options: [] + ) + NSGraphicsContext.restoreGraphicsState() + + NSGraphicsContext.saveGraphicsState() + bodyPath.addClip() + appearance.skirtGradient?.draw(in: frontLipPath, angle: 90) + NSGraphicsContext.restoreGraphicsState() + + NSGraphicsContext.saveGraphicsState() + facePath.addClip() + appearance.faceGradient?.draw(in: facePath, angle: 90) + NSGraphicsContext.restoreGraphicsState() + + appearance.creaseColor.setStroke() + facePath.lineWidth = StrokeWidth.standard + facePath.stroke() + self.drawSideSeams(in: bodyRect, faceRect: faceRect, color: appearance.creaseColor) + + appearance.bodyStrokeColor.setStroke() + bodyPath.lineWidth = StrokeWidth.standard + bodyPath.stroke() + + self.drawLegend(item: item, in: faceRect, textColor: appearance.shared.textColor) + } +} + +// MARK: - Geometry +private extension M0116KeycapRenderer { + /// The shared width helper measures legends in the app-wide label font. This style sets + /// them in a larger italic face, so the text is re-measured here to keep it from clipping. + func m0116KeycapWidth(for item: KeycapItem) -> CGFloat { + if self.usesRegularNavigationWidth(item) { + return M0116KeycapMetrics.minWidth + } + + let sideInsets = 2 * (M0116KeycapMetrics.horizontalPadding + M0116KeycapMetrics.faceSideInset) + var legendWidth: CGFloat = 0 + if let label = item.label { + legendWidth = label.size(withAttributes: [.font: M0116KeycapMetrics.labelFont]).width + } else if item.image == nil, item.sfSymbolName == nil { + legendWidth = item.symbol.size(withAttributes: [.font: M0116KeycapMetrics.charFont]).width + } + if item.label != nil, !item.symbol.isEmpty { + legendWidth = max( + legendWidth, + item.symbol.size(withAttributes: [.font: M0116KeycapMetrics.symbolFont]).width + ) + } + + return max( + M0116KeycapMetrics.minWidth, + keycapWidth(for: item), + (legendWidth + sideInsets).rounded(.up) + ) + } + + func faceRect(in bodyRect: NSRect) -> NSRect { + NSRect( + x: bodyRect.minX + M0116KeycapMetrics.faceSideInset, + y: bodyRect.minY + M0116KeycapMetrics.faceBottomInset, + width: bodyRect.width - 2 * M0116KeycapMetrics.faceSideInset, + height: bodyRect.height - M0116KeycapMetrics.faceTopInset - M0116KeycapMetrics.faceBottomInset + ) + } + + func facePath(in faceRect: NSRect) -> NSBezierPath { + let radius = M0116KeycapMetrics.faceCornerRadius + let path = NSBezierPath() + let lowerCornerInset = radius * 0.95 + let lowerVerticalInset = radius * 0.28 + + path.move(to: NSPoint(x: faceRect.minX + radius, y: faceRect.maxY)) + path.line(to: NSPoint(x: faceRect.maxX - radius, y: faceRect.maxY)) + path.curve( + to: NSPoint(x: faceRect.maxX, y: faceRect.maxY - radius), + controlPoint1: NSPoint(x: faceRect.maxX - radius * 0.45, y: faceRect.maxY), + controlPoint2: NSPoint(x: faceRect.maxX, y: faceRect.maxY - radius * 0.45) + ) + path.line(to: NSPoint(x: faceRect.maxX, y: faceRect.minY + lowerVerticalInset)) + path.curve( + to: NSPoint(x: faceRect.maxX - lowerCornerInset, y: faceRect.minY), + controlPoint1: NSPoint(x: faceRect.maxX, y: faceRect.minY + lowerVerticalInset * 0.35), + controlPoint2: NSPoint(x: faceRect.maxX - lowerCornerInset * 0.15, y: faceRect.minY) + ) + path.line(to: NSPoint(x: faceRect.minX + lowerCornerInset, y: faceRect.minY)) + path.curve( + to: NSPoint(x: faceRect.minX, y: faceRect.minY + lowerVerticalInset), + controlPoint1: NSPoint(x: faceRect.minX + lowerCornerInset * 0.15, y: faceRect.minY), + controlPoint2: NSPoint(x: faceRect.minX, y: faceRect.minY + lowerVerticalInset * 0.35) + ) + path.line(to: NSPoint(x: faceRect.minX, y: faceRect.maxY - radius)) + path.curve( + to: NSPoint(x: faceRect.minX + radius, y: faceRect.maxY), + controlPoint1: NSPoint(x: faceRect.minX, y: faceRect.maxY - radius * 0.45), + controlPoint2: NSPoint(x: faceRect.minX + radius * 0.45, y: faceRect.maxY) + ) + path.close() + return path + } + + func frontLipPath(in bodyRect: NSRect, faceRect: NSRect) -> NSBezierPath { + let path = NSBezierPath() + let leftSeam = self.leftSideSeamCurve(in: bodyRect, faceRect: faceRect) + let rightSeam = self.rightSideSeamCurve(in: bodyRect, faceRect: faceRect) + let topY = faceRect.minY + M0116KeycapMetrics.frontLipOverlap + let leftTopX = faceRect.minX + (M0116KeycapMetrics.faceCornerRadius * 0.45) + let rightTopX = faceRect.maxX - (M0116KeycapMetrics.faceCornerRadius * 0.45) + + path.move(to: NSPoint(x: bodyRect.minX + M0116KeycapMetrics.bodyCornerRadius, y: bodyRect.minY)) + path.line(to: NSPoint(x: bodyRect.maxX - M0116KeycapMetrics.bodyCornerRadius, y: bodyRect.minY)) + path.curve( + to: NSPoint(x: rightSeam.end.x, y: rightSeam.end.y), + controlPoint1: NSPoint(x: bodyRect.maxX, y: bodyRect.minY), + controlPoint2: rightSeam.controlPoint2 + ) + path.line(to: NSPoint(x: rightTopX, y: topY)) + path.line(to: NSPoint(x: leftTopX, y: topY)) + path.line(to: NSPoint(x: leftSeam.end.x, y: leftSeam.end.y)) + path.curve( + to: NSPoint(x: bodyRect.minX + M0116KeycapMetrics.bodyCornerRadius, y: bodyRect.minY), + controlPoint1: leftSeam.controlPoint2, + controlPoint2: NSPoint(x: bodyRect.minX, y: bodyRect.minY) + ) + path.close() + return path + } + + /// The M0116's side walls should be structurally readable on both sides. These seams + /// trace the transition from the inset face down into the front skirt so the lighting + /// can stay directional without making one side look accidentally sharper. + func drawSideSeams(in bodyRect: NSRect, faceRect: NSRect, color: NSColor) { + color.setStroke() + + let leftSeamPath = self.leftSideSeamPath(in: bodyRect, faceRect: faceRect) + leftSeamPath.lineWidth = StrokeWidth.standard + leftSeamPath.stroke() + + let rightSeamPath = self.rightSideSeamPath(in: bodyRect, faceRect: faceRect) + rightSeamPath.lineWidth = StrokeWidth.standard + rightSeamPath.stroke() + } + + func leftSideSeamPath(in bodyRect: NSRect, faceRect: NSRect) -> NSBezierPath { + let path = NSBezierPath() + let curve = self.leftSideSeamCurve(in: bodyRect, faceRect: faceRect) + + path.move(to: curve.start) + path.line(to: curve.curveStart) + path.curve( + to: curve.end, + controlPoint1: curve.controlPoint1, + controlPoint2: curve.controlPoint2 + ) + return path + } + + func rightSideSeamPath(in bodyRect: NSRect, faceRect: NSRect) -> NSBezierPath { + let path = NSBezierPath() + let curve = self.rightSideSeamCurve(in: bodyRect, faceRect: faceRect) + + path.move(to: curve.start) + path.line(to: curve.curveStart) + path.curve( + to: curve.end, + controlPoint1: curve.controlPoint1, + controlPoint2: curve.controlPoint2 + ) + return path + } + + func leftSideSeamCurve(in bodyRect: NSRect, faceRect: NSRect) -> SideSeamCurve { + let faceX = faceRect.minX + let bodyX = bodyRect.minX + return SideSeamCurve( + start: NSPoint( + x: faceX, + y: faceRect.maxY - (M0116KeycapMetrics.faceCornerRadius * 0.75) + ), + curveStart: NSPoint( + x: faceX, + y: faceRect.minY + (M0116KeycapMetrics.faceCornerRadius * 0.5) + ), + end: NSPoint( + x: bodyX, + y: bodyRect.minY + M0116KeycapMetrics.bodyCornerRadius + ), + controlPoint1: NSPoint(x: faceX, y: faceRect.minY - 1), + controlPoint2: NSPoint( + x: faceX + ((bodyX - faceX) * 0.65), + y: bodyRect.minY + 2 + ) + ) + } + + func rightSideSeamCurve(in bodyRect: NSRect, faceRect: NSRect) -> SideSeamCurve { + let faceX = faceRect.maxX + let bodyX = bodyRect.maxX + return SideSeamCurve( + start: NSPoint( + x: faceX, + y: faceRect.maxY - (M0116KeycapMetrics.faceCornerRadius * 0.75) + ), + curveStart: NSPoint( + x: faceX, + y: faceRect.minY + (M0116KeycapMetrics.faceCornerRadius * 0.5) + ), + end: NSPoint( + x: bodyX, + y: bodyRect.minY + M0116KeycapMetrics.bodyCornerRadius + ), + controlPoint1: NSPoint(x: faceX, y: faceRect.minY - 1), + controlPoint2: NSPoint( + x: faceX + ((bodyX - faceX) * 0.65), + y: bodyRect.minY + 2 + ) + ) + } + + /// The recess the cap sits in. It reaches half the item spacing to each side so + /// adjacent wells meet, leaving one uninterrupted dark plate between the caps. + func drawWell(around rect: NSRect, appearance: KeycapAppearance.M0116) { + let wellRect = NSRect( + x: rect.minX - M0116KeycapMetrics.wellSideInset, + y: rect.minY - M0116KeycapMetrics.wellBottomInset, + width: rect.width + 2 * M0116KeycapMetrics.wellSideInset, + height: rect.height + M0116KeycapMetrics.wellBottomInset + M0116KeycapMetrics.wellTopInset + ) + appearance.wellColor.setFill() + NSBezierPath( + roundedRect: wellRect, + xRadius: M0116KeycapMetrics.bodyCornerRadius + 2, + yRadius: M0116KeycapMetrics.bodyCornerRadius + 2 + ).fill() + } +} + +// MARK: - Legends +private extension M0116KeycapRenderer { + func drawLegend(item: KeycapItem, in faceRect: NSRect, textColor: NSColor) { + guard self.rendersOwnLegend(for: item) else { + self.legendRenderer.draw(item: item, in: faceRect, textColor: textColor) + return + } + + if item.wrapsLabel, let label = item.label { + self.drawWrappedLabel(label, for: item, in: faceRect, textColor: textColor) + if item.state.showsDot { + self.legendRenderer.drawKeycapDot(in: faceRect, active: item.state.isDotActive) + } + return + } + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .left + let x = faceRect.minX + M0116KeycapMetrics.horizontalPadding + let baseline = faceRect.minY + M0116KeycapMetrics.legendBaselineInset + + let baseText = item.label ?? item.symbol + let baseFont = item.label == nil ? M0116KeycapMetrics.charFont : M0116KeycapMetrics.labelFont + let baseAttributes: [NSAttributedString.Key: Any] = [ + .font: baseFont, + .foregroundColor: textColor, + .paragraphStyle: paragraphStyle, + ] + baseText.draw(at: NSPoint(x: x, y: baseline), withAttributes: baseAttributes) + + // Keys carrying both a symbol and a label stack the symbol above the baseline, + // mirroring the shifted legends printed on the original caps. + if item.label != nil, !item.symbol.isEmpty { + let symbolAttributes: [NSAttributedString.Key: Any] = [ + .font: M0116KeycapMetrics.symbolFont, + .foregroundColor: textColor, + .paragraphStyle: paragraphStyle, + ] + let baseHeight = baseText.size(withAttributes: baseAttributes).height + item.symbol.draw( + at: NSPoint(x: x, y: baseline + baseHeight - 2), + withAttributes: symbolAttributes + ) + } + + if item.state.showsDot { + self.legendRenderer.drawKeycapDot(in: faceRect, active: item.state.isDotActive) + } + } + + func drawWrappedLabel(_ label: String, for item: KeycapItem, in faceRect: NSRect, textColor: NSColor) { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .left + let attributes: [NSAttributedString.Key: Any] = [ + .font: M0116KeycapMetrics.labelFont, + .foregroundColor: textColor, + .paragraphStyle: paragraphStyle, + ] + let width = min( + self.wrappedLabelWidth(for: item, faceRect: faceRect), + faceRect.width - 2 * M0116KeycapMetrics.horizontalPadding + ) + let bounds = NSRect( + x: faceRect.minX + M0116KeycapMetrics.horizontalPadding, + y: faceRect.minY + M0116KeycapMetrics.legendBaselineInset, + width: width, + height: faceRect.height - M0116KeycapMetrics.legendBaselineInset + ) + let size = label.boundingRect( + with: bounds.size, + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: attributes + ).integral.size + label.draw( + in: NSRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: size.height + ), + withAttributes: attributes + ) + } + + /// Image and SF Symbol legends keep the shared centered treatment; only text legends + /// take the M0116's lower-left baseline. + func rendersOwnLegend(for item: KeycapItem) -> Bool { + guard item.image == nil, !item.rendersSymbolWithLabel, !item.rendersCenteredLabel else { + return false + } + if item.label != nil { + return true + } + return item.sfSymbolName == nil && !item.symbol.isEmpty + } + + func wrappedLabelWidth(for item: KeycapItem, faceRect: NSRect) -> CGFloat { + switch item.identity { + case .keyCode(KeyboardKeyCode.capsLock.rawValue): + return M0116KeycapMetrics.capsLockLabelWidth + case .keyCode(KeyboardKeyCode.pageUp.rawValue), .keyCode(KeyboardKeyCode.pageDown.rawValue): + return M0116KeycapMetrics.pageNavigationLabelWidth + default: + return faceRect.width - 2 * M0116KeycapMetrics.horizontalPadding + } + } + + func usesRegularNavigationWidth(_ item: KeycapItem) -> Bool { + switch item.identity { + case .keyCode(KeyboardKeyCode.home.rawValue), + .keyCode(KeyboardKeyCode.end.rawValue), + .keyCode(KeyboardKeyCode.pageUp.rawValue), + .keyCode(KeyboardKeyCode.pageDown.rawValue): + return true + default: + return false + } + } +} + +private extension M0116KeycapRenderer { + struct SideSeamCurve { + let start: NSPoint + let curveStart: NSPoint + let end: NSPoint + let controlPoint1: NSPoint + let controlPoint2: NSPoint + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Minimal/MinimalKeycapRenderer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Minimal/MinimalKeycapRenderer.swift index 296f80d0..8e6f4566 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Minimal/MinimalKeycapRenderer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Minimal/MinimalKeycapRenderer.swift @@ -28,7 +28,7 @@ struct MinimalKeycapRenderer: KeycapRendering { if let image = item.image { let size = scaledImageSize(for: image) - KeycapImageRenderer.draw(image: image, badgeText: item.imageBadgeText, color: appearance.textColor, in: NSRect( + KeycapImageRenderer.draw(image: image, badgeText: item.imageBadgeText, color: appearance.shared.textColor, in: NSRect( x: rect.midX - size.width / 2, y: rect.midY - size.height / 2, width: size.width, @@ -37,7 +37,7 @@ struct MinimalKeycapRenderer: KeycapRendering { } else if let sfSymbolName = item.sfSymbolName, let image = SymbolImage.image( named: sfSymbolName, - color: appearance.textColor, + color: appearance.shared.textColor, pointSize: MinimalKeycapMetrics.symbolFont.pointSize ) { let size = image.size @@ -50,7 +50,7 @@ struct MinimalKeycapRenderer: KeycapRendering { } else if !item.symbol.isEmpty { let attributes: [NSAttributedString.Key: Any] = [ .font: MinimalKeycapMetrics.symbolFont, - .foregroundColor: appearance.textColor, + .foregroundColor: appearance.shared.textColor, ] let size = item.symbol.size(withAttributes: attributes) item.symbol.draw( @@ -65,7 +65,7 @@ struct MinimalKeycapRenderer: KeycapRendering { } else if let label = item.label { let attributes: [NSAttributedString.Key: Any] = [ .font: MinimalKeycapMetrics.labelFont, - .foregroundColor: appearance.textColor, + .foregroundColor: appearance.shared.textColor, ] let size = label.size(withAttributes: attributes) label.draw( diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/PBT/PBTKeycapRenderer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/PBT/PBTKeycapRenderer.swift index c88ddb4d..99b1fe62 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/PBT/PBTKeycapRenderer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/PBT/PBTKeycapRenderer.swift @@ -81,6 +81,6 @@ struct PBTKeycapRenderer: KeycapRendering { dishPath.fill() } - legendRenderer.draw(item: item, in: dishRect, textColor: appearance.textColor) + legendRenderer.draw(item: item, in: dishRect, textColor: appearance.shared.textColor) } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Retro/RetroKeycapRenderer.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Retro/RetroKeycapRenderer.swift index 72ba8886..5001d1e4 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Retro/RetroKeycapRenderer.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/Retro/RetroKeycapRenderer.swift @@ -91,7 +91,7 @@ struct RetroKeycapRenderer: KeycapRendering { facePath.lineWidth = 1.25 facePath.stroke() - legendRenderer.draw(item: item, in: faceRect, textColor: appearance.textColor) + legendRenderer.draw(item: item, in: faceRect, textColor: appearance.shared.textColor) } private func retroKeycapWidth(for item: KeycapItem) -> CGFloat { diff --git a/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift b/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift index a608c783..9c913c58 100644 --- a/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift +++ b/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift @@ -56,6 +56,7 @@ enum Size { static let colorWell = CGSize(width: Spacing.grid(11), height: Spacing.grid(6)) static let numberFieldWidth = Spacing.grid(11) static let settingsPickerWidth = Spacing.grid(45) + static let keyboardStylePickerWidth = Spacing.grid(56) static let themePickerWidth = settingsPickerWidth } } diff --git a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings index 2e0cd57c..380f42d5 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -115,10 +115,11 @@ "keyboard_visualizer.size_subtitle" = "Gesamtskalierung der dargestellten Tastenkappen."; "keyboard_visualizer.style_label" = "Stil"; "keyboard_visualizer.style_subtitle" = "Allgemeine Form und Oberflächenbehandlung der Tastenkappen."; -"keyboard_visualizer.style.apple" = "Apple"; +"keyboard_visualizer.style.apple" = "Apple Magic Keyboard"; "keyboard_visualizer.style.pbt" = "PBT"; "keyboard_visualizer.style.minimal" = "Minimal"; "keyboard_visualizer.style.retro" = "Retro"; +"keyboard_visualizer.style.m0116" = "Apple Standard Keyboard"; "keyboard_visualizer.linger_time_label" = "Anzeigedauer"; "keyboard_visualizer.linger_time_subtitle" = "Dauer, für die Tastenkappen vor dem Ausblenden vollständig sichtbar bleiben."; "keyboard_visualizer.linger_time.short" = "Kurz"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index 628fc3a9..103e1664 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -115,10 +115,11 @@ "keyboard_visualizer.size_subtitle" = "Overall scale of the rendered keycaps."; "keyboard_visualizer.style_label" = "Style"; "keyboard_visualizer.style_subtitle" = "Overall keycap construction and surface treatment."; -"keyboard_visualizer.style.apple" = "Apple"; +"keyboard_visualizer.style.apple" = "Apple Magic Keyboard"; "keyboard_visualizer.style.pbt" = "PBT"; "keyboard_visualizer.style.minimal" = "Minimal"; "keyboard_visualizer.style.retro" = "Retro"; +"keyboard_visualizer.style.m0116" = "Apple Standard Keyboard"; "keyboard_visualizer.linger_time_label" = "Linger Time"; "keyboard_visualizer.linger_time_subtitle" = "How long keycaps remain fully visible before fading."; "keyboard_visualizer.linger_time.short" = "Short"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings index 86a40724..7867d9c1 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -115,10 +115,11 @@ "keyboard_visualizer.size_subtitle" = "Escala general de las teclas representadas."; "keyboard_visualizer.style_label" = "Estilo"; "keyboard_visualizer.style_subtitle" = "Diseño general y acabado de la superficie de las teclas."; -"keyboard_visualizer.style.apple" = "Apple"; +"keyboard_visualizer.style.apple" = "Apple Magic Keyboard"; "keyboard_visualizer.style.pbt" = "PBT"; "keyboard_visualizer.style.minimal" = "Minimalista"; "keyboard_visualizer.style.retro" = "Retro"; +"keyboard_visualizer.style.m0116" = "Apple Standard Keyboard"; "keyboard_visualizer.linger_time_label" = "Tiempo en pantalla"; "keyboard_visualizer.linger_time_subtitle" = "Tiempo que las teclas permanecen totalmente visibles antes de desvanecerse."; "keyboard_visualizer.linger_time.short" = "Corto"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings index 0cadea16..6477c408 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -115,10 +115,11 @@ "keyboard_visualizer.size_subtitle" = "Échelle générale des touches affichées."; "keyboard_visualizer.style_label" = "Style"; "keyboard_visualizer.style_subtitle" = "Forme générale et traitement de surface des touches."; -"keyboard_visualizer.style.apple" = "Apple"; +"keyboard_visualizer.style.apple" = "Apple Magic Keyboard"; "keyboard_visualizer.style.pbt" = "PBT"; "keyboard_visualizer.style.minimal" = "Minimal"; "keyboard_visualizer.style.retro" = "Rétro"; +"keyboard_visualizer.style.m0116" = "Apple Standard Keyboard"; "keyboard_visualizer.linger_time_label" = "Durée d’affichage"; "keyboard_visualizer.linger_time_subtitle" = "Durée pendant laquelle les touches restent entièrement visibles avant de s’estomper."; "keyboard_visualizer.linger_time.short" = "Courte"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings index f1b25790..d6295db2 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -115,10 +115,11 @@ "keyboard_visualizer.size_subtitle" = "表示するキー全体の大きさです。"; "keyboard_visualizer.style_label" = "スタイル"; "keyboard_visualizer.style_subtitle" = "キーの全体的な形状と表面の仕上げです。"; -"keyboard_visualizer.style.apple" = "Apple"; +"keyboard_visualizer.style.apple" = "Apple Magic Keyboard"; "keyboard_visualizer.style.pbt" = "PBT"; "keyboard_visualizer.style.minimal" = "ミニマル"; "keyboard_visualizer.style.retro" = "レトロ"; +"keyboard_visualizer.style.m0116" = "Apple Standard Keyboard"; "keyboard_visualizer.linger_time_label" = "表示時間"; "keyboard_visualizer.linger_time_subtitle" = "フェードアウトが始まるまでキーを完全に表示する時間です。"; "keyboard_visualizer.linger_time.short" = "短い"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings index 3f5f48b3..b9e96c8a 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings @@ -115,10 +115,11 @@ "keyboard_visualizer.size_subtitle" = "Algemene schaal van de weergegeven toetsen."; "keyboard_visualizer.style_label" = "Stijl"; "keyboard_visualizer.style_subtitle" = "Algemene toetsconstructie en oppervlaktebehandeling."; -"keyboard_visualizer.style.apple" = "Apple"; +"keyboard_visualizer.style.apple" = "Apple Magic Keyboard"; "keyboard_visualizer.style.pbt" = "PBT"; "keyboard_visualizer.style.minimal" = "Minimaal"; "keyboard_visualizer.style.retro" = "Retro"; +"keyboard_visualizer.style.m0116" = "Apple Standard Keyboard"; "keyboard_visualizer.linger_time_label" = "Blijftijd"; "keyboard_visualizer.linger_time_subtitle" = "Hoe lang toetsen volledig zichtbaar blijven voordat ze vervagen."; "keyboard_visualizer.linger_time.short" = "Kort"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings index 48340f24..a65d6419 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings @@ -115,10 +115,11 @@ "keyboard_visualizer.size_subtitle" = "Ogólna skala renderowanych klawiszy."; "keyboard_visualizer.style_label" = "Styl"; "keyboard_visualizer.style_subtitle" = "Ogólna konstrukcja i wykończenie powierzchni klawiszy."; -"keyboard_visualizer.style.apple" = "Apple"; +"keyboard_visualizer.style.apple" = "Apple Magic Keyboard"; "keyboard_visualizer.style.pbt" = "PBT"; "keyboard_visualizer.style.minimal" = "Minimalny"; "keyboard_visualizer.style.retro" = "Retro"; +"keyboard_visualizer.style.m0116" = "Apple Standard Keyboard"; "keyboard_visualizer.linger_time_label" = "Czas pozostawania"; "keyboard_visualizer.linger_time_subtitle" = "Jak długo klawisze pozostają w pełni widoczne przed zanikiem."; "keyboard_visualizer.linger_time.short" = "Krótko"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings index 5e77ddc5..fc36fafb 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings @@ -115,10 +115,11 @@ "keyboard_visualizer.size_subtitle" = "Escala geral das teclas renderizadas."; "keyboard_visualizer.style_label" = "Estilo"; "keyboard_visualizer.style_subtitle" = "Construção geral das teclas e tratamento da superfície."; -"keyboard_visualizer.style.apple" = "Apple"; +"keyboard_visualizer.style.apple" = "Apple Magic Keyboard"; "keyboard_visualizer.style.pbt" = "PBT"; "keyboard_visualizer.style.minimal" = "Minimalista"; "keyboard_visualizer.style.retro" = "Retrô"; +"keyboard_visualizer.style.m0116" = "Apple Standard Keyboard"; "keyboard_visualizer.linger_time_label" = "Tempo de Permanência"; "keyboard_visualizer.linger_time_subtitle" = "Por quanto tempo as teclas permanecem totalmente visíveis antes de desaparecer."; "keyboard_visualizer.linger_time.short" = "Curto"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings index 76afc31f..3016607d 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -115,10 +115,11 @@ "keyboard_visualizer.size_subtitle" = "Загальний масштаб відображуваних клавіш."; "keyboard_visualizer.style_label" = "Стиль"; "keyboard_visualizer.style_subtitle" = "Загальний вигляд і оздоблення клавіш."; -"keyboard_visualizer.style.apple" = "Apple"; +"keyboard_visualizer.style.apple" = "Apple Magic Keyboard"; "keyboard_visualizer.style.pbt" = "PBT"; "keyboard_visualizer.style.minimal" = "Мінімалізм"; "keyboard_visualizer.style.retro" = "Ретро"; +"keyboard_visualizer.style.m0116" = "Apple Standard Keyboard"; "keyboard_visualizer.linger_time_label" = "Час відображення"; "keyboard_visualizer.linger_time_subtitle" = "Як довго клавіші залишаються повністю видимими перед зникненням."; "keyboard_visualizer.linger_time.short" = "Короткий"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings index 0ea8e9b0..6057dd67 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings @@ -115,10 +115,11 @@ "keyboard_visualizer.size_subtitle" = "所渲染按键的整体缩放比例。"; "keyboard_visualizer.style_label" = "样式"; "keyboard_visualizer.style_subtitle" = "按键的整体结构和表面效果。"; -"keyboard_visualizer.style.apple" = "Apple"; +"keyboard_visualizer.style.apple" = "Apple Magic Keyboard"; "keyboard_visualizer.style.pbt" = "PBT"; "keyboard_visualizer.style.minimal" = "极简"; "keyboard_visualizer.style.retro" = "复古"; +"keyboard_visualizer.style.m0116" = "Apple Standard Keyboard"; "keyboard_visualizer.linger_time_label" = "显示时间"; "keyboard_visualizer.linger_time_subtitle" = "按键在淡出前保持完全可见的时间。"; "keyboard_visualizer.linger_time.short" = "短"; diff --git a/Apps/Keyty/Sources/Keyty/Services/Display/UnicodeToken.swift b/Apps/Keyty/Sources/Keyty/Services/Display/UnicodeToken.swift index 7af0d495..53aeb456 100644 --- a/Apps/Keyty/Sources/Keyty/Services/Display/UnicodeToken.swift +++ b/Apps/Keyty/Sources/Keyty/Services/Display/UnicodeToken.swift @@ -36,6 +36,11 @@ enum UnicodeToken { static let rightArrow: Unicode.Scalar = "\u{25B6}" static let upArrow: Unicode.Scalar = "\u{25B2}" static let downArrow: Unicode.Scalar = "\u{25BC}" + + static let leftwardsDashedArrow: Unicode.Scalar = "\u{21E0}" + static let upwardsDashedArrow: Unicode.Scalar = "\u{21E1}" + static let rightwardsDashedArrow: Unicode.Scalar = "\u{21E2}" + static let downwardsDashedArrow: Unicode.Scalar = "\u{21E3}" static let leftwardsArrow: Unicode.Scalar = "\u{2190}" static let rightwardsArrow: Unicode.Scalar = "\u{2192}" diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/ColorPickerOrderTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/ColorPickerOrderTests.swift index 978dc986..34bb8449 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/ColorPickerOrderTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/ColorPickerOrderTests.swift @@ -19,6 +19,13 @@ final class ColorPickerOrderTests: XCTestCase { ) } + func testM0116ThemePickerOnlyShowsWhite() { + XCTAssertEqual( + KeyboardVisualizerTheme.pickerSections(for: KeycapStyle.m0116.allowedThemes), + [[.white]] + ) + } + func testKeyboardLegendColorPickerOrdersColorsBySpectrum() { XCTAssertEqual( Array(KeyboardSettingsPaneViewModel.ColorPreset.presets.suffix(Self.colorOrder.count)).map(\.color.hexString), 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 aa3cd938..68b2e4ad 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerGroupViewSnapshotTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerGroupViewSnapshotTests.swift @@ -34,6 +34,52 @@ final class KeyboardVisualizerGroupViewSnapshotTests: XCTestCase { ) } + func testRendersM0116StyleKeycap() { + self.assertGroupSnapshot( + style: .m0116, + theme: .white, + items: self.keycapItems( + keyCode: KeyboardKeyCode.a.rawValue, + legend: EventLegend(text: "A"), + style: .m0116, + theme: .white + ), + named: "m0116-keycap" + ) + } + + func testRendersM0116StyleEscapeKeycap() { + self.assertGroupSnapshot( + style: .m0116, + theme: .white, + items: self.keycapItems( + keyCode: KeyboardKeyCode.escape.rawValue, + legend: EventLegend(text: KeyboardGlyphCatalog.symbol(for: .escape), label: KeyboardSpecialKey.escape.label), + style: .m0116, + theme: .white + ), + named: "m0116-escape-keycap" + ) + } + + func testRendersM0116StyleCommandShiftKKeycaps() { + let settings = self.settings(style: .m0116, theme: .white) + let items = KeycapItemFactory.modifierItems( + currentFlags: [], + releasedFlags: Self.commandShiftFlags, + palette: settings.palette + ) + + KeycapItemFactory.keycapItems( + keyCode: KeyboardKeyCode.k.rawValue, + legend: EventLegend(text: "K"), + modifierFlags: [], + isPressed: false, + palette: settings.palette + ) + + self.assertGroupSnapshot(style: .m0116, theme: .white, items: items, named: "m0116-command-shift-k-keycaps") + } + func testRendersAppleBlackEscapeKeycap() { self.assertAppleBlackSnapshot( items: self.keycapItems( @@ -53,14 +99,16 @@ private extension KeyboardVisualizerGroupViewSnapshotTests { func keycapItems( keyCode: UInt16, legend: EventLegend, - modifierFlags: NSEvent.ModifierFlags = [] + modifierFlags: NSEvent.ModifierFlags = [], + style: KeycapStyle = .apple, + theme: KeyboardVisualizerTheme = .black ) -> [KeycapItem] { KeycapItemFactory.keycapItems( keyCode: keyCode, legend: legend, modifierFlags: modifierFlags, isPressed: false, - palette: self.appleBlackSettings.palette + palette: self.settings(style: style, theme: theme).palette ) } @@ -87,7 +135,19 @@ private extension KeyboardVisualizerGroupViewSnapshotTests { testName: String = #function, line: UInt = #line ) { - let settings = self.appleBlackSettings + self.assertGroupSnapshot(style: .apple, theme: .black, items: items, named: name, file: file, testName: testName, line: line) + } + + func assertGroupSnapshot( + style: KeycapStyle, + theme: KeyboardVisualizerTheme, + items: [KeycapItem], + named name: String, + file: StaticString = #filePath, + testName: String = #function, + line: UInt = #line + ) { + let settings = self.settings(style: style, theme: theme) let view = KeyboardVisualizerGroupView(items: items, settings: settings) view.frame = NSRect(origin: .zero, size: view.preferredSize) @@ -102,9 +162,13 @@ private extension KeyboardVisualizerGroupViewSnapshotTests { } var appleBlackSettings: KeyboardVisualizerSettings { + self.settings(style: .apple, theme: .black) + } + + func settings(style: KeycapStyle, theme: KeyboardVisualizerTheme) -> KeyboardVisualizerSettings { let settings = KeyboardVisualizerSettings(store: InMemoryKeyValueStore()) - settings.style = .apple - settings.theme = .black + settings.style = style + settings.theme = theme settings.scale = 1.0 return settings } diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift index 857df03c..37a98851 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift @@ -321,7 +321,7 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { self.settings.style = .pbt let pbtAppearance = self.settings.appearance - XCTAssertEqual(appleAppearance.textColor, pbtAppearance.textColor) + XCTAssertEqual(appleAppearance.shared.textColor, pbtAppearance.shared.textColor) XCTAssertTrue(appleAppearance.apple != nil) XCTAssertTrue(pbtAppearance.pbt != nil) XCTAssertEqual(pbtAppearance.pbt?.bodyStrokeColor, KeyboardVisualizerTheme.citrus.appearance(for: .pbt).pbt?.bodyStrokeColor) @@ -333,4 +333,40 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { XCTAssertEqual(self.settings.themeTokens.surfaceBaseColor, KeyboardVisualizerTheme.black.tokens.surfaceBaseColor) XCTAssertEqual(self.settings.themeTokens.recessColor, KeyboardVisualizerTheme.black.tokens.recessColor) } + + + func testM0116StyleClampsThemesToWhite() { + self.settings.theme = .black + self.settings.modifierTheme = .purple + self.settings.specialTheme = .rose + self.settings.mediaTheme = .blue + self.settings.mouseTheme = .green + self.settings.groupBackgroundTheme = .orange + self.settings.style = .m0116 + + XCTAssertEqual(self.settings.theme, .white) + XCTAssertEqual(self.settings.modifierTheme, .white) + XCTAssertEqual(self.settings.specialTheme, .white) + XCTAssertEqual(self.settings.mediaTheme, .white) + XCTAssertEqual(self.settings.mouseTheme, .white) + XCTAssertEqual(self.settings.groupBackgroundTheme, .white) + XCTAssertEqual(self.store.integer(forKey: KeyboardVisualizerSettingsKeys.theme), KeyboardVisualizerTheme.white.rawValue) + XCTAssertEqual(self.store.integer(forKey: KeyboardVisualizerSettingsKeys.modifierTheme), KeyboardVisualizerTheme.white.rawValue) + XCTAssertEqual(self.store.integer(forKey: KeyboardVisualizerSettingsKeys.specialTheme), KeyboardVisualizerTheme.white.rawValue) + XCTAssertEqual(self.store.integer(forKey: KeyboardVisualizerSettingsKeys.mediaTheme), KeyboardVisualizerTheme.white.rawValue) + XCTAssertEqual(self.store.integer(forKey: KeyboardVisualizerSettingsKeys.mouseTheme), KeyboardVisualizerTheme.white.rawValue) + XCTAssertEqual(self.store.integer(forKey: KeyboardVisualizerSettingsKeys.groupBackgroundTheme), KeyboardVisualizerTheme.white.rawValue) + XCTAssertNotNil(self.settings.appearance.m0116) + XCTAssertNotEqual(self.settings.appearance.m0116?.shared.textColor, KeyboardVisualizerTheme.white.tokens.textColor) + } + + func testM0116StyleRejectsNonWhiteThemeAssignments() { + self.settings.style = .m0116 + + self.settings.theme = .black + self.settings.modifierTheme = .purple + + XCTAssertEqual(self.settings.theme, .white) + XCTAssertEqual(self.settings.modifierTheme, .white) + } } 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 e1461af2..e04c17ec 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeycapItemFactoryTests.swift @@ -42,6 +42,29 @@ final class KeycapItemFactoryTests: XCTestCase { XCTAssertEqual(KeycapCategory(identity: item.identity), .special) } + func testM0116FunctionKeyMatchesModifierStyle() { + let m0116Item = KeycapItemFactory.functionItem( + isPressed: true, + palette: Self.makePalette(style: .m0116, theme: .white) + ) + let appleItem = KeycapItemFactory.functionItem( + isPressed: true, + palette: Self.makePalette(style: .apple, theme: .black) + ) + + XCTAssertEqual(m0116Item.identity, .keyCode(KeyboardKeyCode.function.rawValue)) + XCTAssertEqual(m0116Item.symbol, "") + XCTAssertEqual(m0116Item.label, "fn") + XCTAssertNil(m0116Item.sfSymbolName) + XCTAssertEqual(appleItem.sfSymbolName, "globe") + XCTAssertEqual(appleItem.label, "fn") + } + + func testM0116PresentationPolicyHidesCapsLockDot() { + XCTAssertFalse(Self.makePalette(style: .m0116, theme: .white).style.presentationPolicy.showsCapsLockDot) + XCTAssertTrue(Self.makePalette(style: .apple, theme: .black).style.presentationPolicy.showsCapsLockDot) + } + func testModifierItemsUseLocationSpecificModifierKeys() { let items = KeycapItemFactory.modifierItems( currentFlags: NSEvent.ModifierFlags.command.addingRawMasks( @@ -178,6 +201,216 @@ final class KeycapItemFactoryTests: XCTestCase { } } + func testM0116EscapeOmitsSymbolButKeepsLayoutHints() { + let items = KeycapItemFactory.keycapItems( + keyCode: KeyboardKeyCode.escape.rawValue, + legend: EventLegend( + text: KeyboardSpecialKey.escape.displayText, + label: KeyboardSpecialKey.escape.label + ), + modifierFlags: [], + isPressed: true, + palette: Self.makePalette(style: .m0116, theme: .white) + ) + + XCTAssertEqual(items.map(\.identity), [.keyCode(KeyboardKeyCode.escape.rawValue)]) + XCTAssertEqual(items.first?.symbol, "") + XCTAssertEqual(items.first?.label, "esc") + XCTAssertEqual(items.first?.rendersCenteredLabel, false) + XCTAssertEqual(items.first?.layoutHints.alignment, .left) + } + + func testM0116ArrowsUseDashedArrowGlyphsOnlyForThatStyle() { + let m0116Palette = Self.makePalette(style: .m0116, theme: .white) + let applePalette = Self.makePalette(style: .apple, theme: .black) + let expectedM0116: [(KeyboardKeyCode, String, String)] = [ + (.leftArrow, UnicodeToken.leftwardsDashedArrow.string, UnicodeToken.leftArrow.string), + (.rightArrow, UnicodeToken.rightwardsDashedArrow.string, UnicodeToken.rightArrow.string), + (.upArrow, UnicodeToken.upwardsDashedArrow.string, UnicodeToken.upArrow.string), + (.downArrow, UnicodeToken.downwardsDashedArrow.string, UnicodeToken.downArrow.string), + ] + + for (keyCode, m0116Symbol, defaultSymbol) in expectedM0116 { + let legend = EventLegend(text: KeyboardSpecialKeyResolver.specialKey(for: keyCode.rawValue)?.displayText ?? "") + + let m0116Items = KeycapItemFactory.keycapItems( + keyCode: keyCode.rawValue, + legend: legend, + modifierFlags: [], + isPressed: true, + palette: m0116Palette + ) + let appleItems = KeycapItemFactory.keycapItems( + keyCode: keyCode.rawValue, + legend: legend, + modifierFlags: [], + isPressed: true, + palette: applePalette + ) + + XCTAssertEqual(m0116Items.first?.symbol, m0116Symbol) + XCTAssertEqual(appleItems.first?.symbol, defaultSymbol) + } + } + + func testM0116SpaceOmitsLegendButKeepsWideKeycap() { + let items = KeycapItemFactory.keycapItems( + keyCode: KeyboardKeyCode.space.rawValue, + legend: EventLegend(text: KeyboardSpecialKey.space.displayText), + modifierFlags: [], + isPressed: true, + palette: Self.makePalette(style: .m0116, theme: .white) + ) + + XCTAssertEqual(items.map(\.identity), [.keyCode(KeyboardKeyCode.space.rawValue)]) + XCTAssertEqual(items.first?.symbol, "") + XCTAssertEqual(items.first?.label, nil) + XCTAssertEqual(items.first?.rendersCenteredLabel, false) + XCTAssertEqual(items.first?.layoutHints.fixedWidth, 256) + } + + func testM0116ReturnOmitsSymbolButKeepsReturnKeyLayout() { + let items = KeycapItemFactory.keycapItems( + keyCode: KeyboardKeyCode.returnKey.rawValue, + legend: EventLegend( + text: KeyboardSpecialKey.returnKey.displayText, + label: KeyboardSpecialKey.returnKey.label + ), + modifierFlags: [], + isPressed: true, + palette: Self.makePalette(style: .m0116, theme: .white) + ) + + XCTAssertEqual(items.map(\.identity), [.keyCode(KeyboardKeyCode.returnKey.rawValue)]) + XCTAssertEqual(items.first?.symbol, "") + XCTAssertEqual(items.first?.label, "return") + XCTAssertEqual(items.first?.rendersCenteredLabel, false) + XCTAssertNil(items.first?.layoutHints.fixedWidth) + XCTAssertEqual(items.first.map(keycapWidth(for:)), 128) + } + + func testM0116DeleteOmitsSymbolButKeepsDeleteKeyLayout() { + let items = KeycapItemFactory.keycapItems( + keyCode: KeyboardKeyCode.delete.rawValue, + legend: EventLegend( + text: KeyboardSpecialKey.delete.displayText, + label: KeyboardSpecialKey.delete.label + ), + modifierFlags: [], + isPressed: true, + palette: Self.makePalette(style: .m0116, theme: .white) + ) + + XCTAssertEqual(items.map(\.identity), [.keyCode(KeyboardKeyCode.delete.rawValue)]) + XCTAssertEqual(items.first?.symbol, "") + XCTAssertEqual(items.first?.label, "delete") + XCTAssertEqual(items.first?.rendersCenteredLabel, false) + XCTAssertEqual(items.first.map(keycapWidth(for:)), 112) + } + + func testM0116NavigationKeysUseRegularLabelRendering() { + let expected: [(KeyboardKeyCode, String)] = [ + (.home, "home"), + (.end, "end"), + ] + + 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: Self.makePalette(style: .m0116, theme: .white) + ) + + XCTAssertEqual(items.map(\.identity), [.keyCode(keyCode.rawValue)]) + XCTAssertEqual(items.first?.symbol, "") + XCTAssertEqual(items.first?.label, label) + XCTAssertEqual(items.first?.rendersCenteredLabel, false) + XCTAssertEqual(items.first?.wrapsLabel, false) + } + } + + func testM0116PageNavigationKeysWrapLabels() { + let expected: [(KeyboardKeyCode, String)] = [ + (.pageUp, "page up"), + (.pageDown, "page down"), + ] + + 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: Self.makePalette(style: .m0116, theme: .white) + ) + + XCTAssertEqual(items.map(\.identity), [.keyCode(keyCode.rawValue)]) + XCTAssertEqual(items.first?.symbol, "") + XCTAssertEqual(items.first?.label, label) + XCTAssertEqual(items.first?.rendersCenteredLabel, false) + XCTAssertEqual(items.first?.wrapsLabel, true) + } + } + + func testM0116TabOmitsSymbolButKeepsTabLayout() { + let items = KeycapItemFactory.keycapItems( + keyCode: KeyboardKeyCode.tab.rawValue, + legend: EventLegend( + text: KeyboardSpecialKey.tab.displayText, + label: KeyboardSpecialKey.tab.label + ), + modifierFlags: [], + isPressed: true, + palette: Self.makePalette(style: .m0116, theme: .white) + ) + + XCTAssertEqual(items.map(\.identity), [.keyCode(KeyboardKeyCode.tab.rawValue)]) + XCTAssertEqual(items.first?.symbol, "") + XCTAssertEqual(items.first?.label, "tab") + XCTAssertEqual(items.first?.rendersCenteredLabel, false) + XCTAssertEqual(items.first?.layoutHints.alignment, .left) + XCTAssertEqual(items.first.map(keycapWidth(for:)), 112) + } + + func testM0116ModifiersOmitGlyphsButOtherStylesKeepThem() { + let cases: [(NSEvent.ModifierFlags, UInt, KeycapIdentity, String, String)] = [ + (.shift.addingRawMasks(UInt(NX_DEVICELSHIFTKEYMASK)), UInt(NX_DEVICELSHIFTKEYMASK), .modifier(.leftShift), "shift", UnicodeToken.shift.string), + (.control.addingRawMasks(UInt(NX_DEVICELCTLKEYMASK)), UInt(NX_DEVICELCTLKEYMASK), .modifier(.leftControl), "control", UnicodeToken.control.string), + (.option.addingRawMasks(UInt(NX_DEVICELALTKEYMASK)), UInt(NX_DEVICELALTKEYMASK), .modifier(.leftOption), "option", UnicodeToken.option.string), + (.command.addingRawMasks(UInt(NX_DEVICELCMDKEYMASK)), UInt(NX_DEVICELCMDKEYMASK), .modifier(.leftCommand), "command", UnicodeToken.command.string), + ] + + for (flags, _, expectedIdentity, expectedLabel, defaultSymbol) in cases { + let m0116Items = KeycapItemFactory.modifierItems( + currentFlags: flags, + releasedFlags: [], + palette: Self.makePalette(style: .m0116, theme: .white) + ) + let appleItems = KeycapItemFactory.modifierItems( + currentFlags: flags, + releasedFlags: [], + palette: Self.makePalette(style: .apple, theme: .black) + ) + + XCTAssertEqual(m0116Items.map(\.identity), [expectedIdentity]) + XCTAssertEqual(m0116Items.first?.symbol, "") + XCTAssertEqual(m0116Items.first?.label, expectedLabel) + XCTAssertEqual(appleItems.first?.symbol, defaultSymbol) + XCTAssertEqual(appleItems.first?.label, expectedLabel) + } + + XCTAssertEqual( + KeycapItemFactory.modifierItems( + currentFlags: .shift.addingRawMasks(UInt(NX_DEVICELSHIFTKEYMASK)), + releasedFlags: [], + palette: Self.makePalette(style: .m0116, theme: .white) + ).first?.layoutHints.alignment, + .right + ) + } + func testAppleRendererKeepsPageNavigationKeysAtStandardWidth() throws { let palette = Self.makePalette() let settings = KeyboardVisualizerSettings(store: InMemoryKeyValueStore()) @@ -197,6 +430,25 @@ final class KeycapItemFactoryTests: XCTestCase { } } + func testM0116RendererKeepsNavigationKeysAtRegularWidth() throws { + let palette = Self.makePalette(style: .m0116, theme: .white) + let settings = KeyboardVisualizerSettings(store: InMemoryKeyValueStore()) + let renderer = M0116KeycapRenderer() + + for keyCode in [KeyboardKeyCode.home, .end, .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, M0116KeycapMetrics.minWidth) + } + } + func testMouseItemUsesPressedStateFromMouseEventType() { let palette = Self.makePalette() diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/__Snapshots__/KeyboardVisualizerGroupViewSnapshotTests/testRendersM0116StyleCommandShiftKKeycaps.m0116-command-shift-k-keycaps.png b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/__Snapshots__/KeyboardVisualizerGroupViewSnapshotTests/testRendersM0116StyleCommandShiftKKeycaps.m0116-command-shift-k-keycaps.png new file mode 100644 index 00000000..6a3a0760 Binary files /dev/null and b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/__Snapshots__/KeyboardVisualizerGroupViewSnapshotTests/testRendersM0116StyleCommandShiftKKeycaps.m0116-command-shift-k-keycaps.png differ diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/__Snapshots__/KeyboardVisualizerGroupViewSnapshotTests/testRendersM0116StyleEscapeKeycap.m0116-escape-keycap.png b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/__Snapshots__/KeyboardVisualizerGroupViewSnapshotTests/testRendersM0116StyleEscapeKeycap.m0116-escape-keycap.png new file mode 100644 index 00000000..8acef23c Binary files /dev/null and b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/__Snapshots__/KeyboardVisualizerGroupViewSnapshotTests/testRendersM0116StyleEscapeKeycap.m0116-escape-keycap.png differ diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/__Snapshots__/KeyboardVisualizerGroupViewSnapshotTests/testRendersM0116StyleKeycap.m0116-keycap.png b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/__Snapshots__/KeyboardVisualizerGroupViewSnapshotTests/testRendersM0116StyleKeycap.m0116-keycap.png new file mode 100644 index 00000000..eea2639a Binary files /dev/null and b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/__Snapshots__/KeyboardVisualizerGroupViewSnapshotTests/testRendersM0116StyleKeycap.m0116-keycap.png differ