Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions Apps/Keyty/Sources/Keyty/App/Composition/AppSettingsContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,38 @@ import Foundation

final class AppSettingsContainer {
let store: KeyValueStore
let appSettings: any AppSettingsProtocol
let pointerRingSettings: any PointerRingSettingsProtocol & ReactiveSettings
let pointerRipplesSettings: any PointerRipplesSettingsProtocol & ReactiveSettings
let pointerIconSettings: any PointerIconSettingsProtocol & ReactiveSettings
let shortcutSettings: any ShortcutSettingsProtocol
let appSettings: AppSettings
let pointerRingSettings: PointerRingSettings
let pointerRipplesSettings: PointerRipplesSettings
let pointerIconSettings: PointerIconSettings
let shortcutSettings: ShortcutSettings
let keyboardVisualizerSettings: KeyboardVisualizerSettings
private let settingsOwners: [any HasSettingsStore]

init(store: KeyValueStore) {
self.store = store

self.appSettings = AppSettings(store: store)
self.pointerRingSettings = PointerRingSettings(store: store)
self.pointerRipplesSettings = PointerRipplesSettings(store: store)
self.pointerIconSettings = PointerIconSettings(store: store)
self.shortcutSettings = ShortcutSettings(store: store)
self.keyboardVisualizerSettings = KeyboardVisualizerSettings(store: store)
let appSettings = AppSettings(store: store)
let pointerRingSettings = PointerRingSettings(store: store)
let pointerRipplesSettings = PointerRipplesSettings(store: store)
let pointerIconSettings = PointerIconSettings(store: store)
let shortcutSettings = ShortcutSettings(store: store)
let keyboardVisualizerSettings = KeyboardVisualizerSettings(store: store)

self.appSettings = appSettings
self.pointerRingSettings = pointerRingSettings
self.pointerRipplesSettings = pointerRipplesSettings
self.pointerIconSettings = pointerIconSettings
self.shortcutSettings = shortcutSettings
self.keyboardVisualizerSettings = keyboardVisualizerSettings
self.settingsOwners = [
appSettings,
pointerRingSettings,
pointerRipplesSettings,
pointerIconSettings,
shortcutSettings,
keyboardVisualizerSettings
]

self.registerDefaults()
}
Expand All @@ -47,4 +63,8 @@ final class AppSettingsContainer {
self.shortcutSettings.resetToDefaults()
self.keyboardVisualizerSettings.resetToDefaults()
}

var transferableSettings: [AnyStoredSetting] {
self.settingsOwners.flatMap(\.storedSettings)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,21 @@ final class DisplaysSettingsPaneViewModel: ObservableObject {
func finishCustomPositionSetting() {
self.stopCustomPositionSetting()
}

func reloadFromSettings() {
self.screens = self.screensService.screens
self.selectedScreen = self.resolveSelectedScreen(for: self.keyboardVisualizerSettings.screenID)
self.selectedAnchor = self.keyboardVisualizerSettings.anchor
self.placementMode = self.keyboardVisualizerSettings.placementMode
self.windowPadding = Double(self.keyboardVisualizerSettings.windowPadding)
self.customPositionNormalizedX = Double(self.keyboardVisualizerSettings.customPositionNormalizedX)
self.customPositionNormalizedY = Double(self.keyboardVisualizerSettings.customPositionNormalizedY)
self.customHorizontalAlignment = self.keyboardVisualizerSettings.customHorizontalAlignment
self.customVerticalAlignment = self.keyboardVisualizerSettings.customVerticalAlignment
self.stackAxis = self.keyboardVisualizerSettings.stackAxis
self.scale = Double(self.keyboardVisualizerSettings.scale)
self.isSettingCustomPosition = false
}
}

extension DisplaysSettingsPaneViewModel {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@ struct GeneralSettingsPane: View {
init(
shortcutManager: ShortcutManager,
appSettings: any AppSettingsProtocol,
onResetAllSettingsToDefaults: @escaping @MainActor () -> Void
onResetAllSettingsToDefaults: @escaping @MainActor () -> Void,
onExportSettings: @escaping @MainActor () throws -> Void,
onImportSettings: @escaping @MainActor () throws -> Void
) {
_model = StateObject(wrappedValue: GeneralSettingsPaneViewModel(
shortcutManager: shortcutManager,
appSettings: appSettings,
onResetAllSettingsToDefaults: onResetAllSettingsToDefaults
onResetAllSettingsToDefaults: onResetAllSettingsToDefaults,
onExportSettings: onExportSettings,
onImportSettings: onImportSettings
))
}

Expand Down Expand Up @@ -61,6 +65,32 @@ struct GeneralSettingsPane: View {
}

SettingsSectionView(title: L10n.General.settingsSectionTitle) {
SettingsControlRow(
title: L10n.General.exportSettingsTitle,
subtitle: L10n.General.exportSettingsSubtitle
) {
Button(L10n.General.exportSettingsButton) {
self.model.exportSettings()
}
.buttonStyle(.bordered)
.controlSize(.regular)
}

Divider()

SettingsControlRow(
title: L10n.General.importSettingsTitle,
subtitle: L10n.General.importSettingsSubtitle
) {
Button(L10n.General.importSettingsButton) {
self.model.importSettings()
}
.buttonStyle(.bordered)
.controlSize(.regular)
}

Divider()

SettingsControlRow(
title: L10n.General.resetAllSettingsTitle,
subtitle: L10n.General.resetAllSettingsSubtitle
Expand All @@ -83,5 +113,12 @@ struct GeneralSettingsPane: View {
secondaryButton: .cancel(Text(L10n.General.resetAllSettingsCancelButton))
)
}
.alert(item: self.$model.transferErrorAlert) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.message),
dismissButton: .default(Text(L10n.General.resetAllSettingsCancelButton))
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,28 @@ final class GeneralSettingsPaneViewModel: ObservableObject {

private let appSettings: any AppSettingsProtocol
private let onResetAllSettingsToDefaults: @MainActor () -> Void
private let onExportSettings: @MainActor () throws -> Void
private let onImportSettings: @MainActor () throws -> Void

@Published var visibleAtLaunch: Bool {
didSet { self.appSettings.visibleAtLaunch = self.visibleAtLaunch }
}

@Published var shortcutValidationMessage: String?
@Published var transferErrorAlert: TransferErrorAlert?

init(
shortcutManager: ShortcutManager,
appSettings: any AppSettingsProtocol,
onResetAllSettingsToDefaults: @escaping @MainActor () -> Void
onResetAllSettingsToDefaults: @escaping @MainActor () -> Void,
onExportSettings: @escaping @MainActor () throws -> Void,
onImportSettings: @escaping @MainActor () throws -> Void
) {
self.shortcutManager = shortcutManager
self.appSettings = appSettings
self.onResetAllSettingsToDefaults = onResetAllSettingsToDefaults
self.onExportSettings = onExportSettings
self.onImportSettings = onImportSettings

self.visibleAtLaunch = self.appSettings.visibleAtLaunch
self.shortcutValidationMessage = self.shortcutManager.shortcutValidationMessage
Expand All @@ -39,7 +46,48 @@ final class GeneralSettingsPaneViewModel: ObservableObject {
@MainActor
func resetAllSettingsToDefaults() {
self.onResetAllSettingsToDefaults()
self.reloadFromSettings()
}

@MainActor
func exportSettings() {
do {
try self.onExportSettings()
} catch {
self.presentTransferError(error)
}
}

@MainActor
func importSettings() {
do {
try self.onImportSettings()
self.reloadFromSettings()
} catch {
self.presentTransferError(error)
}
}

@MainActor
func reloadFromSettings() {
self.visibleAtLaunch = self.appSettings.visibleAtLaunch
self.shortcutValidationMessage = self.shortcutManager.shortcutValidationMessage
}
}

extension GeneralSettingsPaneViewModel {
struct TransferErrorAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}
}

private extension GeneralSettingsPaneViewModel {
func presentTransferError(_ error: Error) {
self.transferErrorAlert = TransferErrorAlert(
title: L10n.General.settingsTransferErrorTitle,
message: error.localizedDescription
)
}
}
10 changes: 10 additions & 0 deletions Apps/Keyty/Sources/Keyty/Features/Settings/SettingsContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Sparkle
@MainActor
final class SettingsContext {
let settings: AppSettingsContainer
let settingsTransferService: SettingsTransferService
let shortcutManager: ShortcutManager
let pointerRingVisualizer: PointerRingVisualizer
let pointerRipplesVisualizer: PointerRipplesVisualizer
Expand All @@ -34,6 +35,7 @@ final class SettingsContext {
placementCoordinator: any KeyboardVisualizerPlacementCoordinating
) {
self.settings = settings
self.settingsTransferService = SettingsTransferService(settings: settings)
self.shortcutManager = shortcutManager
self.pointerRingVisualizer = pointerRingVisualizer
self.pointerRipplesVisualizer = pointerRipplesVisualizer
Expand All @@ -45,4 +47,12 @@ final class SettingsContext {
func resetAllSettingsToDefaults() {
self.settings.resetAllSettingsToDefaults()
}

func exportSettings(to url: URL) throws {
try self.settingsTransferService.export(to: url)
}

func importSettings(from url: URL) throws {
try self.settingsTransferService.import(from: url)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ struct SettingsPaneRegistry {
let entries: [SettingsPaneEntry]

init(context: SettingsContext, displaysViewModel: DisplaysSettingsPaneViewModel) {
self.init(
context: context,
displaysViewModel: displaysViewModel,
settingsTransferPanelPresenter: .live
)
}

init(
context: SettingsContext,
displaysViewModel: DisplaysSettingsPaneViewModel,
settingsTransferPanelPresenter: SettingsTransferPanelPresenter
) {
self.entries = [
SettingsPaneEntry(
id: .general,
Expand All @@ -33,6 +45,16 @@ struct SettingsPaneRegistry {
appSettings: context.appSettings,
onResetAllSettingsToDefaults: {
context.resetAllSettingsToDefaults()
displaysViewModel.reloadFromSettings()
},
onExportSettings: {
guard let url = settingsTransferPanelPresenter.presentExportPanel() else { return }
try context.exportSettings(to: url)
},
onImportSettings: {
guard let url = settingsTransferPanelPresenter.presentImportPanel() else { return }
try context.importSettings(from: url)
displaysViewModel.reloadFromSettings()
}
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// SettingsTransferPanelPresenter.swift
// Keyty
//
// SPDX-FileCopyrightText: 2026 Serhii Bykov
// SPDX-License-Identifier: BSD-3-Clause
//

import AppKit

@MainActor
struct SettingsTransferPanelPresenter {
let presentExportPanel: () -> URL?
let presentImportPanel: () -> URL?
}

extension SettingsTransferPanelPresenter {
static let live = SettingsTransferPanelPresenter(
presentExportPanel: {
let panel = NSSavePanel()
panel.canCreateDirectories = true
panel.isExtensionHidden = false
panel.allowedFileTypes = [SettingsTransferService.archiveFileExtension]
panel.nameFieldStringValue = SettingsTransferService.defaultArchiveFilename
panel.title = L10n.General.exportSettingsPanelTitle
panel.prompt = L10n.General.exportSettingsButton

guard panel.runModal() == .OK else { return nil }
return panel.url
},
presentImportPanel: {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.allowedFileTypes = [SettingsTransferService.archiveFileExtension]
panel.title = L10n.General.importSettingsPanelTitle
panel.prompt = L10n.General.importSettingsButton

guard panel.runModal() == .OK else { return nil }
return panel.url
}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ extension SettingsWindowController: NSWindowDelegate {
}
}

// MARK: - Window
extension SettingsWindowController {
final class Window: NSWindow {
private let titlebarToolbar = NSToolbar(identifier: "KeytySettingsToolbar")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS
},
write: { store, key, value in
store.set(value.storedValue, forKey: key)
},
import: { _, key, value, defaultValue, apply in
if let intValue = value as? Int {
apply(KeyboardVisualizerStackAxis(storedValue: intValue))
return
}
if let number = value as? NSNumber {
apply(KeyboardVisualizerStackAxis(storedValue: number.intValue))
return
}
throw StoredSettingImportError.invalidValue(key: key, expected: "Int", actual: value)
}
))
var stackAxis: KeyboardVisualizerStackAxis
Expand Down Expand Up @@ -269,6 +280,21 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS
},
write: { store, key, value in
store.set(Int(value), forKey: key)
},
import: { _, key, value, defaultValue, apply in
if let intValue = value as? Int {
apply(CGDirectDisplayID(max(0, intValue)))
return
}
if let number = value as? NSNumber {
apply(CGDirectDisplayID(max(0, number.intValue)))
return
}
if let displayID = value as? CGDirectDisplayID {
apply(displayID)
return
}
apply(defaultValue)
}
))
var screenID: CGDirectDisplayID {
Expand Down
Loading
Loading