mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-26 17:47:40 +00:00
Merge branch 'feat/mac/config-ui' into feat/mac/install-window
This commit is contained in:
commit
5d70b5a8cd
8 changed files with 498 additions and 9 deletions
|
|
@ -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 = (
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,5 +37,9 @@ struct ConfigApp: App {
|
|||
}
|
||||
.windowResizability(.contentSize)
|
||||
.defaultSize(width: 600, height: 450)
|
||||
Window("Main Configuration", id: "main_config") {
|
||||
MainConfigView()
|
||||
.environmentObject(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
mac/Config/Config/HelpView.swift
Normal file
34
mac/Config/Config/HelpView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
46
mac/Config/Config/IconButtonView.swift
Normal file
46
mac/Config/Config/IconButtonView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
88
mac/Config/Config/MainConfigView.swift
Normal file
88
mac/Config/Config/MainConfigView.swift
Normal 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)
|
||||
}
|
||||
128
mac/Config/Config/PackageInfoView.swift
Normal file
128
mac/Config/Config/PackageInfoView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
177
mac/Config/Config/PackageRowView.swift
Normal file
177
mac/Config/Config/PackageRowView.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue