Repo // + UserDefRenderableCocoa & extending AppKit.

This commit is contained in:
ShikiSuen 2024-02-05 22:47:48 +08:00
parent 368f9bb653
commit b8c915dca0
5 changed files with 678 additions and 1 deletions

View File

@ -0,0 +1,396 @@
// (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 AppKit
import SwiftExtension
// MARK: - NSAlert
public extension NSAlert {
func beginSheetModal(at window: NSWindow?, completionHandler handler: @escaping (NSApplication.ModalResponse) -> Void) {
if let window = window ?? NSApp.keyWindow {
beginSheetModal(for: window, completionHandler: handler)
} else {
handler(runModal())
}
}
}
// MARK: - NSOpenPanel
public extension NSOpenPanel {
func beginSheetModal(at window: NSWindow?, completionHandler handler: @escaping (NSApplication.ModalResponse) -> Void) {
if let window = window ?? NSApp.keyWindow {
beginSheetModal(for: window, completionHandler: handler)
} else {
handler(runModal())
}
}
}
// MARK: - NSButton
public extension NSButton {
convenience init(verbatim title: String, target: AnyObject?, action: Selector?) {
self.init()
self.title = title
self.target = target
self.action = action
bezelStyle = .rounded
}
convenience init(_ title: String, target: AnyObject?, action: Selector?) {
self.init(verbatim: title.localized, target: target, action: action)
}
}
// MARK: - Convenient Constructor for NSEdgeInsets.
public extension NSEdgeInsets {
static func new(all: CGFloat? = nil, top: CGFloat? = nil, bottom: CGFloat? = nil, left: CGFloat? = nil, right: CGFloat? = nil) -> NSEdgeInsets {
NSEdgeInsets(top: top ?? all ?? 0, left: left ?? all ?? 0, bottom: bottom ?? all ?? 0, right: right ?? all ?? 0)
}
}
// MARK: - Constrains and Box Container Modifier.
public extension NSView {
@discardableResult func makeSimpleConstraint(
_ attribute: NSLayoutConstraint.Attribute,
relation: NSLayoutConstraint.Relation,
value: CGFloat?
) -> NSView {
guard let value = value else { return self }
translatesAutoresizingMaskIntoConstraints = false
let widthConstraint = NSLayoutConstraint(
item: self, attribute: attribute, relatedBy: relation, toItem: nil,
attribute: NSLayoutConstraint.Attribute.notAnAttribute, multiplier: 1, constant: value
)
addConstraint(widthConstraint)
return self
}
func boxed(title: String = "") -> NSBox {
let maxDimension = fittingSize
let result = NSBox()
result.title = title.localized
if result.title.isEmpty {
result.titlePosition = .noTitle
}
let minWidth = Swift.max(maxDimension.width + 12, result.intrinsicContentSize.width)
let minHeight = Swift.max(maxDimension.height + result.titleRect.height + 14, result.intrinsicContentSize.height)
result.makeSimpleConstraint(.width, relation: .greaterThanOrEqual, value: minWidth)
result.makeSimpleConstraint(.height, relation: .greaterThanOrEqual, value: minHeight)
result.contentView = self
if let self = self as? NSStackView, self.orientation == .horizontal {
self.spacing = 0
}
return result
}
}
// MARK: - Stacks
public extension NSStackView {
var requiresConstraintBasedLayout: Bool {
true
}
static func buildSection(
_ orientation: NSUserInterfaceLayoutOrientation = .vertical,
width: CGFloat? = nil,
withDividers: Bool = true,
@ArrayBuilder<NSView?> views: () -> [NSView?]
) -> NSStackView? {
let viewsRendered = views().compactMap {
//
// $0?.wantsLayer = true
// $0?.layer?.backgroundColor = NSColor.red.cgColor
$0
}
guard !viewsRendered.isEmpty else { return nil }
var itemWidth = width
var splitterDelta: CGFloat = 4
splitterDelta = withDividers ? splitterDelta : 0
if let width = width, orientation == .horizontal, viewsRendered.count > 0 {
itemWidth = (width - splitterDelta) / CGFloat(viewsRendered.count) - 6
}
func giveViews() -> [NSView?] { viewsRendered }
let result = build(orientation, divider: withDividers, width: itemWidth, views: giveViews)?
.withInsets(.new(all: 4))
return result
}
static func build(
_ orientation: NSUserInterfaceLayoutOrientation,
divider: Bool = false,
width: CGFloat? = nil,
height: CGFloat? = nil,
insets: NSEdgeInsets? = nil,
@ArrayBuilder<NSView?> views: () -> [NSView?]
) -> NSStackView? {
let result = views().compactMap {
$0?
.makeSimpleConstraint(.width, relation: .equal, value: width)
.makeSimpleConstraint(.height, relation: .equal, value: height)
}
guard !result.isEmpty else { return nil }
return result.stack(orientation, divider: divider)?.withInsets(insets)
}
func withInsets(_ newValue: NSEdgeInsets?) -> NSStackView {
edgeInsets = newValue ?? edgeInsets
return self
}
}
public extension Array where Element == NSView {
func stack(
_ orientation: NSUserInterfaceLayoutOrientation,
divider: Bool = false,
insets: NSEdgeInsets? = nil
) -> NSStackView? {
guard !isEmpty else { return nil }
let outerStack = NSStackView()
if #unavailable(macOS 10.11) {
outerStack.hasEqualSpacing = true
} else {
outerStack.distribution = .equalSpacing
}
outerStack.orientation = orientation
if #unavailable(macOS 10.10) {
outerStack.spacing = Swift.max(1, outerStack.spacing) - 1
}
outerStack.setHuggingPriority(.fittingSizeCompression, for: .horizontal)
outerStack.setHuggingPriority(.fittingSizeCompression, for: .vertical)
forEach { subView in
if divider, !outerStack.views.isEmpty {
let divider = NSView()
divider.wantsLayer = true
divider.layer?.backgroundColor = NSColor.gray.withAlphaComponent(0.2).cgColor
switch orientation {
case .horizontal:
divider.makeSimpleConstraint(.width, relation: .equal, value: 1)
case .vertical:
divider.makeSimpleConstraint(.height, relation: .equal, value: 1)
@unknown default: break
}
divider.translatesAutoresizingMaskIntoConstraints = false
outerStack.addView(divider, in: orientation == .horizontal ? .leading : .top)
}
subView.layoutSubtreeIfNeeded()
switch orientation {
case .horizontal:
subView.makeSimpleConstraint(.height, relation: .greaterThanOrEqual, value: subView.intrinsicContentSize.height)
subView.makeSimpleConstraint(.width, relation: .greaterThanOrEqual, value: subView.intrinsicContentSize.width)
case .vertical:
subView.makeSimpleConstraint(.width, relation: .greaterThanOrEqual, value: subView.intrinsicContentSize.width)
subView.makeSimpleConstraint(.height, relation: .greaterThanOrEqual, value: subView.intrinsicContentSize.height)
@unknown default: break
}
subView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
subView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
subView.translatesAutoresizingMaskIntoConstraints = false
outerStack.addView(subView, in: orientation == .horizontal ? .leading : .top)
}
switch orientation {
case .horizontal:
outerStack.alignment = .centerY
case .vertical:
outerStack.alignment = .leading
@unknown default: break
}
return outerStack.withInsets(insets)
}
}
// MARK: - Make NSAttributedString into Label
public extension NSAttributedString {
func makeNSLabel(fixWidth: CGFloat? = nil) -> NSTextField {
let textField = NSTextField()
textField.attributedStringValue = self
textField.isEditable = false
textField.isBordered = false
textField.backgroundColor = .clear
if let fixWidth = fixWidth {
textField.preferredMaxLayoutWidth = fixWidth
}
return textField
}
}
// MARK: - Make String into Label
public extension String {
func makeNSLabel(descriptive: Bool = false, localized: Bool = true, fixWidth: CGFloat? = nil) -> NSTextField {
let rawAttributedString = NSMutableAttributedString(string: localized ? self.localized : self)
rawAttributedString.addAttributes([.kern: 0], range: .init(location: 0, length: rawAttributedString.length))
let textField = rawAttributedString.makeNSLabel(fixWidth: fixWidth)
if descriptive {
if #available(macOS 10.10, *) {
textField.textColor = .secondaryLabelColor
} else {
textField.textColor = .textColor.withAlphaComponent(0.55)
}
textField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
}
return textField
}
}
// MARK: - NSTabView
public extension NSTabView {
struct TabPage {
public let title: String
public let view: NSView
public init?(title: String, view: NSView?) {
self.title = title
guard let view = view else { return nil }
self.view = view
}
public init(title: String, view: NSView) {
self.title = title
self.view = view
}
public init?(title: String, @ArrayBuilder<NSView?> views: () -> [NSView?]) {
self.title = title
let viewsRendered = views()
guard !viewsRendered.isEmpty else { return nil }
func giveViews() -> [NSView?] { viewsRendered }
let result = NSStackView.build(.vertical, insets: .new(all: 14, top: 0), views: giveViews)
guard let result = result else { return nil }
view = result
}
}
static func build(
@ArrayBuilder<TabPage?> pages: () -> [TabPage?]
) -> NSTabView? {
let tabPages = pages().compactMap { $0 }
guard !tabPages.isEmpty else { return nil }
let finalTabView = NSTabView()
tabPages.forEach { currentPage in
finalTabView.addTabViewItem({
let currentItem = NSTabViewItem(identifier: UUID())
currentItem.label = currentPage.title.localized
let stacked = NSStackView.build(.vertical) {
currentPage.view
}
stacked?.alignment = .centerX
currentItem.view = stacked
return currentItem
}())
}
return finalTabView
}
}
// MARK: - NSMenu
public extension NSMenu {
@discardableResult func appendItems(_ target: AnyObject? = nil, @ArrayBuilder<NSMenuItem?> items: () -> [NSMenuItem?]) -> NSMenu {
let theItems = items()
for currentItem in theItems {
guard let currentItem = currentItem else { continue }
addItem(currentItem)
guard let target = target else { continue }
currentItem.target = target
currentItem.submenu?.propagateTarget(target)
}
return self
}
@discardableResult func propagateTarget(_ obj: AnyObject?) -> NSMenu {
for currentItem in items {
currentItem.target = obj
currentItem.submenu?.propagateTarget(obj)
}
return self
}
static func buildSubMenu(verbatim: String?, @ArrayBuilder<NSMenuItem?> items: () -> [NSMenuItem?]) -> NSMenuItem? {
guard let verbatim = verbatim, !verbatim.isEmpty else { return nil }
let newItem = NSMenu.Item(verbatim: verbatim)
newItem?.submenu = .init(title: verbatim).appendItems(items: items)
return newItem
}
static func buildSubMenu(_ title: String?, @ArrayBuilder<NSMenuItem?> items: () -> [NSMenuItem?]) -> NSMenuItem? {
guard let title = title?.localized, !title.isEmpty else { return nil }
return buildSubMenu(verbatim: title, items: items)
}
typealias Item = NSMenuItem
}
public extension Array where Element == NSMenuItem? {
func propagateTarget(_ obj: AnyObject?) {
forEach { currentItem in
guard let currentItem = currentItem else { return }
currentItem.target = obj
currentItem.submenu?.propagateTarget(obj)
}
}
}
public extension NSMenuItem {
convenience init?(verbatim: String?) {
guard let verbatim = verbatim, !verbatim.isEmpty else { return nil }
self.init(title: verbatim, action: nil, keyEquivalent: "")
}
convenience init?(_ title: String?) {
guard let title = title?.localized, !title.isEmpty else { return nil }
self.init(verbatim: title)
}
@discardableResult func hotkey(_ keyEquivalent: String, mask: NSEvent.ModifierFlags? = nil) -> NSMenuItem {
keyEquivalentModifierMask = mask ?? keyEquivalentModifierMask
self.keyEquivalent = keyEquivalent
return self
}
@discardableResult func state(_ givenState: Bool) -> NSMenuItem {
state = givenState ? .on : .off
return self
}
@discardableResult func act(_ action: Selector) -> NSMenuItem {
self.action = action
return self
}
@discardableResult func nulled(_ condition: Bool) -> NSMenuItem? {
condition ? nil : self
}
@discardableResult func mask(_ flags: NSEvent.ModifierFlags) -> NSMenuItem {
keyEquivalentModifierMask = flags
return self
}
@discardableResult func represent(_ object: Any?) -> NSMenuItem {
representedObject = object
return self
}
@discardableResult func tag(_ givenTag: Int?) -> NSMenuItem {
guard let givenTag = givenTag else { return self }
tag = givenTag
return self
}
}

View File

@ -48,12 +48,19 @@ public extension NSWindowController {
}
public extension NSWindow {
@discardableResult func callAlert(title: String, text: String? = nil) -> NSApplication.ModalResponse {
(self as NSWindow?).callAlert(title: title, text: text)
}
}
public extension NSWindow? {
@discardableResult func callAlert(title: String, text: String? = nil) -> NSApplication.ModalResponse {
let alert = NSAlert()
alert.messageText = title
if let text = text { alert.informativeText = text }
alert.addButton(withTitle: NSLocalizedString("OK", comment: ""))
var result: NSApplication.ModalResponse = .alertFirstButtonReturn
guard let self = self else { return alert.runModal() }
alert.beginSheetModal(for: self) { theResponce in
result = theResponce
}

View File

@ -20,7 +20,6 @@ public enum UserDef: String, CaseIterable, Identifiable {
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?

View File

@ -0,0 +1,275 @@
// (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.
// UserDefRenderable AppKit SwiftUI
import AppKit
import CocoaExtension
import Foundation
import IMKUtils
public class UserDefRenderableCocoa: NSObject, Identifiable {
public let def: UserDef
public var id: String { def.rawValue }
public var optionsLocalized: [(Int, String)?]
private var optionsLocalizedAsIdentifiables: [(String, String)?] = [] // Int
public var inlineDescriptionLocalized: String?
public var hideTitle: Bool = false
public var mainViewOverride: (() -> NSView?)?
public var currentControl: NSControl?
public var tinySize: Bool = false
public init(def: UserDef) {
self.def = def
if let rawOptions = def.metaData?.options, !rawOptions.isEmpty {
var newOptions: [Int: String] = [:]
rawOptions.forEach { key, value in
newOptions[key] = value.localized
}
optionsLocalized = rawOptions.sorted(by: { $0.key < $1.key })
} else {
optionsLocalized = []
}
var objOptions = [(String, String)?]()
var intOptions = [(Int, String)?]()
checkDef: switch def {
case .kAlphanumericalKeyboardLayout:
IMKHelper.allowedAlphanumericalTISInputSources.forEach { currentTIS in
objOptions.append((currentTIS.identifier, currentTIS.vChewingLocalizedName))
}
optionsLocalizedAsIdentifiables = objOptions
case .kBasicKeyboardLayout:
IMKHelper.allowedBasicLayoutsAsTISInputSources.forEach { currentTIS in
guard let currentTIS = currentTIS else {
objOptions.append(nil)
return
}
objOptions.append((currentTIS.identifier, currentTIS.vChewingLocalizedName))
}
optionsLocalizedAsIdentifiables = objOptions
case .kKeyboardParser:
KeyboardParser.allCases.forEach { currentParser in
if [7, 100].contains(currentParser.rawValue) { intOptions.append(nil) }
intOptions.append((currentParser.rawValue, currentParser.localizedMenuName))
}
optionsLocalized = intOptions
default: break checkDef
}
super.init()
guard let metaData = def.metaData else {
inlineDescriptionLocalized = nil
return
}
var stringStack = [String]()
if let promptText = metaData.inlinePrompt?.localized, !promptText.isEmpty {
stringStack.append(promptText)
}
if let descText = metaData.description?.localized, !descText.isEmpty {
stringStack.append(descText)
}
if metaData.minimumOS > 10.9 {
var strOSReq = ""
strOSReq += String(
format: "This feature requires macOS %@ and above.".localized, arguments: ["12.0"]
)
stringStack.append(strOSReq)
}
currentControl = renderFunctionControl()
guard !stringStack.isEmpty else {
inlineDescriptionLocalized = nil
return
}
inlineDescriptionLocalized = stringStack.joined(separator: "\n")
}
}
public extension UserDefRenderableCocoa {
func render(fixWidth fixedWith: CGFloat? = nil) -> NSView? {
let result: NSStackView? = NSStackView.build(.vertical) {
renderMainLine(fixedWidth: fixedWith)
renderDescription(fixedWidth: fixedWith)
}
result?.makeSimpleConstraint(.width, relation: .equal, value: fixedWith)
return result
}
func renderDescription(fixedWidth: CGFloat? = nil) -> NSTextField? {
guard let text = inlineDescriptionLocalized else { return nil }
let textField = text.makeNSLabel(descriptive: true)
if #available(macOS 10.10, *), tinySize {
textField.controlSize = .small
textField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
}
textField.preferredMaxLayoutWidth = fixedWidth ?? 0
return textField
}
func renderMainLine(fixedWidth: CGFloat? = nil) -> NSView? {
if let mainViewOverride = mainViewOverride {
return mainViewOverride()
}
guard let control: NSView = currentControl ?? renderFunctionControl() else { return nil }
let controlWidth = control.fittingSize.width
let textLabel: NSTextField? = {
if !hideTitle, let strTitle = def.metaData?.shortTitle {
return strTitle.makeNSLabel()
}
return nil
}()
let result = NSStackView.build(.horizontal) {
if !hideTitle, let textlabel = textLabel {
textlabel
NSView()
}
control
}
if let fixedWidth = fixedWidth {
textLabel?.preferredMaxLayoutWidth = fixedWidth - controlWidth
}
textLabel?.sizeToFit()
return result
}
private func renderFunctionControl() -> NSControl? {
var result: NSControl? {
switch def.dataType {
case .string where def == .kCandidateKeys:
let comboBox = NSComboBox()
comboBox.makeSimpleConstraint(.width, relation: .equal, value: 128)
comboBox.font = NSFont.systemFont(ofSize: 12)
comboBox.intercellSpacing = NSSize(width: 0.0, height: 10.0)
comboBox.addItems(withObjectValues: CandidateKey.suggestions)
comboBox.bind(
.value,
to: NSUserDefaultsController.shared,
withKeyPath: "values.\(def.rawValue)"
)
return comboBox
case .bool where optionsLocalized.isEmpty:
let checkBox: NSControl
if #unavailable(macOS 10.15) {
checkBox = NSButton()
(checkBox as? NSButton)?.setButtonType(.switch)
(checkBox as? NSButton)?.title = ""
} else {
checkBox = NSSwitch()
checkBox.controlSize = .mini
}
checkBox.bind(
.value,
to: NSUserDefaultsController.shared,
withKeyPath: "values.\(def.rawValue)",
options: [.continuouslyUpdatesValue: true]
)
// 滿
checkDef: switch def {
case .kAlwaysExpandCandidateWindow:
checkBox.bind(
.enabled,
to: NSUserDefaultsController.shared,
withKeyPath: "values.\(UserDef.kCandidateWindowShowOnlyOneLine.rawValue)",
options: [
.valueTransformerName: NSValueTransformerName.negateBooleanTransformerName,
]
)
case .kUseDynamicCandidateWindowOrigin:
checkBox.bind(
.enabled,
to: NSUserDefaultsController.shared,
withKeyPath: "values.\(UserDef.kUseRearCursorMode.rawValue)",
options: [
.valueTransformerName: NSValueTransformerName.negateBooleanTransformerName,
]
)
default: break checkDef
}
//
return checkBox
case .integer, .double,
.bool where !optionsLocalized.isEmpty,
.string where !optionsLocalized.isEmpty,
.string where !optionsLocalizedAsIdentifiables.isEmpty:
let dropMenu: NSMenu = .init()
let btnPopup = NSPopUpButton()
var itemShouldBeChosen: NSMenuItem?
if !optionsLocalizedAsIdentifiables.isEmpty {
btnPopup.bind(
.selectedObject,
to: NSUserDefaultsController.shared,
withKeyPath: "values.\(def.rawValue)",
options: [.continuouslyUpdatesValue: true]
)
optionsLocalizedAsIdentifiables.forEach { entity in
guard let obj = entity?.0, let title = entity?.1.localized else {
dropMenu.addItem(.separator())
return
}
let newItem = NSMenuItem(title: title, action: nil, keyEquivalent: "")
newItem.representedObject = .init(obj)
if obj == UserDefaults.current.object(forKey: def.rawValue) as? String {
itemShouldBeChosen = newItem
}
dropMenu.addItem(newItem)
}
} else {
btnPopup.bind(
.selectedTag,
to: NSUserDefaultsController.shared,
withKeyPath: "values.\(def.rawValue)",
options: [.continuouslyUpdatesValue: true]
)
optionsLocalized.forEach { entity in
guard let tag = entity?.0, let title = entity?.1.localized else {
dropMenu.addItem(.separator())
return
}
let newItem = NSMenuItem(title: title, action: nil, keyEquivalent: "")
newItem.tag = tag
if tag == UserDefaults.current.integer(forKey: def.rawValue) {
itemShouldBeChosen = newItem
}
if Double(tag) == UserDefaults.current.double(forKey: def.rawValue) {
itemShouldBeChosen = newItem
}
dropMenu.addItem(newItem)
}
}
btnPopup.menu = dropMenu
btnPopup.font = NSFont.systemFont(ofSize: 12)
btnPopup.setFrameSize(btnPopup.fittingSize)
btnPopup.select(itemShouldBeChosen)
return btnPopup
case .array, .dictionary, .other: return nil
default: return nil
}
}
if #available(macOS 10.10, *), tinySize {
result?.controlSize = .small
return result?.makeSimpleConstraint(.height, relation: .greaterThanOrEqual, value: Swift.max(14, result?.fittingSize.height ?? 14)) as? NSControl
}
return result?.makeSimpleConstraint(.height, relation: .greaterThanOrEqual, value: Swift.max(16, result?.fittingSize.height ?? 16)) as? NSControl
}
}
// MARK: - External Extensions.
public extension UserDef {
func render(fixWidth: CGFloat? = nil, extraOps: ((inout UserDefRenderableCocoa) -> Void)? = nil) -> NSView? {
var renderable = toCocoaRenderable()
extraOps?(&renderable)
return renderable.render(fixWidth: fixWidth)
}
func toCocoaRenderable() -> UserDefRenderableCocoa {
.init(def: self)
}
}