diff --git a/mac/Config/Config.xcodeproj/project.pbxproj b/mac/Config/Config.xcodeproj/project.pbxproj index 7e9875797a..3b8bb555a0 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 65f7260b83..6305a14210 100644 --- a/mac/Config/Config/ConfigApp.swift +++ b/mac/Config/Config/ConfigApp.swift @@ -37,5 +37,9 @@ struct ConfigApp: App { } .windowResizability(.contentSize) .defaultSize(width: 600, height: 450) + Window("Main Configuration", id: "main_config") { + MainConfigView() + .environmentObject(settings) + } } } 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/HelpView.swift b/mac/Config/Config/HelpView.swift new file mode 100644 index 0000000000..f30e46014c --- /dev/null +++ b/mac/Config/Config/HelpView.swift @@ -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) + } + } +} diff --git a/mac/Config/Config/IconButtonView.swift b/mac/Config/Config/IconButtonView.swift new file mode 100644 index 0000000000..4b42c731f6 --- /dev/null +++ b/mac/Config/Config/IconButtonView.swift @@ -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) + } + } +} diff --git a/mac/Config/Config/MainConfigView.swift b/mac/Config/Config/MainConfigView.swift new file mode 100644 index 0000000000..2a24491853 --- /dev/null +++ b/mac/Config/Config/MainConfigView.swift @@ -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) +} diff --git a/mac/Config/Config/PackageInfoView.swift b/mac/Config/Config/PackageInfoView.swift new file mode 100644 index 0000000000..413fc2abd7 --- /dev/null +++ b/mac/Config/Config/PackageInfoView.swift @@ -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) + } +} diff --git a/mac/Config/Config/PackageRowView.swift b/mac/Config/Config/PackageRowView.swift new file mode 100644 index 0000000000..b607412636 --- /dev/null +++ b/mac/Config/Config/PackageRowView.swift @@ -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, 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 { + 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 { + 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] + } +}