SettingsUI // Implement UserDefRenderable.

This commit is contained in:
ShikiSuen 2024-01-29 00:18:39 +08:00
parent cfe9a1ce5d
commit 09aec2bb06
6 changed files with 437 additions and 9 deletions

View File

@ -0,0 +1,247 @@
// (c) 2021 and onwards The vChewing Project (MIT-NTL License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
// ... with NTL restriction stating that:
// No trademark license is granted to use the trade names, trademarks, service
// marks, or product names of Contributor, except as required to fulfill notice
// requirements defined in MIT License.
import Foundation
import IMKUtils
import Shared
import SwiftUI
// MARK: - UserDefRenderable Extension
public extension UserDefRenderable<String> {
@ViewBuilder
func render() -> some View {
if let metaData = metaData {
VStack(alignment: .leading) {
Group {
switch (def.dataType, def) {
case (.array, .kAppleLanguages):
Picker(LocalizedStringKey(metaData.shortTitle ?? ""), selection: binding) {
Text(LocalizedStringKey("Follow OS settings")).tag("auto")
Text(LocalizedStringKey("Simplified Chinese")).tag("zh-Hans")
Text(LocalizedStringKey("Traditional Chinese")).tag("zh-Hant")
Text(LocalizedStringKey("Japanese")).tag("ja")
Text(LocalizedStringKey("English")).tag("en")
}
case (.string, .kCandidateKeys):
HStack {
Text(LocalizedStringKey(metaData.shortTitle ?? ""))
Spacer()
ComboBox(
items: CandidateKey.suggestions,
text: binding
).frame(width: 180)
}
case (.string, .kAlphanumericalKeyboardLayout):
Picker(LocalizedStringKey(metaData.shortTitle ?? ""), selection: binding) {
ForEach(0 ... (IMKHelper.allowedAlphanumericalTISInputSources.count - 1), id: \.self) { id in
let theEntry = IMKHelper.allowedAlphanumericalTISInputSources[id]
Text(theEntry.vChewingLocalizedName).tag(theEntry.identifier)
}.id(UUID())
}
case (.string, .kBasicKeyboardLayout):
Picker(LocalizedStringKey(metaData.shortTitle ?? ""), selection: binding) {
ForEach(0 ... (IMKHelper.allowedBasicLayoutsAsTISInputSources.count - 1), id: \.self) { id in
let theEntry = IMKHelper.allowedBasicLayoutsAsTISInputSources[id]
if let theEntry = theEntry {
Text(theEntry.vChewingLocalizedName).tag(theEntry.identifier)
} else {
Divider()
}
}.id(UUID())
}
case (.string, .kCassettePath): EmptyView()
case (.string, .kUserDataFolderSpecified): EmptyView()
default: EmptyView()
}
}.disabled(OS.ifUnavailable(metaData.minimumOS))
descriptionView()
}
}
}
}
public extension UserDefRenderable<Bool> {
@ViewBuilder
func render() -> some View {
if let metaData = metaData {
VStack(alignment: .leading) {
Group {
switch def.dataType {
case .bool where options.isEmpty: //
Toggle(LocalizedStringKey(metaData.shortTitle ?? ""), isOn: binding)
case .bool where !options.isEmpty: //
let shortTitle = metaData.shortTitle
let picker = Picker(
LocalizedStringKey(metaData.shortTitle ?? ""),
selection: binding
) {
ForEach(options, id: \.key) { theTag, strOption in
Text(LocalizedStringKey(strOption)).tag(theTag == 0 ? false : true)
}
}
if shortTitle == nil {
picker.labelsHidden()
} else {
picker
}
default: Text("[Debug] Control Type Mismatch: \(def.rawValue)")
}
}.disabled(OS.ifUnavailable(metaData.minimumOS))
descriptionView()
}
}
}
}
public extension UserDefRenderable<Int> {
@ViewBuilder
func render() -> some View {
if let metaData = metaData {
VStack(alignment: .leading) {
Group {
switch def.dataType {
case .integer where options.isEmpty && def != .kKeyboardParser:
Text("[Debug] Needs Review: \(def.rawValue)")
case .integer where options.isEmpty && def == .kKeyboardParser: //
Picker(
LocalizedStringKey(metaData.shortTitle ?? ""),
selection: binding
) {
ForEach(KeyboardParser.allCases, id: \.self) { item in
if [7, 100].contains(item.rawValue) { Divider() }
Text(item.localizedMenuName).tag(item.rawValue)
}.id(UUID())
}
case .integer where !options.isEmpty:
VStack(alignment: .leading) {
let shortTitle = metaData.shortTitle
let picker = Picker(
LocalizedStringKey(metaData.shortTitle ?? ""),
selection: binding
) {
ForEach(options, id: \.key) { theTag, strOption in
Text(LocalizedStringKey(strOption)).tag(theTag)
}
}
if shortTitle == nil {
picker.labelsHidden()
} else {
picker
}
}
default: Text("[Debug] Control Type Mismatch: \(def.rawValue)")
}
}.disabled(OS.ifUnavailable(metaData.minimumOS))
descriptionView()
}
}
}
}
public extension UserDefRenderable<Double> {
@ViewBuilder
func render() -> some View {
if let metaData = metaData {
VStack(alignment: .leading) {
Group {
switch def.dataType {
case .double where options.isEmpty: // RAW Double
Text("[Debug] Needs Review: \(def.rawValue)")
case .double where !options.isEmpty:
VStack(alignment: .leading) {
let shortTitle = metaData.shortTitle
let picker = Picker(
LocalizedStringKey(metaData.shortTitle ?? ""),
selection: binding
) {
ForEach(options, id: \.key) { theTag, strOption in
Text(LocalizedStringKey(strOption)).tag(Double(theTag))
}
}
if shortTitle == nil {
picker.labelsHidden()
} else {
picker
}
}
default: Text("[Debug] Control Type Mismatch: \(def.rawValue)")
}
}.disabled(OS.ifUnavailable(metaData.minimumOS))
descriptionView()
}
}
}
}
// MARK: - NSComboBox
// Ref: https://stackoverflow.com/a/71058587/4162914
// License: https://creativecommons.org/licenses/by-sa/4.0/
@available(macOS 10.15, *)
public struct ComboBox: NSViewRepresentable {
// The items that will show up in the pop-up menu:
public var items: [String] = []
// The property on our parent view that gets synced to the current
// stringValue of the NSComboBox, whether the user typed it in or
// selected it from the list:
@Binding public var text: String
public func makeCoordinator() -> Coordinator {
Coordinator(self)
}
public func makeNSView(context: Context) -> NSComboBox {
let comboBox = NSComboBox()
comboBox.usesDataSource = false
comboBox.completes = false
comboBox.delegate = context.coordinator
comboBox.intercellSpacing = NSSize(width: 0.0, height: 10.0)
return comboBox
}
public func updateNSView(_ nsView: NSComboBox, context: Context) {
nsView.removeAllItems()
nsView.addItems(withObjectValues: items)
// ComboBox doesn't automatically select the item matching its text;
// we must do that manually. But we need the delegate to ignore that
// selection-change or we'll get a "state modified during view update;
// will cause undefined behavior" warning.
context.coordinator.ignoreSelectionChanges = true
nsView.stringValue = text
nsView.selectItem(withObjectValue: text)
context.coordinator.ignoreSelectionChanges = false
}
public class Coordinator: NSObject, NSComboBoxDelegate {
public var parent: ComboBox
public var ignoreSelectionChanges = false
public init(_ parent: ComboBox) {
self.parent = parent
}
public func comboBoxSelectionDidChange(_ notification: Notification) {
if !ignoreSelectionChanges,
let box: NSComboBox = notification.object as? NSComboBox,
let newStringValue: String = box.objectValueOfSelectedItem as? String
{
parent.text = newStringValue
}
}
public func controlTextDidEndEditing(_ obj: Notification) {
if let textField = obj.object as? NSTextField {
parent.text = textField.stringValue
}
}
}
}

View File

@ -6,6 +6,7 @@
// marks, or product names of Contributor, except as required to fulfill notice
// requirements defined in MIT License.
import Shared
import SwiftUI
@available(macOS 13, *)

View File

@ -0,0 +1,27 @@
// (c) 2021 and onwards The vChewing Project (MIT-NTL License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
// ... with NTL restriction stating that:
// No trademark license is granted to use the trade names, trademarks, service
// marks, or product names of Contributor, except as required to fulfill notice
// requirements defined in MIT License.
import Foundation
import SwiftExtension
public enum OS {
public static let currentOSVersionString: String = {
let strSet = ProcessInfo().operatingSystemVersion
return "\(strSet.majorVersion).\(strSet.minorVersion).\(strSet.patchVersion)"
}()
public static func ifAvailable(_ givenOSVersion: Double) -> Bool {
let rawResult = currentOSVersionString.versionCompare(givenOSVersion.description)
return [.orderedDescending].contains(rawResult)
}
public static func ifUnavailable(_ givenOSVersion: Double) -> Bool {
let rawResult = currentOSVersionString.versionCompare(givenOSVersion.description)
return [.orderedSame, .orderedAscending].contains(rawResult)
}
}

View File

@ -10,19 +10,22 @@ import Foundation
// MARK: - UserDef
public enum UserDef: String, CaseIterable {
public enum UserDef: String, CaseIterable, Identifiable {
public enum DataType: CaseIterable {
case string, bool, integer, double, array, dictionary, other
}
public var id: String { rawValue }
public struct MetaData {
public var userDef: UserDef
public var shortTitle: String?
public var control: AnyObject?
public var prompt: String?
public var inlinePrompt: String?
public var popupPrompt: String?
public var description: String?
public var minimumOS: Double?
public var minimumOS: Double = 10.9
public var options: [Int: String]?
public var toolTip: String?
}
@ -268,7 +271,7 @@ public extension UserDef {
)
case .kUseExternalFactoryDict: return .init(
userDef: self, shortTitle: "Read external factory dictionary files if possible",
prompt: "Read external factory dictionary files if possible"
description: "This will use the SQLite database deployed by the “make install” command from libvChewing-Data if possible."
)
case .kKeyboardParser: return .init(
userDef: self, shortTitle: "Phonetic Parser:",
@ -286,10 +289,29 @@ public extension UserDef {
userDef: self, shortTitle: "Show notifications when toggling Caps Lock", minimumOS: 12
)
case .kCandidateListTextSize: return .init(
userDef: self, shortTitle: "Candidate Size:", description: "Choose candidate font size for better visual clarity."
userDef: self,
shortTitle: "Candidate Size:",
description: "Choose candidate font size for better visual clarity.",
options: [
12: "12",
14: "14",
16: "16",
17: "17",
18: "18",
20: "20",
22: "22",
24: "24",
32: "32",
64: "64",
96: "96",
]
)
case .kAlwaysExpandCandidateWindow: return .init(userDef: self, shortTitle: "Always expand candidate window panel")
case .kCandidateWindowShowOnlyOneLine: return .init(userDef: self, shortTitle: "Use only one row / column in candidate window")
case .kCandidateWindowShowOnlyOneLine: return .init(
userDef: self,
shortTitle: "Use only one row / column in candidate window",
description: "Tadokoro candidate window shows 4 rows / columns by default, providing similar experiences from Microsoft New Phonetic IME and macOS bult-in Chinese IME (since macOS 10.9). However, for some users who have presbyopia, they prefer giant candidate font sizes, resulting a concern that multiple rows / columns of candidates can make the candidate window looks too big, hence this option. Note that this option will be dismissed if the typing context is vertical, forcing the candidates to be shown in only one row / column. Only one reverse-lookup result can be made available in single row / column mode due to reduced candidate window size."
)
case .kAppleLanguages: return .init(
userDef: self, shortTitle: "UI Language:",
description: "Change user interface language (will reboot the IME)."
@ -454,12 +476,20 @@ public extension UserDef {
description: "⚠︎ This feature is useful ONLY WHEN the font you are using doesn't support dynamic vertical punctuations. However, typed vertical punctuations will always shown as vertical punctuations EVEN IF your editor has changed the typing direction to horizontal."
)
case .kTrimUnfinishedReadingsOnCommit: return .init(userDef: self, shortTitle: "Trim unfinished readings / strokes on commit")
case .kAlwaysShowTooltipTextsHorizontally: return .init(userDef: self, shortTitle: "Always show tooltip texts horizontally")
case .kAlwaysShowTooltipTextsHorizontally: return .init(
userDef: self,
shortTitle: "Always show tooltip texts horizontally",
description: "Key names in tooltip will be shown as symbols when the tooltip is vertical. However, this option will be ignored since tooltip will always be horizontal if the UI language is English."
)
case .kClientsIMKTextInputIncapable: return .init(userDef: self)
case .kShowTranslatedStrokesInCompositionBuffer: return .init(userDef: self, shortTitle: "Show translated strokes in composition buffer")
case .kShowTranslatedStrokesInCompositionBuffer: return .init(
userDef: self,
shortTitle: "Show translated strokes in composition buffer",
description: "All strokes in the composition buffer will be shown as ASCII keyboard characters unless this option is enabled. Stroke is definable in the “%keyname” section of the CIN file."
)
case .kForceCassetteChineseConversion: return .init(
userDef: self,
shortTitle: "Chinese conversion for cassette module",
shortTitle: "Chinese Conversion:",
description: "This conversion only affects the cassette module, converting typed contents to either Simplified Chinese or Traditional Chinese in accordance with this setting and your current input mode.",
options: [
0: "Disable forced conversion for cassette outputs",
@ -496,7 +526,11 @@ public extension UserDef {
description: "Some clients with web-based front UI may have issues rendering segmented thick underlines drawn by their implemented “setMarkedText()”. This option stops the input method from delivering segmented thick underlines to “client().setMarkedText()”. Note that segmented thick underlines are only used in marking mode, unless the client itself misimplements the IMKTextInput method “setMarkedText()”. This option only affects the inline composition buffer."
)
case .kCandidateTextFontName: return nil
case .kCandidateKeys: return .init(userDef: self, shortTitle: "Selection Keys:", prompt: "Choose or hit Enter to confim your prefered keys for selecting candidates.", description: "This will also affect the row / column capacity of the candidate window.")
case .kCandidateKeys: return .init(
userDef: self, shortTitle: "Selection Keys:",
inlinePrompt: "Choose or hit Enter to confim your prefered keys for selecting candidates.",
description: "This will also affect the row / column capacity of the candidate window."
)
case .kAssociatedPhrasesEnabled: return nil
case .kPhraseReplacementEnabled: return .init(
userDef: self, shortTitle: "Enable phrase replacement table",

View File

@ -0,0 +1,93 @@
// (c) 2022 and onwards The vChewing Project (MIT-NTL License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
// ... with NTL restriction stating that:
// No trademark license is granted to use the trade names, trademarks, service
// marks, or product names of Contributor, except as required to fulfill notice
// requirements defined in MIT License.
import Foundation
import SwiftUI
// MARK: - UserDefRenderable
@available(macOS 10.15, *)
public struct UserDefRenderable<Value>: Identifiable {
public let def: UserDef
public let binding: Binding<Value>
public let options: [Dictionary<Int, String>.Element]
public var id: String { def.rawValue }
public var metaData: UserDef.MetaData? { def.metaData }
public init(_ userDef: UserDef, binding: Binding<Value>) {
def = userDef
self.binding = binding
options = (def.metaData?.options ?? [:]).sorted(by: { $0.key < $1.key })
}
public typealias RawFormat = (key: UserDef, value: Binding<Value>)
@ViewBuilder
public func render() -> some View {
EmptyView()
}
public var hasInlineDescription: Bool {
guard let meta = def.metaData else { return false }
return meta.description != nil || meta.inlinePrompt != nil || meta.minimumOS > 10.9
}
@ViewBuilder
public func descriptionView() -> some View {
if let metaData = metaData {
if hasInlineDescription { Spacer().frame(height: 6) }
let descText = metaData.description
let promptText = metaData.inlinePrompt
let descriptionSource: [String] = [promptText, descText].compactMap { $0 }
if !descriptionSource.isEmpty {
ForEach(Array(descriptionSource.enumerated()), id: \.offset) { _, i18nKey in
Text(LocalizedStringKey(i18nKey)).settingsDescription()
}
}
if metaData.minimumOS > 10.9 {
Group {
Text("") + Text(LocalizedStringKey("This feature requires macOS \(metaData.minimumOS.description) and above."))
}.settingsDescription()
}
}
}
}
public extension UserDefRenderable<Any> {
func batch(_ input: [RawFormat]) -> [UserDefRenderable<Value>] {
input.compactMap { metaPair in
metaPair.key.bind(binding)
}
}
}
extension [UserDefRenderable<Any>]: Identifiable {
public var id: String { map(\.id).description }
}
// MARK: - UserDef metaData Extension
public extension UserDef {
func bind<Value>(_ binding: Binding<Value>) -> UserDefRenderable<Value> {
UserDefRenderable(self, binding: binding)
}
}
// MARK: - Private View Extension
@available(macOS 10.15, *)
private extension View {
func settingsDescription(maxWidth: CGFloat? = .infinity) -> some View {
controlSize(.small)
.frame(maxWidth: maxWidth, alignment: .leading)
// TODO: Use `.foregroundStyle` when targeting macOS 12.
.foregroundColor(.secondary)
}
}

View File

@ -316,3 +316,29 @@ public extension Array where Element: Hashable {
Set(self).isOverlapped(with: target)
}
}
// MARK: - Version Comparer.
public extension String {
/// ref: https://sarunw.com/posts/how-to-compare-two-app-version-strings-in-swift/
func versionCompare(_ otherVersion: String) -> ComparisonResult {
let versionDelimiter = "."
var versionComponents = components(separatedBy: versionDelimiter) // <1>
var otherVersionComponents = otherVersion.components(separatedBy: versionDelimiter)
let zeroDiff = versionComponents.count - otherVersionComponents.count // <2>
// <3> Compare normally if the formats are the same.
guard zeroDiff != 0 else { return compare(otherVersion, options: .numeric) }
let zeros = Array(repeating: "0", count: abs(zeroDiff)) // <4>
if zeroDiff > 0 {
otherVersionComponents.append(contentsOf: zeros) // <5>
} else {
versionComponents.append(contentsOf: zeros)
}
return versionComponents.joined(separator: versionDelimiter)
.compare(otherVersionComponents.joined(separator: versionDelimiter), options: .numeric) // <6>
}
}