diff --git a/mac/Config/Config/ConfigView.swift b/mac/Config/Config/ConfigView.swift index 575ceb732c..64ee8f460f 100644 --- a/mac/Config/Config/ConfigView.swift +++ b/mac/Config/Config/ConfigView.swift @@ -11,17 +11,61 @@ import KeymanSettings struct ConfigView: View { @EnvironmentObject var settings: SettingsContainer + // selectedItem cannot be used with .enumerated + //@State private var selectedItem: KeymanPackage? var body: some View { VStack { - Image(systemName: "keyboard") - .imageScale(.large) - .foregroundColor(.accentColor) - Text(settings.settingName) - Text("keyboard count = \(settings.keyboardPackages.count)") - Button("Update settings") {settings.settingName = "Settings changed!"} - Button("Remove last keyboard") {if !settings.keyboardPackages.isEmpty { - settings.keyboardPackages.removeLast()} + HStack { + Image(systemName: "keyboard") + .imageScale(.large) + .foregroundColor(.accentColor) + Text("keyboard count = \(settings.installedKeyboardPackages.count)") + Button("debug") { + settings.debug() + } + Spacer() + } + .padding() + +// ScrollView(showsIndicators: false) { +// List { +// // Use $ to get binding to the published array +// ForEach($settings.installedKeyboardPackages) { $package in +// Section(header: Text("Section")) { +// // Use $ to get binding to the nested array +// ForEach($package.keyboards) { $keyboard in +// Toggle(keyboard.keyboardId, isOn: $keyboard.enabled) // Binding here +// } +// } +// } +// } +// .padding() +// } + ScrollView { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(settings.installedKeyboardPackages.enumerated()), id: \.offset) { index, package in + VStack { + HStack(alignment: .center, spacing: 10) { + Text(package.packageName) + .font(.headline) + Text(package.packageVersion) + .font(.subheadline) + // Example of Icon-Only Button + Spacer() + Button(action: { + settings.installedKeyboardPackages.remove(at: index) + }) { + Label("remove", systemImage: "trash.fill") + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } + KeyboardListView(packageId: package.id, keyboards: package.keyboards) + } + } + } + .padding(.trailing, 25) // allow space for scroll bar } } .padding() @@ -29,7 +73,7 @@ struct ConfigView: View { } #Preview { - let configuration = Configuration() + let settings = SettingsContainer() ConfigView() - .environmentObject(configuration) + .environmentObject(settings) } diff --git a/mac/Config/Config/KeyboardListView.swift b/mac/Config/Config/KeyboardListView.swift new file mode 100644 index 0000000000..2a74130342 --- /dev/null +++ b/mac/Config/Config/KeyboardListView.swift @@ -0,0 +1,34 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-04-02 + * + * Subview to display list of keyboards for a package + */ + +import SwiftUI +import KeymanSettings +import Combine + +struct KeyboardListView: View { + @EnvironmentObject var settings: SettingsContainer + @State var packageId: UUID + @State var keyboards: [Keyboard] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach($keyboards) { $keyboard in + HStack { + Toggle("", isOn: Binding( + get: { settings.isKeyboardEnabled(packageId: packageId, keyboardId: keyboard.keyboardId) }, + set: { newValue in settings.setKeyboardEnabled(packageId: packageId, keyboardId: keyboard.keyboardId, enabled: newValue) + settings.objectWillChange.send() } + )) + Text(keyboard.keyboardId) + .padding(.leading, 5) + Spacer() + } + } + } + } +} diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 9f4227068a..a273153ac8 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -1,25 +1,89 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-04-02 + * + * Class that exposes all settings information to the Config app + * Provides a place for the Config app can bind directly to the settings + * and update when changes are made + * + */ + +import Foundation import Combine public class SettingsContainer : ObservableObject { - - @Published public var settingName: String - @Published public var keyboardPackages: [KeymanPackage] - @Published public var activeKeyboards: Set - @Published public var currentKeyboard: String - - fileprivate let dataRepository: PackageRepository + // keyboard packages are defined on disk + @Published public var installedKeyboardPackages: [KeymanPackage] + + fileprivate let packageRepository: PackageRepository fileprivate let settingsRepository: SettingsRepository - + + // active keyboards and selected keyboard are stored in the UserDefaults + fileprivate var activeKeyboards: Set + fileprivate var currentKeyboard: String + public init() { - settingName = "uninitialized" - self.dataRepository = PackageRepository() - self.settingsRepository = SettingsRepository(suiteName: KeymanPaths.groupId) + self.packageRepository = PackageRepository() + + do { + try self.settingsRepository = SettingsRepository(suiteName: KeymanPaths.groupId) + print("Found group container") + } catch UserDefaultsError.unknownSuite { + fatalError("Group container not found.") + } catch { + fatalError("Unable to access settings in group container.") + } self.activeKeyboards = self.settingsRepository.readActiveKeyboards() self.currentKeyboard = self.settingsRepository.readSelectedKeyboard() - self.keyboardPackages = [] + self.installedKeyboardPackages = [] + self.loadPackages() + self.synchronizeSettings() + self.applySettings() + } + + // TODO: delete test code + public func debug() { + self.installedKeyboardPackages .forEach { package in + package.keyboards.forEach { keyboard in + print("\(keyboard.keyboardId) enabled: \(keyboard.enabled)") + } + } + } + + // TODO: extract method for keyboard search + // TODO: throw error for keyboard not found or log and fail silently (should never happen) + public func isKeyboardEnabled(packageId: UUID, keyboardId: String) -> Bool { + var enabled = false; + guard let package = self.installedKeyboardPackages.first(where: { $0.id == packageId }) else { + print ("ERROR: setKeyboardEnabled could not find package with ID: \(packageId)") + return enabled + } + + enabled = package.isKeyboardEnabled(keyboardId: keyboardId) + print ("isEnabled for \(keyboardId) returning with \(enabled)") + + return enabled + } + + public func setKeyboardEnabled(packageId: UUID, keyboardId: String, enabled: Bool) { + let package = self.installedKeyboardPackages.first(where: { $0.id == packageId }) + if (package == nil) { + print ("ERROR: setKeyboardEnabled could not find package with ID: \(packageId)") + } else { + package!.enableKeyboard(keyboardId: keyboardId, enabled: enabled) + print ("setKeyboardEnabled for \(keyboardId) setting to \(enabled)") + } + } + + /** + * read the Keyman packages from the group container directory and store in the keyboardPackages array + */ + func loadPackages() { + // TODO: create keyboards directory if it doesn't exist // if let keyboardsUrl = self.pathUtil.keymanKeyboardsDirectory { // // if FileManager.default.fileExists(atPath: keyboardsUrl.path) { @@ -28,21 +92,50 @@ public class SettingsContainer : ObservableObject { // print("non-existent directory: \(keyboardsUrl.absoluteString)") // } // } - - // load keyboards from disk - if (self.dataRepository.keyman19SharedDataDirectoryExists()) { - // TODO: remove test code - self.dataRepository.writeSomethingToContainer() - let packageSourceArray = self.dataRepository.readKeymanPackagesForKeyman19() + // load keyboards from disk + if (self.packageRepository.keyman19SharedDataDirectoryExists()) { + // TODO: remove test code + self.packageRepository.writeSomethingToContainer() - // create a KeymanPackage object for each PackageSource object and insert it in the array - for source in packageSourceArray { - let package = KeymanPackage(packageSource: source) - self.keyboardPackages.append(package) - } + self.installedKeyboardPackages = self.packageRepository.loadPackages() } else { - self.dataRepository.createKeyman19SharedDataDirectories() + self.packageRepository.createKeyman19SharedDataDirectories() } } + + /** + * returns set containing the keyboards settings keys for the keyboards of every installed package + */ + func getAllKeyboardSettingsKeys() -> Set { + var settingsKeys = Set() + + // loop through all the installed packages and for each of the package's keyboards, + // insert the settings key for the keyboard + self.installedKeyboardPackages.forEach { $0.keyboards.forEach + {settingsKeys.insert($0.keyboardSettingsKey)} + } + + return settingsKeys + } + + /** + * remove any settings (UserDefaults) for which we have no installed package + */ + func synchronizeSettings() { + let installedPackageKeys = self.getAllKeyboardSettingsKeys() + let activeKeyboardKeys = self.settingsRepository.readActiveKeyboards() + let commonKeyboardKeys = installedPackageKeys.intersection(activeKeyboardKeys) + + // TODO: replace the active keyboards list with the intersection of the current + // active keyboards list and the installed packages list + } + + /** + * apply the current settings to the installed packages + */ + func applySettings() { + + } + } diff --git a/mac/KeymanSettings/Sources/Model/Keyboard.swift b/mac/KeymanSettings/Sources/Model/Keyboard.swift index 593d8a7e5a..17634f97da 100644 --- a/mac/KeymanSettings/Sources/Model/Keyboard.swift +++ b/mac/KeymanSettings/Sources/Model/Keyboard.swift @@ -3,8 +3,8 @@ * * Created by Shawn Schantz on 2026-02-24 * - * Wrapper bject that represents a Keyman keyboard as loaded from a .KMP file - * Can be loaded but disabled + * Wrapper object that represents a Keyman keyboard as loaded from a .KMP file + * Mostly immutable, but can be marked as enabled/disabled * */ @@ -12,19 +12,86 @@ import Foundation import AppKit -public struct Keyboard: Identifiable, Hashable, Equatable { +public class Keyboard: Identifiable, Hashable, Equatable { public var id = UUID() public var enabled: Bool public var name: String public var keyboardId: String - - public init(keyboardSource: KeyboardSource) { + // the directory we are reading the keyboard from + public var keyboardDirectoryUrl: URL + // the URL of the .kmx file for the package + public let kmxFileUrl: URL + // the UserDefaults key for this package, used for the selected Keyboard and active keyboards + // the key is in the form "/[package directory]/[package name].kmx" + // for example, "/khmer_angkor/khmer_angkor.kmx" + public let keyboardSettingsKey: String + + public init(keyboardSource: KeyboardSource, directoryUrl: URL) { self.enabled = true self.name = keyboardSource.name self.keyboardId = keyboardSource.id - + self.keyboardDirectoryUrl = directoryUrl + self.kmxFileUrl = Keyboard.deriveKmxFileUrl(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) + self.keyboardSettingsKey = Keyboard.deriveKeyboardSettingsKey(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) + // ConfigLogger.shared.testLogger.debug("keyboard created for: \(keyboardSource.id)") - print("keyboard created for: \(keyboardSource.id)") + print("keyboard created for: \(keyboardSource.id) \r with kmxFileUrl: \(self.kmxFileUrl) \r and settingsKey: \(self.keyboardSettingsKey)") } + + public static func == (lhs: Keyboard, rhs: Keyboard) -> Bool { + return lhs.keyboardId == rhs.keyboardId && lhs.enabled == rhs.enabled + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(keyboardId) + } + + static func deriveKmxFileUrl(from keyboardDirectory: URL, keyboardId: String) -> URL { + return keyboardDirectory.appendingPathComponent("\(keyboardId).kmx") + } + + static func deriveKeyboardSettingsKey(from keyboardDirectory: URL, keyboardId: String) -> String { + // get parent directory + let parentDirectoryName = keyboardDirectory.lastPathComponent + + // get filename from keyboardId + let kmxFilename = "\(keyboardId).kmx" + + let settingsKey = "/\(parentDirectoryName)/\(kmxFilename)" + print("settingsKey: \(settingsKey)") + + return settingsKey + } + + // TODO: throw/handle error if no kmx file found in directory + public func validateKmxFile() -> Bool { + let fileManager = FileManager.default + if fileManager.fileExists(atPath: self.kmxFileUrl.path) { + return true + } else { + print(" *** Error: could not find kmx file \(self.kmxFileUrl.path)") + return false + } + } + + public func findKmxFile(in keyboardDirectoryUrl: URL) -> URL? { + var kmxFileUrl: URL? = nil + let fileManager = FileManager.default + + do { + let fileURLs = try fileManager.contentsOfDirectory(at: keyboardDirectoryUrl, includingPropertiesForKeys: nil) + let filteredFiles = fileURLs.filter { $0.pathExtension == "kmx" } + + if (!filteredFiles.isEmpty) { + kmxFileUrl = filteredFiles.first! + } + } catch { + print("Error reading directory: \(error)") + } + + return kmxFileUrl; + } + + } diff --git a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift index b9c0c7feb6..624564b108 100644 --- a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift +++ b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift @@ -4,7 +4,7 @@ * Created by Shawn Schantz on 2026-02-24 * * Wrapper object that represents a Keyman package as loaded from a .KMP file - * Can be loaded but disabled + * Mostly immutable, but can be marked as enabled/disabled * */ @@ -12,7 +12,7 @@ import Foundation import AppKit // MARK: - Root -public struct KeymanPackage: Identifiable, Hashable, Equatable { +public class KeymanPackage: Identifiable, Hashable, Equatable { static let defaultImage: NSImage? = { var image: NSImage? = nil if let imageUrl = Bundle.main.url(forResource: "SideImage", withExtension: "bmp") { @@ -22,11 +22,12 @@ public struct KeymanPackage: Identifiable, Hashable, Equatable { }() public var id = UUID() - public var enabled: Bool let source: PackageSource - public let keyboards: [Keyboard] + public var keyboards: [Keyboard] + // the URL of the directory in which the package is contained public let sourceDirectoryUrl: URL + // the URL of the kmp.json file for the package public let jsonFileUrl: URL public let readmeFileUrl: URL? public let graphicFileUrl: URL? @@ -48,7 +49,6 @@ public struct KeymanPackage: Identifiable, Hashable, Equatable { } init(packageSource: PackageSource) { - self.enabled = true self.source = packageSource self.sourceDirectoryUrl = packageSource.directoryUrl! self.jsonFileUrl = packageSource.jsonFileUrl! @@ -67,7 +67,7 @@ public struct KeymanPackage: Identifiable, Hashable, Equatable { if let keyboards = source.keyboards { for keyboardSource in keyboards { - let keyboard = Keyboard(keyboardSource: keyboardSource) + let keyboard = Keyboard(keyboardSource: keyboardSource, directoryUrl: self.sourceDirectoryUrl) keyboardsArray.append(keyboard) } } @@ -77,6 +77,34 @@ public struct KeymanPackage: Identifiable, Hashable, Equatable { print("package created for: \(packageSource.packageName)") } + public func isKeyboardEnabled(keyboardId: String) -> Bool { + var enabled = false + if let keyboard = self.keyboards.first(where: { $0.keyboardId == keyboardId }) { + enabled = keyboard.enabled + } + + return enabled + } + + public func enableKeyboard(keyboardId: String, enabled: Bool) { + let keyboard = self.keyboards.first(where: { $0.keyboardId == keyboardId }) + if (keyboard != nil) { + keyboard!.enabled = enabled + } + } + + public func validate() -> Bool { + var validKeyboards = self.keyboards.isEmpty == false + + self.keyboards.forEach { keyboard in + if (validKeyboards && !keyboard.validateKmxFile()) { + validKeyboards = false + } + } + + return validKeyboards + } + static func buildGraphicFileUrl(source: PackageSource) -> URL? { var fileUrl: URL? = nil diff --git a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift index 16e7fa65ae..e2515f349d 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift @@ -18,6 +18,23 @@ public struct PackageRepository { self.pathUtil = KeymanPaths() } + func loadPackages() -> [KeymanPackage] { + var installedPackages: [KeymanPackage] = [] + let packageSourceArray = self.readKeymanPackagesForKeyman19() + + // create a KeymanPackage object for each PackageSource object and insert it in the array if it is valid + for source in packageSourceArray { + let package = KeymanPackage(packageSource: source) + if (package.validate()) { + installedPackages.append(package) + } else { + print("package '\(source.packageName)' is not valid") + } + } + + return installedPackages + } + /** * Creates the directory tree where keyboards are stored under the standard 'Group Containers' directory */ @@ -44,6 +61,9 @@ public struct PackageRepository { } } + /** + * for group container testing purposes to check directory access + */ public func writeSomethingToContainer() { if let keyboardDirectory = self.pathUtil.keyman19KeyboardsDirectory { print("About to write to: \(keyboardDirectory.path)") @@ -74,6 +94,9 @@ public struct PackageRepository { return self.directoryExistsAtPath(directoryUrl: keyboardsUrl) } + /** + * returns true if a directory exists at the specified URL + */ func directoryExistsAtPath(directoryUrl: URL) -> Bool { var isDirectory: ObjCBool = false let exists = FileManager.default.fileExists(atPath: directoryUrl.path, isDirectory: &isDirectory) @@ -110,7 +133,7 @@ public struct PackageRepository { ) for itemUrl in directoryContents { - // if the item is a directory, then attempt to read it as a keyboard + // if the item is a directory, then attempt to read it as a keyboard package if (itemUrl.hasDirectoryPath) { if let packageSource = readKeyboardPackageFromDirectory(keyboardDirectoryUrl: itemUrl) { packages.append(packageSource) @@ -124,27 +147,27 @@ public struct PackageRepository { print("\(packages.count) packages read") return packages } - + func readKeyboardPackageFromDirectory(keyboardDirectoryUrl: URL) -> PackageSource? { var packageSource: PackageSource? - let lastPathComponent = keyboardDirectoryUrl.lastPathComponent let kmpFileUrl = keyboardDirectoryUrl.appendingPathComponent(packageFileName) + if FileManager.default.fileExists(atPath: kmpFileUrl.path) { if let source = readKeyboardPackage(kmpFileUrl) { packageSource = source // save the keyboard directory and path of the kmp.json for this keyboard packageSource?.directoryUrl = keyboardDirectoryUrl packageSource?.jsonFileUrl = kmpFileUrl - } + } } else { -// ConfigLogger.shared.testLogger.debug(" ** \(lastPathComponent) DOES NOT contain the file 'kmp.json')") + // ConfigLogger.shared.testLogger.debug(" ** \(lastPathComponent) DOES NOT contain the file 'kmp.json')") print(" ** \(lastPathComponent) DOES NOT contain the file 'kmp.json')") } return packageSource } - + public func readKeyboardPackage(_ kmpFileUrl: URL) -> PackageSource? { var packageSource: PackageSource? do { diff --git a/mac/KeymanSettings/Sources/Persistence/SettingsRepository.swift b/mac/KeymanSettings/Sources/Persistence/SettingsRepository.swift index 817016edfc..58e6a7c64a 100644 --- a/mac/KeymanSettings/Sources/Persistence/SettingsRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/SettingsRepository.swift @@ -8,9 +8,12 @@ * */ - import Foundation +public enum UserDefaultsError: Error { + case unknownSuite +} + public struct SettingsRepository { fileprivate let pathUtil: KeymanPaths let defaultsSuiteName: String @@ -19,7 +22,8 @@ public struct SettingsRepository { let kActiveKeyboardsKey = "KMActiveKeyboardsKey" let kSelectedKeyboardKey = "KMSelectedKeyboardKey" let kPersistedOptionsKey = "KMPersistedOptionsKey" - let kShowOskOnActivate = "KMShowOskOnActivate" + let kDataModelVersionKey = "KMDataModelVersionKey" + let kShowOskOnActivateKey = "KMShowOskOnActivate" let kForceSentryError = "KMForceSentryError" // public init() { @@ -27,10 +31,16 @@ public struct SettingsRepository { // self.defaultsSuiteName = KeymanPaths.groupId // } // - public init(suiteName: String) { + public init(suiteName: String) throws(Error) { self.pathUtil = KeymanPaths() self.defaultsSuiteName = suiteName - self.defaults = UserDefaults(suiteName: suiteName) + + let userDefaults = UserDefaults(suiteName: suiteName) + if userDefaults == nil { + throw(UserDefaultsError.unknownSuite) + } else { + self.defaults = userDefaults + } } public func readActiveKeyboards() -> Set { @@ -46,13 +56,16 @@ public struct SettingsRepository { } public func readSelectedKeyboard() -> String { - guard let sharedDefaults = self.defaults else { - print("Group container UserDefaults not found.") + if let sharedDefaults = self.defaults { + return sharedDefaults.value(forKey: kSelectedKeyboardKey) as? String ?? "" + } else { return "" } - - let selectedKeyboard = sharedDefaults.value(forKey: kSelectedKeyboardKey) as? String ?? "" - - return selectedKeyboard + } + + public func writeSelectedKeyboard(keyboardName: String) { + if let sharedDefaults = self.defaults { + sharedDefaults.set(keyboardName, forKey: kSelectedKeyboardKey) + } } } diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift index 363550f4bf..768635a9ae 100644 --- a/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift +++ b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift @@ -50,8 +50,36 @@ import Foundation } } +// defaults read test.suite.name -@Suite("Load KMP data") struct KeymanDataRepositoryTests { +@Suite("Settings check") struct KeymanSettingsRepositoryTests { + var settingsRepo: SettingsRepository + + fileprivate init() async throws { + print("init Settings") + do { + try self.settingsRepo = SettingsRepository(suiteName: "test.suite.name") + print("Found group container") + } catch UserDefaultsError.unknownSuite { + fatalError("Group container not found.") + } catch { + fatalError("Unable to access settings in group container.") + } + + self.settingsRepo.writeSelectedKeyboard(keyboardName: "keyboardtest") + } + + @Test("Write selected keyboard") func writeSelectedKeyboard() async throws { + settingsRepo.writeSelectedKeyboard(keyboardName: "testing") + #expect(settingsRepo.readSelectedKeyboard() == "testing") + } + + @Test("Read selected keyboard") func readSelectedKeyboard() async throws { + #expect(settingsRepo.readSelectedKeyboard() == "keyboardtest") + } +} + +@Suite("Load KMP data") struct KeymanPackageRepositoryTests { var testKmpUrl: URL? = nil fileprivate init() async throws { @@ -62,11 +90,6 @@ import Foundation return Bundle.module.url(forResource: "amharic.kmp", withExtension: "json") } - @Test("Find amharic .kmp file") func locateKmpTestData() async throws { - let kmpUrl = try #require(self.getAmharicKmpUrl()) - #expect(kmpUrl.isFileURL) - } - @Test("Read package") func loadPackageSource() async throws { let dataRepo = PackageRepository()