From b998b106653f337855d7afa30db4987127b4cdf5 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 13 Sep 2026 16:47:42 +0200 Subject: [PATCH] fix(keyboard): preserve themes across Apple Standard style changes --- .../Keyboard/KeyboardSettingsPane.swift | 16 ++++- .../KeyboardSettingsPaneViewModel.swift | 67 ++++--------------- .../Keyboard/KeyboardVisualizerSettings.swift | 47 ++++++------- .../Keyboard/Renderers/KeycapContext.swift | 10 +-- .../KeyboardVisualizerSettingsTests.swift | 32 +++------ 5 files changed, 61 insertions(+), 111 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift index 955585c..ac054a3 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift @@ -314,7 +314,7 @@ struct KeyboardSettingsPane: View { ) { self.colorMenuItem( title: L10n.KeyboardVisualizer.Color.automatic, - swatchColor: self.model.theme.displayColor, + swatchColor: self.model.style.sanitize(theme: self.model.theme).displayColor, tag: KeyboardSettingsPaneViewModel.ColorPreset.automaticSelectionID ) @@ -353,7 +353,7 @@ struct KeyboardSettingsPane: View { selection: Binding ) -> some View { SettingsControlRow(title: title, subtitle: subtitle) { - Picker("", selection: selection) { + Picker("", selection: self.compatibleThemeBinding(selection)) { ForEach(Array(self.model.allowedThemeSections.enumerated()), id: \.offset) { index, section in if index > 0 { Divider() @@ -370,6 +370,18 @@ struct KeyboardSettingsPane: View { } } + private func compatibleThemeBinding( + _ selection: Binding + ) -> Binding { + Binding( + get: { self.model.style.sanitize(theme: selection.wrappedValue) }, + set: { theme in + guard self.model.style.allows(theme: theme), self.model.style != .m0116 else { return } + selection.wrappedValue = theme + } + ) + } + private func themeRow(_ theme: KeyboardVisualizerTheme) -> some View { HStack(spacing: Spacing.none) { SwiftUI.Image(nsImage: theme.displayColor.swatchImage(trailingPadding: Spacing.xxs)) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift index cab3037..0fd45d4 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPaneViewModel.swift @@ -50,15 +50,7 @@ final class KeyboardSettingsPaneViewModel: ObservableObject { } @Published var theme: KeyboardVisualizerTheme { - 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 - } + didSet { self.settings.theme = self.theme } } @Published var legendColorMode: KeyboardLegendColorMode { @@ -76,12 +68,17 @@ final class KeyboardSettingsPaneViewModel: ObservableObject { /// Drives the base theme picker. Picking a concrete theme turns custom mode /// off and sets the base `theme`; picking `.custom` turns custom mode on. var themeSelection: ThemeSelection { - get { self.usesCustomThemePalette ? .custom : .theme(self.theme) } + get { + self.usesCustomThemePalette + ? .custom + : .theme(self.style.sanitize(theme: self.theme)) + } set { switch newValue { case .custom: self.usesCustomThemePalette = true case .theme(let theme): + guard self.style.allows(theme: theme), self.style != .m0116 else { return } self.usesCustomThemePalette = false self.theme = theme } @@ -89,63 +86,23 @@ final class KeyboardSettingsPaneViewModel: ObservableObject { } @Published var modifierTheme: KeyboardVisualizerTheme { - 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 - } + didSet { self.settings.modifierTheme = self.modifierTheme } } @Published var specialTheme: KeyboardVisualizerTheme { - 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 - } + didSet { self.settings.specialTheme = self.specialTheme } } @Published var mediaTheme: KeyboardVisualizerTheme { - 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 - } + didSet { self.settings.mediaTheme = self.mediaTheme } } @Published var mouseTheme: KeyboardVisualizerTheme { - 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 - } + didSet { self.settings.mouseTheme = self.mouseTheme } } @Published var groupBackgroundTheme: KeyboardVisualizerTheme { - 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 - } + didSet { self.settings.groupBackgroundTheme = self.groupBackgroundTheme } } @Published var style: KeycapStyle { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift index 1c741d3..036a178 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift @@ -198,8 +198,8 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS private var storedTheme: KeyboardVisualizerTheme var theme: KeyboardVisualizerTheme { - get { self.style.sanitize(theme: self.storedTheme) } - set { self.storedTheme = self.style.sanitize(theme: newValue) } + get { self.storedTheme } + set { self.storedTheme = newValue } } /// Whether keycap legend drawing should use the theme-default color or a custom override. @@ -218,40 +218,40 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS private var storedModifierTheme: KeyboardVisualizerTheme var modifierTheme: KeyboardVisualizerTheme { - get { self.style.sanitize(theme: self.storedModifierTheme) } - set { self.storedModifierTheme = self.style.sanitize(theme: newValue) } + get { self.storedModifierTheme } + set { self.storedModifierTheme = newValue } } @Stored(.enum(KeyboardVisualizerSettingsKeys.specialTheme, default: .black)) private var storedSpecialTheme: KeyboardVisualizerTheme var specialTheme: KeyboardVisualizerTheme { - get { self.style.sanitize(theme: self.storedSpecialTheme) } - set { self.storedSpecialTheme = self.style.sanitize(theme: newValue) } + get { self.storedSpecialTheme } + set { self.storedSpecialTheme = newValue } } @Stored(.enum(KeyboardVisualizerSettingsKeys.mediaTheme, default: .black)) private var storedMediaTheme: KeyboardVisualizerTheme var mediaTheme: KeyboardVisualizerTheme { - get { self.style.sanitize(theme: self.storedMediaTheme) } - set { self.storedMediaTheme = self.style.sanitize(theme: newValue) } + get { self.storedMediaTheme } + set { self.storedMediaTheme = newValue } } @Stored(.enum(KeyboardVisualizerSettingsKeys.mouseTheme, default: .black)) private var storedMouseTheme: KeyboardVisualizerTheme var mouseTheme: KeyboardVisualizerTheme { - get { self.style.sanitize(theme: self.storedMouseTheme) } - set { self.storedMouseTheme = self.style.sanitize(theme: newValue) } + get { self.storedMouseTheme } + set { self.storedMouseTheme = newValue } } @Stored(.enum(KeyboardVisualizerSettingsKeys.groupBackgroundTheme, default: .black)) private var storedGroupBackgroundTheme: KeyboardVisualizerTheme var groupBackgroundTheme: KeyboardVisualizerTheme { - get { self.style.sanitize(theme: self.storedGroupBackgroundTheme) } - set { self.storedGroupBackgroundTheme = self.style.sanitize(theme: newValue) } + get { self.storedGroupBackgroundTheme } + set { self.storedGroupBackgroundTheme = newValue } } @Stored(.enum(KeyboardVisualizerSettingsKeys.anchor, default: .default)) @@ -350,11 +350,7 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS /// Visual style of the rendered keycaps. @Stored(.enum(KeyboardVisualizerSettingsKeys.style, default: .default)) - var style: KeycapStyle { - didSet { - self.sanitizeThemesForStyle() - } - } + var style: KeycapStyle /// Whether only keystrokes pressed with modifiers should be rendered. @Stored(.bool(KeyboardVisualizerSettingsKeys.onlyShowModifiedKeystrokes, default: false)) @@ -377,19 +373,19 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS var showMouseEvents: Bool var themeTokens: KeycapThemeTokens { - self.theme.tokens(legendColorOverride: self.resolvedLegendColorOverride) + self.effectiveTheme(self.theme).tokens(legendColorOverride: self.resolvedLegendColorOverride) } var appearance: KeycapAppearance { - self.theme.appearance(for: self.style, legendColorOverride: self.resolvedLegendColorOverride) + self.effectiveTheme(self.theme).appearance(for: self.style, legendColorOverride: self.resolvedLegendColorOverride) } /// Resolves per-key-type appearance. When `usesCustomThemePalette` is off, every category /// (and the group background) collapses to the base `theme` — identical to legacy behavior. var palette: KeycapThemePalette { - let base = self.theme + let base = self.effectiveTheme(self.theme) func resolve(_ specific: KeyboardVisualizerTheme) -> KeyboardVisualizerTheme { - self.usesCustomThemePalette ? specific : base + self.usesCustomThemePalette ? self.effectiveTheme(specific) : base } return KeycapThemePalette( style: self.style, @@ -423,13 +419,8 @@ 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) + private func effectiveTheme(_ theme: KeyboardVisualizerTheme) -> KeyboardVisualizerTheme { + self.style.sanitize(theme: theme) } } 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 2fe09dc..1c810eb 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapContext.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/Renderers/KeycapContext.swift @@ -22,34 +22,34 @@ extension KeycapContext { if let appearance = self.appearance.apple { return appearance } - return self.settings.theme.appearance(for: .apple).apple! + return self.settings.appearance.apple! } var pbtAppearance: KeycapAppearance.PBT { if let appearance = self.appearance.pbt { return appearance } - return self.settings.theme.appearance(for: .pbt).pbt! + return self.settings.appearance.pbt! } var minimalAppearance: KeycapAppearance.Minimal { if let appearance = self.appearance.minimal { return appearance } - return self.settings.theme.appearance(for: .minimal).minimal! + return self.settings.appearance.minimal! } var retroAppearance: KeycapAppearance.Retro { if let appearance = self.appearance.retro { return appearance } - return self.settings.theme.appearance(for: .retro).retro! + return self.settings.appearance.retro! } var m0116Appearance: KeycapAppearance.M0116 { if let appearance = self.appearance.m0116 { return appearance } - return self.settings.theme.appearance(for: .m0116).m0116! + return self.settings.appearance.m0116! } } 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 9fa8237..765aba4 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift @@ -356,7 +356,7 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { } - func testM0116StyleClampsThemesToWhite() { + func testM0116StyleUsesWhiteWithoutOverwritingSavedThemes() { self.settings.theme = .black self.settings.modifierTheme = .purple self.settings.specialTheme = .rose @@ -365,29 +365,19 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { 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) + XCTAssertEqual(self.settings.theme, .black) + XCTAssertEqual(self.settings.modifierTheme, .purple) + XCTAssertEqual(self.settings.specialTheme, .rose) + XCTAssertEqual(self.settings.mediaTheme, .blue) + XCTAssertEqual(self.settings.mouseTheme, .green) + XCTAssertEqual(self.settings.groupBackgroundTheme, .orange) + XCTAssertEqual(self.settings.themeTokens.surfaceBaseColor, KeyboardVisualizerTheme.white.tokens.surfaceBaseColor) 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 + self.settings.style = .apple - XCTAssertEqual(self.settings.theme, .white) - XCTAssertEqual(self.settings.modifierTheme, .white) + XCTAssertEqual(self.settings.theme, .black) + XCTAssertEqual(self.settings.modifierTheme, .purple) } }