feat(mac): display packages and keyboard list view
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled

added buttons for removal of packages
and toggle for disabling of keyboard
not remotely final but mostly for ensuring that
we can bind to the data model and respond to updates
This commit is contained in:
Shawn Schantz 2026-04-02 14:01:11 -04:00
parent 45ac789c95
commit bb816a1335
8 changed files with 394 additions and 69 deletions

View file

@ -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)
}

View file

@ -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()
}
}
}
}
}

View file

@ -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<String>
@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<String>
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<String> {
var settingsKeys = Set<String>()
// 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() {
}
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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<String> {
@ -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)
}
}
}

View file

@ -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()