Merge branch 'feat/mac/config-ui' into feat/mac/install-window

This commit is contained in:
Shawn Schantz 2026-08-12 21:24:08 -04:00 • committed by GitHub
commit 5d70b5a8cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 498 additions and 9 deletions

View file

@ -33,6 +33,23 @@
D88F03DD2F50ED5100C02A31 /* ConfigUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfigUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
375D21122FF2F21800FCD24A /* Exceptions for "Config" folder in "Config" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ConfigTests/ConfigTests.swift,
);
target = D88F03C52F50ED5000C02A31 /* Config */;
};
375D21132FF2F21800FCD24A /* Exceptions for "Config" folder in "ConfigTests" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ConfigTests/ConfigTests.swift,
);
target = D88F03D22F50ED5100C02A31 /* ConfigTests */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
D87D6F492FAF95400083A95E /* Installation */ = {
isa = PBXFileSystemSynchronizedRootGroup;
@ -41,14 +58,13 @@
};
D88F03C82F50ED5000C02A31 /* Config */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
375D21122FF2F21800FCD24A /* Exceptions for "Config" folder in "Config" target */,
375D21132FF2F21800FCD24A /* Exceptions for "Config" folder in "ConfigTests" target */,
);
path = Config;
sourceTree = "<group>";
};
D88F03D62F50ED5100C02A31 /* ConfigTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = ConfigTests;
sourceTree = "<group>";
};
D88F03E02F50ED5100C02A31 /* ConfigUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = ConfigUITests;
@ -87,7 +103,6 @@
children = (
D87D6F492FAF95400083A95E /* Installation */,
D88F03C82F50ED5000C02A31 /* Config */,
D88F03D62F50ED5100C02A31 /* ConfigTests */,
D88F03E02F50ED5100C02A31 /* ConfigUITests */,
D88F04022F512FE800C02A31 /* Frameworks */,
D88F03C72F50ED5000C02A31 /* Products */,
@ -151,9 +166,6 @@
dependencies = (
D88F03D52F50ED5100C02A31 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
D88F03D62F50ED5100C02A31 /* ConfigTests */,
);
name = ConfigTests;
packageProductDependencies = (
);

View file

@ -37,5 +37,9 @@ struct ConfigApp: App {
}
.windowResizability(.contentSize)
.defaultSize(width: 600, height: 450)
Window("Main Configuration", id: "main_config") {
MainConfigView()
.environmentObject(settings)
}
}
}

View file

@ -0,0 +1,34 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by Gabriel Schantz on 2026-08-03
*
* Webview used to show help for Keyman keyboards
*/
import Foundation
import SwiftUI
import WebKit
import KeymanSettings
public struct HelpView: NSViewRepresentable {
@EnvironmentObject var settings: SettingsContainer
let helpFileURL: URL
// create the AppKit view instance
public func makeNSView(context: Context) -> WKWebView {
let webView = WKWebView()
return webView
}
// update the view when SwiftUI state changes
public func updateNSView(_ nsView: WKWebView, context: Context) {
let request = URLRequest(url: helpFileURL)
// only load the request if it's not already loading/loaded to prevent infinite loops
if nsView.url != helpFileURL {
nsView.load(request)
}
}
}

View file

@ -0,0 +1,46 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by Gabriel Schantz on 2026-07-03
*
* The view used for image-only buttons
*/
import SwiftUI
public struct IconButtonView: View {
let action: () -> Void
let systemImage: String
let font: Font
let helpText: String
public var body: some View {
Button {
action()
} label: {
Image(systemName: systemImage)
.font(font)
}
.buttonStyle(.plain)
.accessibilityLabel(helpText)
.help(helpText)
}
}
// the view for buttons with a label
public struct LabelButtonView: View {
let action: () -> Void
let label: String
let systemImage: String
let font: Font
public var body: some View {
Button(action: action) {
Label(label, systemImage: systemImage)
.font(font)
.buttonStyle(.bordered)
}
}
}

View file

@ -0,0 +1,88 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by Gabriel Schantz on 2026-06-29
*
* Main view used for configuring Keyman
* FEAT/MAC/CONFIG-WINDOW TODO: Set default width and height for window
*/
import SwiftUI
import KeymanSettings
struct MainConfigView: View {
@EnvironmentObject var settings: SettingsContainer
// visibilty state for the add package sheet
@State private var isShowingSheet = false
// used to identify the expanded KeymanPackage id
// both single and multi package views share the same state variable so only a single disclosure group is expanded at once
@State private var expandedPackageID: UUID? = nil
@State private var selectedTab = 0
@State private var packageSelectedForHelpUrl: URL? = nil
/**
* Assigns packageSelectedForHelpUrl the url argument and changes the selected tab to the help tab
*/
public func showHelpTab(for url: URL) {
packageSelectedForHelpUrl = url
selectedTab = 1
}
var body: some View {
TabView (selection: $selectedTab) {
VStack {
// the add keyboard button
LabelButtonView(
action: { isShowingSheet = true },
label: "Add Keyboard",
systemImage: "plus",
font: .title2
)
.clipShape(.capsule)
.padding([.top, .leading, .trailing])
// binds the visibility state to the sheet builder
.sheet(isPresented: $isShowingSheet) {
InstallKeyboardView()
.frame(width: 960, height: 390)
// FEAT/MAC/CONFIG-WINDOW TODO: Make width and height percentages
}
Form {
// the view for single keyboard packages
PackageRowView(packages: settings.singleKeyboardPackages, isSingleKeyboardPackage: true, expandedPackageID: $expandedPackageID, showHelpTab: { url in
showHelpTab(for: url)})
// the view for multi keyboard packages
PackageRowView(packages: settings.multiKeyboardPackages, isSingleKeyboardPackage: false, expandedPackageID: $expandedPackageID, showHelpTab: { url in
showHelpTab(for: url) })
}
.formStyle(.grouped)
// the Spacer pushes the contents of the VStack to the top of the VStack
Spacer()
}
.padding([.leading, .trailing, .bottom])
.tabItem { Text("Keyboards") }
.tag(0)
if let url = packageSelectedForHelpUrl {
HelpView(helpFileURL: url)
.padding()
.tabItem { Text("Help") }
.tag(1)
} else {
Text("Help not available.")
.font(.title)
.tabItem { Text("Help") }
.tag(1)
}
}
}
}
#Preview {
let settings = SettingsContainer()
MainConfigView()
.environmentObject(settings)
}

View file

@ -0,0 +1,128 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by Gabriel Schantz on 2026-07-20
*
* The view used to display package info inside the disclosure group opened from PackageRowView
*/
import SwiftUI
import KeymanSettings
public struct PackageInfoView: View {
let package: KeymanPackage
// closure passed from the parent view
let showAlertFunction: (KeymanPackage) -> Void
/**
* Copies the text argument to the system clipboard
*/
private func copyTextToClipboard (text: String) -> Void {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
public var body: some View {
HStack (alignment: .top) {
// the custom package image
if let packageImage = package.graphicImage {
Image(nsImage: packageImage)
.resizable()
.frame(maxWidth: 84, maxHeight: 150)
}
VStack (alignment: .leading) {
// the text-based package properties presented in a grid
Grid(horizontalSpacing: 10, verticalSpacing: 5) {
// the package version
GridRow {
Text("Package Version:").bold()
.gridColumnAlignment(.trailing) // all elements underneath inherit the .trailing alignment
Text(package.packageVersion)
.gridColumnAlignment(.leading) // all elements underneath inherit the .leading alignment
}
// the fonts
GridRow {
Text("Fonts:").bold()
HStack {
ForEach(package.fonts, id: \.self) { font in
Text(font)
}
}
}
// the copyright
GridRow {
Text("Copyright:").bold()
Text(package.copyright ?? "")
}
// the author
GridRow {
Text("Author:").bold()
Text(package.author ?? "")
}
// the website
GridRow {
Text("Website:").bold()
if let websiteUrl = package.websiteUrl {
Link(destination: websiteUrl) {
Text(websiteUrl.absoluteString)
.underline()
.multilineTextAlignment(.leading)
}
}
}
}
.padding(5)
// uninstall button
Button {
showAlertFunction(package)
} label: {
Text("Remove Package")
}
.padding(.horizontal)
}
Spacer()
// the package QR Code and link to share the package online
VStack {
let size: CGFloat = 106
if let qrCode = package.generateSharePackageQRCode(size: size) {
// the package QR Code
Image(nsImage: qrCode)
.interpolation(.none) // important: ensures the edges of the QR Code remain sharp
.resizable()
.frame(width: size, height: size)
.background(Color.white) // ensures good contrast for scanning
}
if let sharePackageUrl = package.sharePackageUrl {
HStack {
// the link to share the package online
Link(destination: sharePackageUrl) {
Text("Share Keyboard")
.underline()
}
// the button to copy the link to share the package online
IconButtonView(action: { copyTextToClipboard(text: sharePackageUrl.absoluteString) }, systemImage: "doc.on.doc", font: .body , helpText: "Copy link")
}
}
}
.padding(5)
.border(Color.black, width: 1)
}
.frame(height: 150)
}
}

View file

@ -0,0 +1,177 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by Gabriel Schantz on 2026-07-27
*
* Displays an array of Keyman Packages as disclosure groups
* If the argument isSingleKeyboardPackage is true, displays information in a format suited to a single keyboard,
* otherwise displays information in a format suited to a multi-keyboard package
*/
import SwiftUI
import Combine
import KeymanSettings
public struct PackageRowView: View {
@EnvironmentObject var settings: SettingsContainer
// visibilty state for the delete package alert
@State private var isShowingDeleteAlert = false
// used to identify the selected KeymanPackage for the delete package alert
@State private var selectedPackage: KeymanPackage? = nil
// settings.singleKeyboardPackages or settings.multiKeyboardPackages
let packages: [KeymanPackage]
// a boolean for whether or not a package contains multiple keyboards
let isSingleKeyboardPackage: Bool
// binded to the shared state variable in the parent view
@Binding var expandedPackageID: UUID?
// closure passed from the parent view
let showHelpTab: (URL) -> Void
init(packages: [KeymanPackage], isSingleKeyboardPackage: Bool, expandedPackageID: Binding<UUID?>, showHelpTab: @escaping (URL) -> Void) {
self.packages = packages
self.isSingleKeyboardPackage = isSingleKeyboardPackage
self._expandedPackageID = expandedPackageID
self.showHelpTab = showHelpTab
}
/**
* Sets isShowingDeleteAlert to true and assigns the state variable selectedPackage the KeymanPackage argument
*/
public func showDeleteAlert(for package: KeymanPackage) {
isShowingDeleteAlert = true
selectedPackage = package
}
public var body: some View {
ForEach(packages, id: \.id) { package in
ForEach(isSingleKeyboardPackage ? package.keyboards : package.keyboards.onlyFirst) { keyboard in
DisclosureGroup(isExpanded: isExpanded(package: package)) {
// the package info view is shown inside each disclosure group
if expandedPackageID == package.id {
PackageInfoView(package: package, showAlertFunction: { package in
showDeleteAlert(for: package)
})
.transition(.move(edge: .top))
}
} label: {
// a VStack is shown as the label for each disclosure group
VStack (alignment: .leading, spacing: 0) {
HStack {
// if the package contains one keyboard, show the keyboard name, otherwise show the package name
Text(isSingleKeyboardPackage ? keyboard.name: package.packageName)
.font(.title)
// see keyboard help button
if let url = package.helpFileUrl {
IconButtonView(
action: { showHelpTab(url) },
systemImage: "questionmark.circle",
font: .title2,
helpText: "Show keyboard help"
)
}
// the Spacer pushes the contents of the HStack to the either edge
Spacer()
// if the package contains one keyboard shows the toggle button for the keyboard
if isSingleKeyboardPackage {
Toggle("enabled", isOn: isEnabled(packageId: package.id, keyboardKey: keyboard.keyboardKey))
.controlSize(.mini)
.labelsHidden()
.toggleStyle(.switch)
.gridColumnAlignment(.leading)
}
}
// if the package contains multiple keyboards shows an HStack with the keyboard name and toggle button for each keyboard in the package
if !isSingleKeyboardPackage {
ForEach (package.keyboards) { keyboard in
HStack {
Text(keyboard.name)
.font(.title2)
.foregroundStyle(.primary)
.gridColumnAlignment(.leading)
// the Spacer pushes the other views inside the HStack to the opposite edge
Spacer()
// the toggle button for the keyboard
Toggle("enabled", isOn: isEnabled(packageId: package.id, keyboardKey: keyboard.keyboardKey))
.controlSize(.mini)
.labelsHidden()
.toggleStyle(.switch)
.gridColumnAlignment(.leading)
}
}
}
}
.contentShape(Rectangle())
// handles when the HStack is clicked by the user
.onTapGesture {
withAnimation () {
if self.expandedPackageID == package.id {
self.expandedPackageID = nil
} else {
self.expandedPackageID = package.id
}
}
}
}
}
}
// binds the visibilty state to the alert builder
.alert("Are you sure you want to delete the keyboard \"\(selectedPackage?.packageName ?? "")\"?",
isPresented: $isShowingDeleteAlert,
presenting: selectedPackage) { package in
// cancel button
Button("Cancel", role: .cancel) { }
// delete button
Button("Delete", role: .destructive) {
settings.removeInstalledPackage(with: package.id)
}
} message: { package in
Text("You can't undo this action.")
}
}
// the helper method to generate the custom binding for whether a package's disclosure group is expanded or not
func isExpanded(package: KeymanPackage) -> Binding<Bool> {
Binding(
// the getter renders the position of the disclosure group
get: { expandedPackageID == package.id },
// setter handles when the chevron arrow is clicked by the user
// $0 = true when disclosure group is open and $0 = false when disclosure group is closed
set: { isExpanded in
withAnimation {
expandedPackageID = isExpanded ? package.id : nil }
}
)
}
// the helper method to generate the custom binding for whether a keyboard is enabled or not
func isEnabled(packageId: UUID, keyboardKey: String) -> Binding<Bool> {
Binding(
// the getter renders the state of the toggle button based on the enabled property of the keyboard
get: { settings.isKeyboardEnabled(packageId: packageId, keyboardKey: keyboardKey) },
// the setter handles when the toggle button is clicked by the user
// $0 = true when the toggle button is on and $0 = false when the toggle button is off
set: {
settings.setKeyboardEnabled(packageId: packageId, keyboardKey: keyboardKey, enabled: $0)
settings.objectWillChange.send()
}
)
}
}
extension Collection {
// returns the first element of an array in an array or returns an empty array
var onlyFirst: [Element] {
guard let first = self.first else { return [] }
return [first]
}
}