diff --git a/mac/Config/Config.xcodeproj/project.pbxproj b/mac/Config/Config.xcodeproj/project.pbxproj index 56b4e676045..691e04b9ebe 100644 --- a/mac/Config/Config.xcodeproj/project.pbxproj +++ b/mac/Config/Config.xcodeproj/project.pbxproj @@ -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 = ""; }; - D88F03D62F50ED5100C02A31 /* ConfigTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = ConfigTests; - sourceTree = ""; - }; 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 = ( ); diff --git a/mac/Config/Config/ConfigApp.swift b/mac/Config/Config/ConfigApp.swift index e8ed3eb54fb..6c6e6c9f208 100644 --- a/mac/Config/Config/ConfigApp.swift +++ b/mac/Config/Config/ConfigApp.swift @@ -25,6 +25,10 @@ struct ConfigApp: App { } } } + Window("Main Configuration", id: "main_config") { + MainConfigView() + .environmentObject(settings) + } Window("Installation", id: "install") { InstallView() .environmentObject(installation) diff --git a/mac/Config/ConfigTests/ConfigTests.swift b/mac/Config/Config/ConfigTests/ConfigTests.swift similarity index 100% rename from mac/Config/ConfigTests/ConfigTests.swift rename to mac/Config/Config/ConfigTests/ConfigTests.swift diff --git a/mac/Config/Config/IconButtonView.swift b/mac/Config/Config/IconButtonView.swift new file mode 100644 index 00000000000..6551370c132 --- /dev/null +++ b/mac/Config/Config/IconButtonView.swift @@ -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) + + } +} diff --git a/mac/Config/Config/KeyboardInfoView.swift b/mac/Config/Config/KeyboardInfoView.swift new file mode 100644 index 00000000000..e8979c40e3c --- /dev/null +++ b/mac/Config/Config/KeyboardInfoView.swift @@ -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) + } +} diff --git a/mac/Config/Config/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift new file mode 100644 index 00000000000..866eaacedda --- /dev/null +++ b/mac/Config/Config/MainConfigView.swift @@ -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 { + 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 { + 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) +}