SettingsUI // Implement UserDefRenderable.
This commit is contained in:
parent
cfe9a1ce5d
commit
09aec2bb06
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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, *)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue