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
30 changes: 21 additions & 9 deletions mac/Config/Config.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -87,7 +103,6 @@
children = (
D87D6F492FAF95400083A95E /* Installation */,
D88F03C82F50ED5000C02A31 /* Config */,
D88F03D62F50ED5100C02A31 /* ConfigTests */,
D88F03E02F50ED5100C02A31 /* ConfigUITests */,
D88F04022F512FE800C02A31 /* Frameworks */,
D88F03C72F50ED5000C02A31 /* Products */,
Expand Down Expand Up @@ -151,9 +166,6 @@
dependencies = (
D88F03D52F50ED5100C02A31 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
D88F03D62F50ED5100C02A31 /* ConfigTests */,
);
name = ConfigTests;
packageProductDependencies = (
);
Expand Down
4 changes: 4 additions & 0 deletions mac/Config/Config/ConfigApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ struct ConfigApp: App {
}
}
}
Window("Main Configuration", id: "main_config") {
MainConfigView()
.environmentObject(settings)
}
Window("Installation", id: "install") {
InstallView()
.environmentObject(installation)
Expand Down
31 changes: 31 additions & 0 deletions mac/Config/Config/IconButtonView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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(.bordered)
.clipShape(.circle)
.accessibilityLabel(helpText)
.help(helpText)

}
}
117 changes: 117 additions & 0 deletions mac/Config/Config/KeyboardInfoView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by Gabriel Schantz on 2026-07-20
*
* The view used for keyboard info
*
* FEAT/MAC/CONFIG-WINDOW TODO: Finish writing file summary
*/

import SwiftUI
import KeymanSettings

public struct KeyboardInfoView: View {
let package: KeymanPackage

/**
* 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)
}

// 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)

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)
}
}
166 changes: 166 additions & 0 deletions mac/Config/Config/MainConfigView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* 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: Finish writing file summary
* FEAT/MAC/CONFIG-WINDOW TODO: Set width and height for window
*/

import SwiftUI
import Combine
import KeymanSettings

struct MainConfigView: View {
@EnvironmentObject var settings: SettingsContainer
// visibilty state for the add package sheet
@State private var isShowingSheet = false
// 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
// used to identify the expanded KeymanPackage id
@State private var expandedPackageID: UUID? = nil

/**
* Sets isShowingDeleteAlert to true and assigns the state variable selectedPackage the KeymanPackage argument
*/
private func showDeleteAlert(for package: KeymanPackage) {
isShowingDeleteAlert = true
selectedPackage = package
}

var body: some View {
VStack(spacing: 0) {
// 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
}

List(settings.singleKeyboardPackages, id: \.id) { package in
ForEach(package.keyboards) { keyboard in
DisclosureGroup(isExpanded: isExpanded(package: package)) {
// the keyboard info view is shown inside each disclosure group
KeyboardInfoView(package: package)
} label: {
// the HStack is shown as the label for each disclosure group
HStack {

Text(keyboard.name)
.font(.title)

// 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))
.labelsHidden()
.toggleStyle(.switch)

// see keyboard help button
IconButtonView(
action: { print("Show help") },
systemImage: "questionmark.circle",
font: .title2,
helpText: "Show help"
)

// delete keyboard button
IconButtonView(
action: { showDeleteAlert(for: package) },
systemImage: "trash",
font: .title2,
helpText: "Delete keyboard"
)
}
.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.")
}
.padding([.leading, .trailing, .bottom])
}
}

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

// 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: { self.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: { self.expandedPackageID = $0 ? 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()
}
)
}

}

#Preview {
var settings = SettingsContainer()
MainConfigView()
.environmentObject(settings)
}