diff --git a/.github/workflows/stdout_renderer.yml b/.github/workflows/stdout_renderer.yml index 9197a597c..500d8ba06 100644 --- a/.github/workflows/stdout_renderer.yml +++ b/.github/workflows/stdout_renderer.yml @@ -134,6 +134,9 @@ jobs: OPENSWIFTUI_OPENATTRIBUTESHIMS_ATTRIBUTEGRAPH: ${{ matrix.backend == 'AttributeGraph' && '1' || '0' }} OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE: ${{ matrix.backend == 'Compute' && '1' || '0' }} OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_BINARY: 1 + OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_BINARY_REPO: "OpenSwiftUIProject/Compute" + OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_BINARY_VERSION: "0.6.0-bugfix.1" + OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_BINARY_CHECKSUM: "faa4f1598a8b7850a3f68b1b2588ecb15a474e9325195f9fd8a274e3e6132f35" GH_TOKEN: ${{ github.token }} steps: - name: Mark PR status running diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index 18b389955..e051d81f3 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -42,7 +42,7 @@ jobs: OPENSWIFTUI_OPENATTRIBUTESHIMS_ATTRIBUTEGRAPH: 0 OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE: ${{ matrix.backend == 'IAG' && '1' || '0' }} OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_BINARY: 0 - OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_SOURCE_VERSION: "0.6.0" + OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_SOURCE_VERSION: "0.6.0-bugfix.1" OPENSWIFTUI_COMPATIBILITY_TEST: 0 OPENSWIFTUI_SWIFT_LOG: 1 OPENSWIFTUI_SWIFT_CRYPTO: 1 diff --git a/Example/Shared/View/ButtonExample.swift b/Example/Shared/View/ButtonExample.swift new file mode 100644 index 000000000..94ffcb647 --- /dev/null +++ b/Example/Shared/View/ButtonExample.swift @@ -0,0 +1,29 @@ +// +// ButtonExample.swift +// Shared + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +struct ButtonExample: View { + var onPress: (Int) -> Void = { _ in } + + @State private var count = 0 + + var body: some View { + Button { + count += 1 + onPress(count) + } label: { + Text("Count: \(count)") + .frame(minWidth: 120, minHeight: 44) + } + } +} + +#Preview { + ButtonExample() +} diff --git a/Example/Tests/OpenSwiftUIInteractionTests/View/ButtonInteractionTests.swift b/Example/Tests/OpenSwiftUIInteractionTests/View/ButtonInteractionTests.swift new file mode 100644 index 000000000..ce5e361af --- /dev/null +++ b/Example/Tests/OpenSwiftUIInteractionTests/View/ButtonInteractionTests.swift @@ -0,0 +1,54 @@ +// +// ButtonInteractionTests.swift +// OpenSwiftUIInteractionTests + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif +import SnapshotTesting +import Testing +@testable import TestingHost + +@MainActor +@Suite(.serialized, .tags(.interaction), .snapshots(record: .never, diffTool: diffTool)) +struct ButtonInteractionTests { + @Test + func actionUpdatesCount() async throws { + var counts: [Int] = [] + let content = ButtonExample { counts.append($0) } + + try await withInteractionTestHost(of: content) { host in + #expect(counts.isEmpty) + try await host.assertSnapshot(named: "initial") + + for count in 1...2 { + try await host.tap() + try await host.waitUntil(counts.count >= count) + // Capture the idle appearance after the button's release animation. + try await Task.sleep(for: .milliseconds(600)) + try await host.assertSnapshot(named: "tap-\(count)") + #expect(counts == Array(1...count)) + } + } + } + + @Test + func disabledButtonDoesNotRunAction() async throws { + var counts: [Int] = [] + let content = ButtonExample { counts.append($0) } + .disabled(true) + + try await withInteractionTestHost(of: content) { host in + try await host.assertSnapshot(named: "initial") + + for count in 1...2 { + try await host.tap() + try await Task.sleep(for: .milliseconds(600)) + try await host.assertSnapshot(named: "tap-\(count)") + #expect(counts.isEmpty) + } + } + } +} diff --git a/Package.resolved b/Package.resolved index 8a3ba8d9b..9db656262 100644 --- a/Package.resolved +++ b/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/OpenSwiftUIProject/DarwinPrivateFrameworks.git", "state" : { "branch" : "main", - "revision" : "b00ecca4c7e77ab24da8c25d81f3d413db6fdcb5" + "revision" : "10ace67d6ed884f4dbae0c52e157adf2bbb5301c" } }, { @@ -43,7 +43,7 @@ "location" : "https://github.com/OpenSwiftUIProject/OpenRenderBox", "state" : { "branch" : "main", - "revision" : "4b0e86bcf926426fbbf2186d4dde17280a40cafb" + "revision" : "9f76f89f03cf2be75166919ccd41397c2f6bef1f" } }, { diff --git a/Renderer/Stdout/configure-env.sh b/Renderer/Stdout/configure-env.sh index 059637780..187c061a5 100644 --- a/Renderer/Stdout/configure-env.sh +++ b/Renderer/Stdout/configure-env.sh @@ -9,7 +9,7 @@ Darwin) *) export OPENSWIFTUI_OPENATTRIBUTESHIMS_ATTRIBUTEGRAPH="${OPENSWIFTUI_OPENATTRIBUTESHIMS_ATTRIBUTEGRAPH:-0}" export OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE="${OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE:-1}" - export OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_SOURCE_VERSION="${OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_SOURCE_VERSION:-0.6.0}" + export OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_SOURCE_VERSION="${OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_SOURCE_VERSION:-0.6.0-bugfix.1}" if [[ -z "${OPENSWIFTUI_LIB_SWIFT_PATH:-}" ]]; then swift_binary="$(readlink -f "$(command -v swift)")" if [[ "$(basename "$swift_binary")" == "swiftly" ]]; then diff --git a/Scripts/CI/compute_setup.sh b/Scripts/CI/compute_setup.sh index 74ce89f0e..3547be0fb 100755 --- a/Scripts/CI/compute_setup.sh +++ b/Scripts/CI/compute_setup.sh @@ -14,7 +14,7 @@ clone_checkout_compute() { revision=$(Scripts/CI/get_revision.sh compute 2>/dev/null || true) fi if [ -z "$revision" ]; then - revision="0.6.0" + revision="0.6.0-bugfix.1" fi cd .. if [ ! -d Compute ]; then diff --git a/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h b/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h index c4bdc0ddc..db66278ea 100644 --- a/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h +++ b/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h @@ -12,6 +12,7 @@ #if __has_include() #import +#import "NSButton_Private.h" OPENSWIFTUI_ASSUME_NONNULL_BEGIN diff --git a/Sources/COpenSwiftUI/Shims/AppKit/NSButton_Private.h b/Sources/COpenSwiftUI/Shims/AppKit/NSButton_Private.h new file mode 100644 index 000000000..608c3e851 --- /dev/null +++ b/Sources/COpenSwiftUI/Shims/AppKit/NSButton_Private.h @@ -0,0 +1,36 @@ +// +// NSButton_Private.h +// OpenSwiftUI_SPI +// +// Audited for macOS 15.5 + +#ifndef NSButton_Private_h +#define NSButton_Private_h + +#include "OpenSwiftUIBase.h" + +#if __has_include() +#include + +OPENSWIFTUI_ASSUME_NONNULL_BEGIN + +@interface NSControl (OpenSwiftUI_SPI) +- (void)addTarget:(id)target action:(SEL)action forControlEvents:(unsigned long long)events; +- (void)removeTarget:(id)target action:(SEL)action forControlEvents:(unsigned long long)events; +@end + +@interface NSButton (OpenSwiftUI_SPI) +@property (nullable, strong) NSView *contentView; +@property NSInteger toolbarAppearance; +- (int)effectiveVibrancyBlendModeForAppearance:(NSAppearance *)appearance; +- (void)_setUsesCautionaryAppearanceWhenActionIsDestructive:(BOOL)destructive; +- (BOOL)_getIntrinsicArtworkSize:(CGSize *)size + alignmentRectInsets:(NSEdgeInsets *)alignmentInsets + idealContentInsets:(NSEdgeInsets *)contentInsets + maxContentInsets:(nullable NSEdgeInsets *)maxContentInsets; +@end + +OPENSWIFTUI_ASSUME_NONNULL_END +#endif + +#endif /* NSButton_Private_h */ diff --git a/Sources/COpenSwiftUI/Shims/AppKit/NSView_Private.h b/Sources/COpenSwiftUI/Shims/AppKit/NSView_Private.h index dd460547b..7bdc95ba9 100644 --- a/Sources/COpenSwiftUI/Shims/AppKit/NSView_Private.h +++ b/Sources/COpenSwiftUI/Shims/AppKit/NSView_Private.h @@ -10,11 +10,26 @@ #if __has_include() #include +#include "_NSConstraintBasedLayoutHostingView.h" + +@protocol NSContentStyle +@property (nullable, readonly) NSColor *equivalentForegroundColorForTemplateImage; +@property (readonly) int outputBlendModeForTemplateContent; +@end @interface NSView (OpenSwiftUI_SPI) @property (getter=isOpaque) BOOL opaque; @property (nonatomic, assign, readonly) NSEdgeInsets computedSafeAreaInsets; @property (nonatomic) BOOL ignoreHitTest_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(ignoreHitTest); +@property (nullable, copy) id contentStyle; +@property NSUserInterfaceLayoutDirection userInterfaceLayoutDirection; + +- (BOOL)_userInterfaceLayoutDirectionPropagatesToDescendants; +- (void)_setUserInterfaceLayoutDirectionPropagatesToDescendants:(BOOL)propagates; +- (void)_setVibrantBlendingStyleForSubtree:(NSUInteger)style; + +- (BaselineOffset)_baselineOffsetsAtSize:(CGSize)size; +- (nullable NSView *)designatedFocusRingView OPENSWIFTUI_SWIFT_NAME(designatedFocusRing()); - (nullable NSResponder *)_nextResponderForEvent:(nullable NSEvent *)event; - (nonnull id)_observerForChangesInGeometryInWindow:(void (^ _Nonnull)(NSView * _Nonnull view))block; diff --git a/Sources/OpenSwiftUI/Accessibility/AccessibilityLargeContentView.swift b/Sources/OpenSwiftUI/Accessibility/AccessibilityLargeContentView.swift index e1fd3062f..2183e8e7c 100644 --- a/Sources/OpenSwiftUI/Accessibility/AccessibilityLargeContentView.swift +++ b/Sources/OpenSwiftUI/Accessibility/AccessibilityLargeContentView.swift @@ -142,8 +142,14 @@ enum AccessibilityLargeContentViewTree: Equatable { case empty var hasValue: Bool { - // TODO: Check whether the tree contains an enabled large content item. - _openSwiftUIUnimplementedFailure() + switch self { + case let .leaf(item): + item.behavior == .enabled + case let .branch(children): + children.contains { $0.hasValue } + case .empty: + false + } } func hitTest(at point: CGPoint) -> AccessibilityLargeContentViewItem? { @@ -203,7 +209,7 @@ struct AccessibilityLargeContentViewItem: Equatable { var behavior: AccessibilityLargeContentViewBehavior } -// MARK: - AccessibilityLargeContentViewModifier [WIP] +// MARK: - AccessibilityLargeContentViewModifier private struct AccessibilityLargeContentViewModifier: MultiViewModifier, PrimitiveViewModifier { var behavior: AccessibilityLargeContentViewBehavior @@ -214,7 +220,26 @@ private struct AccessibilityLargeContentViewModifier: MultiViewMo inputs: _ViewInputs, body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs ) -> _ViewOutputs { - _openSwiftUIUnimplementedFailure() + let generator = PlatformItemListGenerator( + flags: LabelPlatformItemListFlags.self, + content: modifier.value[offset: { .of(&$0.largeContentView) }], + inputs: inputs, + inputsIncludeGeometry: true + ) + var outputs = body(_Graph(), inputs) + let platformItemList = Attribute(generator) + outputs.preferences.makePreferenceTransformer( + inputs: inputs.preferences, + key: AccessibilityLargeContentViewTree.Key.self, + transform: Attribute(AccessibilityLargeContentViewTransform( + behavior: modifier.value[offset: { .of(&$0.behavior) }], + platformItemList: platformItemList, + size: inputs.size, + position: inputs.position, + transform: inputs.transform + )) + ) + return outputs } } diff --git a/Sources/OpenSwiftUI/Accessibility/Platform/AppKitAccessibility.swift b/Sources/OpenSwiftUI/Accessibility/Platform/AppKitAccessibility.swift index 717db56e2..c96b096f4 100644 --- a/Sources/OpenSwiftUI/Accessibility/Platform/AppKitAccessibility.swift +++ b/Sources/OpenSwiftUI/Accessibility/Platform/AppKitAccessibility.swift @@ -506,7 +506,7 @@ extension AccessibilityCore { } } -private extension NSView { +extension NSView { var enclosingViewRendererHost: (any ViewRendererHost)? { if let host = self as? any ViewRendererHost { return host diff --git a/Sources/OpenSwiftUI/Data/Environment/AppIntentAction.swift b/Sources/OpenSwiftUI/Data/Environment/AppIntentAction.swift index ad79d0d23..dff82f762 100644 --- a/Sources/OpenSwiftUI/Data/Environment/AppIntentAction.swift +++ b/Sources/OpenSwiftUI/Data/Environment/AppIntentAction.swift @@ -3,7 +3,7 @@ // OpenSwiftUI // // Audited for 6.5.4 -// Status: WIP +// Status: Complete import COpenSwiftUI import Foundation @@ -14,13 +14,31 @@ import Logging import os.log #endif -// MARK: - AppIntentAction [TODO] +// MARK: - AppIntentAction -struct AppIntentAction { +struct AppIntentAction: DynamicProperty { @Environment(\.appIntentExecutor) private var appIntentExecutor var lnAction: NSObject var defaultExecutor: @MainActor () -> Void + init(lnAction: NSObject, defaultExecutor: @escaping @MainActor () -> Void) { + self.lnAction = lnAction + self.defaultExecutor = defaultExecutor + } + + func perform() { + MainActor.assumeIsolatedIfLinkedOnOrAfter( + .v7, + context: "App Intent expects to run on the main actor." + ) { + if let appIntentExecutor { + appIntentExecutor.perform(lnAction) + } else { + defaultExecutor() + } + } + } + static let logger = Logger(subsystem: "org.openswiftuiproject.openswiftui", category: "appintent") enum Error: Swift.Error { diff --git a/Sources/OpenSwiftUI/Event/Gesture/ButtonGesture.swift b/Sources/OpenSwiftUI/Event/Gesture/ButtonGesture.swift new file mode 100644 index 000000000..00f4451ea --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Gesture/ButtonGesture.swift @@ -0,0 +1,467 @@ +// +// ButtonGesture.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 2218E1141B3D7C3A65B6697591AFB638 (SwiftUI) + +import Foundation +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +public import OpenSwiftUICore + +// MARK: - _ButtonGesture + +/// Recognizes the standard "button" gesture: press-and-release, with no time +/// limits. Moving in and out of the region while pressed will toggle the +/// "pressing" state of the `pressingAction` handler. +@available(OpenSwiftUI_v1_0, *) +public struct _ButtonGesture: PrimitiveGesture, PubliclyPrimitiveGesture, Gesture { + /// The action to dispatch. + public var action: () -> Void + + /// The action to dispatch when the "pressing" state changes. + public var pressingAction: ((Bool) -> Void)? + + var touchDelay: Double? + + public init( + action: @escaping () -> Void, + pressing: ((Bool) -> Void)? = nil + ) { + self.action = action + pressingAction = pressing + touchDelay = nil + } + + nonisolated public static func _makeGesture( + gesture: _GraphValue, + inputs: _GestureInputs + ) -> _GestureOutputs { + makeGesture(gesture: gesture, inputs: inputs) + } + + package var internalBody: some Gesture { + StaticIf(ImprovedButtonGestureFeature.self) { + PrimitiveButtonGesture( + pressingAction: pressingAction.map { action in + .withinBounds { phase in + let isPressed = phase == .pressingInside + MainActor.assumeIsolated { + action(isPressed) + } + } + }, + pressedAction: { _ in + MainActor.assumeIsolated { + action() + } + }, + touchDelay: touchDelay + ) + .map { _ in () } + } else: { + LegacyBody(base: self) + } + } + + private struct LegacyBody: Gesture { + @Environment(\.buttonOutset) var outset + + var base: _ButtonGesture + + var body: some Gesture { + let effectiveOutset: CGFloat + if let outset { + effectiveOutset = outset + } else if _GraphInputs.defaultInterfaceIdiom.accepts(.pad) { + effectiveOutset = 25 + } else if _GraphInputs.defaultInterfaceIdiom.accepts(.mac) { + effectiveOutset = 0 + } else { + effectiveOutset = 70 + } + return LegacyButtonGesture( + outset: effectiveOutset, + touchDelay: base.touchDelay ?? 0 + ) + .callbacks(pressing: base.pressingAction, pressed: base.action) + .map { _ in () } + } + } +} + +@available(*, unavailable) +extension _ButtonGesture: Sendable {} + +// MARK: - View + _onButtonGesture + +@available(OpenSwiftUI_v1_0, *) +extension View { + /// Returns a version of `self` that will invoke `action` after + /// recognizing a longPress gesture. + @MainActor + @preconcurrency + public func _onButtonGesture( + pressing: ((Bool) -> Void)? = nil, + perform action: @escaping () -> Void + ) -> some View { + modifier(ButtonActionModifier( + gesture: _ButtonGesture(action: action, pressing: pressing), + action: action + )) + } +} + +// MARK: - ButtonActionModifier + +struct ButtonActionModifier: GestureViewModifier where G: Gesture { + var gesture: G + + var action: () -> Void + + nonisolated static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + var outputs = makeView(modifier: modifier, inputs: inputs, body: body) + if inputs.preferences.requiresPlatformItemList, + inputs.platformItemListFlags.contains(.selection) { + outputs.transformPlatformItemList( + inputs: inputs, + transform: Attribute(PlatformSelectionBehavior( + modifier: modifier.value, + isEnabled: inputs.isEnabled, + springLoadingBehavior: inputs.base.springLoadingBehavior + )) + ) + } + if inputs.preferences.requiresPlatformItems, + inputs.platformItemFeatures.contains(.selection) { + let action = Attribute(PlatformAction(modifier: modifier.value)) + let selection = Attribute(PlatformButtonActionTransform.SelectionContent( + action: action, + isEnabled: inputs.isEnabled, + springLoadingBehavior: inputs.base.springLoadingBehavior + )) + let transform = Attribute(PlatformButtonActionTransform.MakeTransform( + selection: selection + )) + PlatformButtonActionTransform.transformPlatformItemsOutputs( + &outputs, + inputs: inputs, + modifier: _GraphValue(transform) + ) + } + return outputs + } + + private struct PlatformSelectionBehavior: Rule { + @Attribute var modifier: ButtonActionModifier + @Attribute var isEnabled: Bool + @Attribute var springLoadingBehavior: SpringLoadingBehavior + + var value: (inout PlatformItemList) -> Void { + let modifier = modifier + let isEnabled = isEnabled + let springLoadingBehavior = springLoadingBehavior + return { list in + var item = list.mergedContentItem + item.selectionBehavior = isEnabled + ? .init(onSelect: modifier.action, springLoadingBehavior: springLoadingBehavior) + : .init() + item.isEnabled = isEnabled + list.items = [item] + } + } + } + + private struct PlatformAction: Rule { + @Attribute var modifier: ButtonActionModifier + + var value: (() -> Void)? { + modifier.action + } + } +} + +// MARK: - ButtonOutsetKey + +private struct ButtonOutsetKey: EnvironmentKey { + static var defaultValue: CGFloat? { nil } +} + +extension EnvironmentValues { + var buttonOutset: CGFloat? { + get { self[ButtonOutsetKey.self] } + set { self[ButtonOutsetKey.self] = newValue } + } +} + +extension CachedEnvironment.ID { + fileprivate static let buttonOutset = CachedEnvironment.ID() +} + +extension View { + func buttonOutset(_ outset: CGFloat?) -> some View { + environment(\.buttonOutset, outset) + } +} + +// MARK: - PrimitiveButtonGesture + +struct PrimitiveButtonGesture: PrimitiveGesture { + var pressingAction: ButtonPressingAction? + + var pressedAction: (CGPoint?) -> Void + + var touchDelay: Double? = nil + + nonisolated static func _makeGesture( + gesture: _GraphValue, + inputs: _GestureInputs + ) -> _GestureOutputs { + let child = Attribute(Child( + gesture: gesture.value, + outset: inputs.viewInputs.base.mapEnvironment(id: .buttonOutset) { $0.buttonOutset }, + interfaceIdiom: inputs.viewInputs.base.interfaceIdiom + )) + let outputs = Child.Value.makeDebuggableGesture( + gesture: _GraphValue(child), + inputs: inputs + ) + return outputs.withPhase(Attribute(Phase(phase: outputs.phase))) + } + + typealias Value = Void + + private struct Child: Rule { + @Attribute var gesture: PrimitiveButtonGesture + @Attribute var outset: CGFloat? + var interfaceIdiom: AnyInterfaceIdiom + + var value: some Gesture { + PrimitiveButtonGestureCore(outset: effectiveOutset, touchDelay: gesture.touchDelay) + .callbacks(PrimitiveButtonGestureCallbacks( + pressingAction: gesture.pressingAction, + pressedAction: gesture.pressedAction + )) + .requiredTapCount(1) + } + + var effectiveOutset: CGFloat { + if let outset { + return outset + } else if interfaceIdiom.accepts(.pad) { + return 25 + } else if interfaceIdiom.accepts(.mac) { + return 0 + } else { + return 70 + } + } + } + + private struct Phase: Rule { + @Attribute var phase: GesturePhase + + var value: GesturePhase { + phase.withValue(()) + } + } +} + +// MARK: - ButtonPressingAction + +enum ButtonPressingAction { + case withinBounds((ButtonPressPhase) -> Void) + case simple((Bool) -> Void) +} + +// MARK: - ButtonPressPhase + +enum ButtonPressPhase: Hashable { + case inactive + case pressingOutside + case pressingInside + case pressedInside +} + +// MARK: - LegacyButtonGesture + +private struct LegacyButtonGesture: Gesture { + var outset: CGFloat + + var touchDelay: Double + + var body: some Gesture { + SizeGesture { size in + EventListener() + .delayed(by: touchDelay) { $0.kind == .touch } + .mapPhase { phase in + let bounds = CGRect(origin: .zero, size: size) + .insetBy(dx: -outset, dy: -outset) + switch phase { + case .possible(nil): + return .possible(nil) + case let .possible(event?), let .active(event): + let value = Value( + location: event.location, + timestamp: event.timestamp, + inside: bounds.contains(event.location) + ) + return .possible(value.inside ? value : nil) + case let .ended(event): + let value = Value( + location: event.location, + timestamp: event.timestamp, + inside: bounds.contains(event.location) + ) + return value.inside ? .ended(value) : .failed + case .failed: + return .failed + } + } + } + .dependency(.failIfActive) + .eventFilter(forType: MouseEvent.self) { $0.button == .primary } + .eventFilter(forType: SpatialEvent.self) { $0.kind != .pan } + } + + struct Value: PressableEventValue { + var location: CGPoint + var timestamp: Time + var inside: Bool + + static func isPressing(_ phase: GesturePhase) -> Bool { + phase.unwrapped?.inside ?? false + } + } +} + +// MARK: - PrimitiveButtonGestureCore + +private struct PrimitiveButtonGestureCore: Gesture { + var outset: CGFloat + + var touchDelay: Double? + + var body: some Gesture { + SizeGesture { size in + EventListener() + .delayed(by: touchDelay ?? 0.012) { $0.kind == .touch } + .mapPhase { phase in + let bounds = CGRect(origin: .zero, size: size) + let value = phase.map { event in + let locationInBounds: LocationInBounds + if bounds.contains(event.location) { + locationInBounds = .inside + } else if bounds.insetBy(dx: -outset, dy: -outset).contains(event.location) { + locationInBounds = .insideExtended + } else { + locationInBounds = .outsideExtended + } + return Value( + location: event.location, + timestamp: event.timestamp, + locationInBounds: locationInBounds + ) + } + if case let .ended(event) = value, event.locationInBounds == .outsideExtended { + return .failed + } + return value + } + } + .dependency(.failIfActive) + .eventFilter(forType: MouseEvent.self) { $0.button == .primary } + .eventFilter(forType: SpatialEvent.self) { $0.kind != .pan } + .cancellable() + } + + struct Value: Equatable { + var location: CGPoint + var timestamp: Time + var locationInBounds: LocationInBounds + } + + enum LocationInBounds: Hashable { + case inside + case insideExtended + case outsideExtended + } +} + +// MARK: - PrimitiveButtonGestureCallbacks + +private struct PrimitiveButtonGestureCallbacks: GestureCallbacks { + var pressingAction: ButtonPressingAction? + + var pressedAction: (CGPoint?) -> Void + + typealias Value = PrimitiveButtonGestureCore.Value + + static var initialState: ButtonPressPhase { .inactive } + + func dispatch(phase: GesturePhase, state: inout ButtonPressPhase) -> (() -> Void)? { + switch phase { + case .possible: + return nil + case .active: + let newState = pressPhase(phase) + guard newState != state else { + return nil + } + let oldState = state + state = newState + switch pressingAction { + case let .withinBounds(action): + return { action(newState) } + case let .simple(action): + let wasPressing = oldState == .pressingOutside || oldState == .pressingInside + let isPressing = newState == .pressingOutside || newState == .pressingInside + return wasPressing && isPressing ? nil : { action(isPressing) } + case nil: + return nil + } + case let .ended(value): + let reset = cancel(state: state) + state = .inactive + return { + reset?() + pressedAction(value.location) + } + case .failed: + let reset = cancel(state: state) + state = .inactive + return reset + } + } + + func cancel(state: ButtonPressPhase) -> (() -> Void)? { + switch pressingAction { + case let .withinBounds(action) where state == .pressingInside: + return { action(.inactive) } + case let .simple(action) where state == .pressingOutside || state == .pressingInside: + return { action(false) } + default: + return nil + } + } + + func pressPhase(_ phase: GesturePhase) -> ButtonPressPhase { + guard let value = phase.unwrapped else { + return .inactive + } + switch value.locationInBounds { + case .inside: + return phase.isEnded ? .pressedInside : .pressingInside + case .insideExtended: + return .pressingInside + case .outsideExtended: + return .pressingOutside + } + } +} diff --git a/Sources/OpenSwiftUI/Event/Gesture/PressableGestureCallbacks.swift b/Sources/OpenSwiftUI/Event/Gesture/PressableGestureCallbacks.swift new file mode 100644 index 000000000..fa302f32b --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Gesture/PressableGestureCallbacks.swift @@ -0,0 +1,83 @@ +// +// PressableGestureCallbacks.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: C70334A42970E36EF599A57E69899EA7 (SwiftUI) + +import Foundation +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +// MARK: - PressableEventValue + +protocol PressableEventValue: Equatable { + var pressLocation: CGPoint? { get } + + static func isPressing(_ phase: GesturePhase) -> Bool +} + +extension PressableEventValue { + var pressLocation: CGPoint? { nil } +} + +// MARK: - Gesture + PressableEventValue + +extension Gesture where Value: PressableEventValue { + func callbacks( + pressing: ((Bool) -> Void)?, + pressed: (() -> Void)? + ) -> some Gesture { + callbacks(PressableGestureCallbacks( + pressing: pressing, + pressed: { _ in pressed?() } + )) + .cancellable() + } +} + +// MARK: - PressableGestureCallbacks + +private struct PressableGestureCallbacks: GestureCallbacks where Value: PressableEventValue { + var pressing: ((Bool) -> Void)? + + var pressed: ((CGPoint?) -> Void)? + + static var initialState: Bool { false } + + func dispatch(phase: GesturePhase, state: inout Bool) -> (() -> Void)? { + switch phase { + case let .ended(value): + let wasPressing = state + state = false + if wasPressing, let pressing { + if let pressed { + return { + pressing(false) + pressed(value.pressLocation) + } + } else { + return { pressing(false) } + } + } else { + return bind(pressed, value.pressLocation) + } + case .failed: + let wasPressing = state + state = false + return wasPressing ? bind(pressing, false) : nil + default: + let isPressing = Value.isPressing(phase) + guard isPressing != state else { + return nil + } + state = isPressing + return bind(pressing, isPressing) + } + } + + func cancel(state: Bool) -> (() -> Void)? { + state ? bind(pressing, false) : nil + } +} diff --git a/Sources/OpenSwiftUI/Event/Platform/AppKit/AppKitKeyEventConversions.swift b/Sources/OpenSwiftUI/Event/Platform/AppKit/AppKitKeyEventConversions.swift index ee007c1be..489859da6 100644 --- a/Sources/OpenSwiftUI/Event/Platform/AppKit/AppKitKeyEventConversions.swift +++ b/Sources/OpenSwiftUI/Event/Platform/AppKit/AppKitKeyEventConversions.swift @@ -38,4 +38,33 @@ extension EventModifiers { self = modifiers } } + +// MARK: - NSEvent.ModifierFlags + EventModifiers + +extension NSEvent.ModifierFlags { + init(_ modifiers: EventModifiers) { + self = [] + if modifiers.contains(.capsLock) { + insert(.capsLock) + } + if modifiers.contains(.shift) { + insert(.shift) + } + if modifiers.contains(.control) { + insert(.control) + } + if modifiers.contains(.option) { + insert(.option) + } + if modifiers.contains(.command) { + insert(.command) + } + if modifiers.contains(.numericPad) { + insert(.numericPad) + } + if modifiers.contains(._function) { + insert(.function) + } + } +} #endif diff --git a/Sources/OpenSwiftUI/Integration/Graphic/AppKit/AppKitViewEnvironment.swift b/Sources/OpenSwiftUI/Integration/Graphic/AppKit/AppKitViewEnvironment.swift new file mode 100644 index 000000000..2810b4279 --- /dev/null +++ b/Sources/OpenSwiftUI/Integration/Graphic/AppKit/AppKitViewEnvironment.swift @@ -0,0 +1,47 @@ +// +// AppKitViewEnvironment.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: TBA + +#if os(macOS) +import AppKit +import COpenSwiftUI +import OpenSwiftUICore + +extension NSView { + func adoptEnvironment(_ environment: EnvironmentValues, hostedSubview: NSView) { + let appearance = NSAppearance.appearance(from: environment, allowsVibrantBlending: nil) + if self.appearance != appearance { + self.appearance = appearance + } + let layoutDirection: NSUserInterfaceLayoutDirection = environment.layoutDirection == .rightToLeft + ? .rightToLeft : .leftToRight + if userInterfaceLayoutDirection != layoutDirection { + userInterfaceLayoutDirection = layoutDirection + } + if !_userInterfaceLayoutDirectionPropagatesToDescendants() { + _setUserInterfaceLayoutDirectionPropagates(toDescendants: true) + } + // TODO: GroupedFormStyle and sidebar cell semantic context + _setVibrantBlendingStyle(forSubtree: environment.backgroundMaterial == nil ? 1 : 2) + if UnifiedHitTestingFeature.isEnabled, !ResponderBasedHitTesting.isEnabled, + let view = self as? any RecursiveIgnoreHitTestCustomizing, + view.recursiveIgnoreHitTest == environment.isEnabled { + view.recursiveIgnoreHitTest = !environment.isEnabled + } + if let control = hostedSubview as? NSControl { + if control.isEnabled != environment.isEnabled { + control.isEnabled = environment.isEnabled + } + let size: NSControl.ControlSize = environment.controlSize == .extraLarge + ? .large : NSControl.ControlSize(environment.controlSize) + if control.controlSize != size { + control.controlSize = size + } + // TODO: NSControl.updateFontForControlSize(in:) for clients linked before v2_3 + } + } +} +#endif diff --git a/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemList.swift b/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemList.swift index 3dd776207..d4d06d316 100644 --- a/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemList.swift +++ b/Sources/OpenSwiftUI/Integration/PlatformItemList/PlatformItemList.swift @@ -7,6 +7,9 @@ // ID: CE84B1BFBEAEAB6361605407E54625A3 (SwiftUI) import Foundation +#if os(macOS) +import AppKit +#endif import OpenAttributeGraphShims @_spi(ForOpenSwiftUIOnly) @_spi(Private) @@ -131,7 +134,30 @@ package struct PlatformItemList { self.systemItem = systemItem } - struct SelectionBehavior {} + struct SelectionBehavior { + var isMomentary: Bool = true + var isContainerSelection: Bool = false + var yieldsToContainerSelection: Bool = false + var isPickerOption: Bool = false + var visualStyle: VisualStyle = .plain + var onSelect: (() -> Void)? + var onDeselect: (() -> Void)? + #if os(macOS) + var onActivate: ((NSEvent?) -> Bool)? + #else + var onActivate: ((Void?) -> Bool)? + #endif + #if canImport(ObjectiveC) + var platformSelector: Selector? + #endif + var springLoadingBehavior: SpringLoadingBehavior = .automatic + + enum VisualStyle: Hashable { + case plain + case checkmark + case selected + } + } struct SecondaryNavigationBehavior {} diff --git a/Sources/OpenSwiftUI/View/Configuration/ViewInputPredicate+View.swift b/Sources/OpenSwiftUI/View/Configuration/ViewInputPredicate+View.swift index bd6111c60..4469fd06f 100644 --- a/Sources/OpenSwiftUI/View/Configuration/ViewInputPredicate+View.swift +++ b/Sources/OpenSwiftUI/View/Configuration/ViewInputPredicate+View.swift @@ -35,4 +35,8 @@ extension View { nonisolated func input(_ flag: Flag.Type) -> some View where Flag: ViewInputBoolFlag { modifier(ViewInputFlagModifier(flag: flag.init())) } + + nonisolated func inputFalse(_ flag: Flag.Type) -> some View where Flag: ViewInputBoolFlag { + modifier(FalseViewInputBoolFlagModifier(flag: flag.init())) + } } diff --git a/Sources/OpenSwiftUI/View/Control/Button/AppKitButton.swift b/Sources/OpenSwiftUI/View/Control/Button/AppKitButton.swift new file mode 100644 index 000000000..efb17ab2d --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/Button/AppKitButton.swift @@ -0,0 +1,911 @@ +// +// AppKitButton.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: TBA +// ID: 9FEBA96B0BC70E1682E82D239F242E73 (SwiftUI) + +#if os(macOS) +import AppKit +import COpenSwiftUI +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +// MARK: - AllowsVerticallyFlexibleMacButtons + +struct AllowsVerticallyFlexibleMacButtons: SemanticFeature { + static let introduced: Semantics = .v5 +} + +// MARK: - AppKitButtonStyle + +struct AppKitButtonStyle: PrimitiveButtonStyle { + var appearance: AppKitButtonAppearance + + func makeBody(configuration: Configuration) -> some View { + Content(appearance: appearance, configuration: configuration) + } + + struct Content: EnvironmentalView { + var appearance: AppKitButtonAppearance + var configuration: PrimitiveButtonStyleConfiguration + + func body(environment: EnvironmentValues) -> some View { + AppKitButton( + configuration: AppKitButtonConfiguration( + role: configuration.role, + action: configuration.trigger, + appearance: appearance.resolve(in: environment), + isOn: environment.defaultToggleIsOn + ), + label: configuration.label + ) + .coordinateSpace(.id(focusCoordinateSpace)) + } + } +} + +// MARK: - AppKitButtonAppearance + +struct AppKitButtonAppearance { + let bezelStyle: NSButton.BezelStyle + let isBordered: Bool + fileprivate let tint: Tint? + let showsBorderOnHover: Bool + fileprivate let heightBehavior: HeightBehavior + let isInToolbar: Bool + + init(bezelStyle: NSButton.BezelStyle, tint: Color?, isInToolbar: Bool) { + self.bezelStyle = bezelStyle + isBordered = true + self.tint = tint.map(Tint.bezel) + showsBorderOnHover = bezelStyle == .accessoryBar + switch bezelStyle.rawValue { + case 1, 5, 7, 9, 11, 12, 13, 14: heightBehavior = .fixed + case 28: heightBehavior = .fixedWithFlexibleFallback + default: heightBehavior = .flexible + } + self.isInToolbar = isInToolbar + } + + private init( + bezelStyle: NSButton.BezelStyle, isBordered: Bool, tint: Tint?, + showsBorderOnHover: Bool, heightBehavior: HeightBehavior, isInToolbar: Bool + ) { + self.bezelStyle = bezelStyle + self.isBordered = isBordered + self.tint = tint + self.showsBorderOnHover = showsBorderOnHover + self.heightBehavior = heightBehavior + self.isInToolbar = isInToolbar + } + + static func borderless(tint: Color?) -> AppKitButtonAppearance { + AppKitButtonAppearance( + bezelStyle: .flexiblePush, isBordered: false, tint: tint.map(Tint.content), + showsBorderOnHover: false, heightBehavior: .flexible, isInToolbar: false + ) + } + + static let accessoryBarAction = AppKitButtonAppearance( + bezelStyle: .accessoryBarAction, tint: nil, isInToolbar: false + ) + + static let link = AppKitButtonAppearance( + bezelStyle: .flexiblePush, isBordered: false, tint: .content(Color(appearanceName: "linkColor")), + showsBorderOnHover: false, heightBehavior: .flexible, isInToolbar: false + ) + + fileprivate enum Tint { + case bezel(Color) + case content(Color) + } + + fileprivate enum HeightBehavior: Hashable { + case fixed + case fixedWithFlexibleFallback + case flexible + } + + fileprivate struct Resolved { + let bezelStyle: NSButton.BezelStyle + let isBordered: Bool + let tint: Tint? + let showsBorderOnHover: Bool + let heightBehavior: HeightBehavior + let isInToolbar: Bool + + enum Tint { + case bezel(Color.Resolved) + case content(Color.Resolved) + } + + func updateEnvironment(_ environment: inout EnvironmentValues) { + guard isInToolbar else { return } + if environment.controlSize == .large { + environment.symbolFont = .system(size: NSFont.systemFontSize, weight: .medium) + environment.imageScale = .large + } else { + environment.imageScale = .medium + } + } + + func apply(to button: NSButton) { + button.bezelStyle = bezelStyle + switch tint { + case .bezel(let color): + button.bezelColor = (color.kitColor as! NSColor) + button.contentTintColor = nil + case .content(let color): + button.bezelColor = nil + button.contentTintColor = (color.kitColor as! NSColor) + case nil: + button.bezelColor = nil + button.contentTintColor = nil + } + button.isBordered = isBordered + button.showsBorderOnlyWhileMouseInside = showsBorderOnHover + } + } + + fileprivate func resolve(in environment: EnvironmentValues) -> Resolved { + let resolvedTint: Resolved.Tint? + switch tint { + case .bezel(let color): resolvedTint = .bezel(color.resolve(in: environment)) + case .content(let color): resolvedTint = .content(color.resolve(in: environment)) + case nil: resolvedTint = nil + } + return Resolved( + bezelStyle: bezelStyle, isBordered: isBordered, tint: resolvedTint, + showsBorderOnHover: showsBorderOnHover, heightBehavior: heightBehavior, + isInToolbar: isInToolbar + ) + } +} + +// MARK: - AppKitButtonConfiguration + +private struct AppKitButtonConfiguration { + var role: ButtonRole? + var action: () -> Void + var appearance: AppKitButtonAppearance.Resolved + var isOn: Bool? +} + +// MARK: - NSButtonResponder + +private class NSButtonResponder: PlatformUnaryViewResponder { + var configuration: AppKitButtonConfiguration + + init(layoutResponder: DefaultLayoutViewResponder, configuration: AppKitButtonConfiguration) { + self.configuration = configuration + super.init(layoutResponder: layoutResponder) + } + + override func addContentPath( + to path: inout Path, kind: ContentShapeKinds, in space: CoordinateSpace, + observer: (any ContentPathObserver)? + ) { + if configuration.appearance.isBordered { + super.addContentPath(to: &path, kind: kind, in: space, observer: observer) + } else { + layoutResponder.addContentPath(to: &path, kind: kind, in: space, observer: observer) + } + } +} + +// MARK: - OpenSwiftUIAppKitButton + +private class OpenSwiftUIAppKitButton: NSButton, RecursiveIgnoreHitTestCustomizing, AcceptsFirstMouseCustomizing { + let viewType: Any.Type + var configuration: AppKitButtonConfiguration? + var repeatTiming: ButtonRepeatTiming? + var repeatState: RepeatState? + var recursiveIgnoreHitTest = false + var customAcceptsFirstMouse: Bool? + + struct RepeatState { + var timer: Timer + var hasTriggeredInitialAction: Bool + } + + init(viewType: Any.Type, contentStyleSignal: WeakAttribute) { + self.viewType = viewType + let host = ContentViewHost(contentStyleSignal: contentStyleSignal) + super.init(frame: .zero) + title = "" + imagePosition = .noImage + bezelStyle = .push + lineBreakMode = .byTruncatingTail + setButtonType(.momentaryPushIn) + host.ignoreContentStyleUpdates = true + contentView = host + cell?.setAccessibilityElement(false) + addTarget(self, action: #selector(didReleaseButton(_:)), forControlEvents: 0x40) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update( + configuration: AppKitButtonConfiguration, keyboardShortcut: KeyboardShortcut?, + springLoading: SpringLoadingBehavior, buttonRepeatTiming: ButtonRepeatTiming?, + forceDestructiveAppearance: Bool, allowsWindowActivationEvents: Bool? + ) { + let host = contentView as! ContentViewHost + host.ignoreContentStyleUpdates = true + font = nil + configuration.appearance.apply(to: self) + hasDestructiveAction = configuration.role == .destructive + _setUsesCautionaryAppearance(whenActionIsDestructive: forceDestructiveAppearance) + if let keyboardShortcut { + let key = String(keyboardShortcut.key.character) + keyEquivalent = isLinkedOnOrAfter(.v3) ? key.lowercased() : key + keyEquivalentModifierMask = NSEvent.ModifierFlags(keyboardShortcut.modifiers) + } else { + keyEquivalent = "" + keyEquivalentModifierMask = [] + } + isSpringLoaded = springLoading == .enabled + let cell = cell as! NSButtonCell + let stateMask: NSCell.StyleMask + if let isOn = configuration.isOn { + cell.state = isOn ? .on : .off + stateMask = .contentsCellMask + } else { + stateMask = [] + } + if cell.showsStateBy != stateMask { cell.showsStateBy = stateMask } + repeatTiming = buttonRepeatTiming + let targets: [(Selector, UInt64)] = [ + (#selector(didPressButton(_:)), 0x1), + (#selector(didCancelButton(_:)), 0x180), + (#selector(didExitButton(_:)), 0x20), + (#selector(didReenterButton(_:)), 0x10), + ] + for (action, events) in targets { + if buttonRepeatTiming != nil { + addTarget(self, action: action, forControlEvents: events) + } else { + removeTarget(self, action: action, forControlEvents: events) + } + } + self.configuration = configuration + customAcceptsFirstMouse = allowsWindowActivationEvents + host.ignoreContentStyleUpdates = false + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateCell(cell!) + } + + override func menu(for event: NSEvent) -> NSMenu? { + (enclosingViewRendererHost as? NSView)?.menu(for: event) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + if !ResponderBasedHitTesting.isEnabled { + guard !recursiveIgnoreHitTest, + alphaValue >= ViewResponder.minOpacityForHitTest else { return nil } + } + return super.hitTest(point) == nil ? nil : self + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + let accepts = ResponderBasedHitTesting.isEnabled + ? customAcceptsFirstMouse : effectiveAcceptsFirstMouse + return accepts ?? super.acceptsFirstMouse(for: event) + } + + override func designatedFocusRing() -> NSView? { + isBordered ? super.designatedFocusRing() : contentView + } + + @objc func didReleaseButton(_ sender: NSButton) { + repeatState?.timer.invalidate() + if let configuration, repeatState?.hasTriggeredInitialAction != true { + Update.enqueueAction(reason: .didReleaseButton) { configuration.action() } + } + repeatState = nil + } + + @objc func didCancelButton(_ sender: NSButton) { + repeatState?.timer.invalidate() + repeatState = nil + } + + @objc func didExitButton(_ sender: NSButton) { repeatState?.timer.invalidate() } + + @objc func didReenterButton(_ sender: NSButton) { + if repeatState != nil, let repeatTiming { + scheduleRepeatTimer(timingIterator: repeatTiming.makeIterator(), isInitial: true) + } + } + + @objc func didPressButton(_ sender: NSButton) { + if let repeatTiming { + scheduleRepeatTimer(timingIterator: repeatTiming.makeIterator(), isInitial: true) + } + } + + func scheduleRepeatTimer(timingIterator: I, isInitial: Bool) where I.Element == Double { + var iterator = timingIterator + guard let interval = iterator.next() else { return } + let timer = Timer(timeInterval: interval, repeats: false) { [weak self] _ in + guard let self else { return } + self.repeatState?.hasTriggeredInitialAction = true + self.configuration?.action() + self.scheduleRepeatTimer(timingIterator: iterator, isInitial: false) + } + RunLoop.main.add(timer, forMode: .common) + if repeatState != nil { + repeatState?.timer = timer + } else { + repeatState = RepeatState(timer: timer, hasTriggeredInitialAction: false) + } + } + + class ContentViewHost: NSView { + let contentStyleSignal: WeakAttribute + var ignoreContentStyleUpdates = false + var icsHeight = NSView.noIntrinsicMetric + var firstBaselineOffset = CGFloat.leastNormalMagnitude + var lastBaselineOffset = CGFloat.leastNormalMagnitude + final lazy var helper = FocusRingHelper(responder: nil, focusRingView: self) + + init(contentStyleSignal: WeakAttribute) { + self.contentStyleSignal = contentStyleSignal + super.init(frame: .zero) + clipsToBounds = false + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override var focusRingMaskBounds: NSRect { helper.focusRingMaskBounds } + override func drawFocusRingMask() { helper.drawFocusRingMask() } + override var isFlipped: Bool { true } + override func setFlipped(_ flipped: Bool) {} + + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: icsHeight) + } + + override func _baselineOffsets(at size: CGSize) -> BaselineOffset { + BaselineOffset(firstTextBaseline: firstBaselineOffset, lastTextBaseline: lastBaselineOffset) + } + + override var contentStyle: (any NSContentStyle)? { + get { super.contentStyle } + set { + super.contentStyle = newValue + guard !ignoreContentStyleUpdates, let attribute = contentStyleSignal.attribute else { return } + attribute.graph.viewGraph().asyncTransaction( + invalidating: contentStyleSignal, style: .deferred, mayDeferUpdate: false + ) + } + } + + func update(height: CGFloat, firstBaseline: CGFloat, lastBaseline: CGFloat) { + if icsHeight != height { + icsHeight = height + invalidateIntrinsicContentSize() + superview?.needsLayout = true + } + if firstBaselineOffset != firstBaseline { + firstBaselineOffset = firstBaseline + superview?.needsLayout = true + } + if lastBaselineOffset != height - lastBaseline { + lastBaselineOffset = height - lastBaseline + superview?.needsLayout = true + } + } + } +} + +extension OpenSwiftUIAppKitButton: PlatformFocusViewProvider { + var focusView: NSView { self } +} + +extension OpenSwiftUIAppKitButton: PlatformGroupFactory { + func makePlatformGroup() -> AnyObject? { self } + func needsUpdateFor(newValue: any PlatformGroupFactory) -> Bool { false } + func updatePlatformGroup(_ view: inout AnyObject) {} + func platformGroupContainer(_ view: AnyObject) -> AnyObject { contentView! } + + func renderPlatformGroup( + _ list: DisplayList, in ctx: GraphicsContext, size: CGSize, renderer: DisplayList.GraphicsRenderer + ) { + Update.syncMain { + renderer.renderPlatformView(self, in: ctx, size: size, viewType: NSButton.self) + } + } +} + +extension OpenSwiftUIAppKitButton.ContentViewHost: CustomRecursiveStringConvertible { + var descriptionAttributes: [(name: String, value: String)] { + guard superview != nil, _TestApp.host != nil, + _TestApp.isIntending(to: .includeBaselines) else { return [] } + return [ + ("firstBaseline", firstBaselineOffset.description), + ("lastBaseline", lastBaselineOffset.description), + ] + } + + var defaultDescriptionAttributes: Set { [] } + + var descriptionChildren: [any CustomRecursiveStringConvertible] { + subviews.compactMap { $0 as? any CustomRecursiveStringConvertible } + } +} + +// MARK: - NSButton.Metrics + +extension NSButton { + fileprivate struct Metrics { + var size: CGSize + var alignmentInsets: EdgeInsets + var contentInsets: EdgeInsets + } + + fileprivate var metrics: Metrics { + var size = CGSize.zero + var alignment = NSEdgeInsetsZero + var content = NSEdgeInsetsZero + Update.syncMain { + if isBordered { + _ = _getIntrinsicArtworkSize( + &size, alignmentRectInsets: &alignment, + idealContentInsets: &content, maxContentInsets: nil + ) + } + } + let alignmentInsets = EdgeInsets(top: alignment.top, leading: alignment.left, bottom: alignment.bottom, trailing: alignment.right) + let contentInsets = EdgeInsets(top: content.top, leading: content.left, bottom: content.bottom, trailing: content.right) + return Metrics(size: size, alignmentInsets: alignmentInsets, contentInsets: contentInsets.adding(alignmentInsets.negatedInsets)) + } +} + +// MARK: - AppKitButton + +private struct AppKitButton: PrimitiveView, UnaryView { + var configuration: AppKitButtonConfiguration + var label: Label + + static func _makeView(view: _GraphValue, inputs: _ViewInputs) -> _ViewOutputs { + let signal = Attribute(value: ()) + let configuration = view[\.configuration].value + let position = inputs.animatedPosition() + let size = inputs.animatedSize() + let needsDisplayList = inputs.preferences.requiresDisplayList + let isInToolbar = needsDisplayList && inputs.base.accepts(.toolbar) + var nativeButton: OpenSwiftUIAppKitButton! + Update.syncMain { + nativeButton = OpenSwiftUIAppKitButton(viewType: Self.self, contentStyleSignal: WeakAttribute(signal)) + } + var button = Attribute(UpdatedButton( + button: nativeButton, + configuration: configuration, + environment: inputs.environment, + keyboardShortcut: inputs.base.keyboardShortcut, + springLoading: inputs.base.mapEnvironment(id: .springLoadingBehavior) { + $0.springLoadingBehavior + }, + buttonRepeatTiming: inputs.base.mapEnvironment(id: .effectiveButtonRepeatTiming) { + $0.effectiveButtonRepeatTiming + }, + forceDestructiveAppearance: inputs.base.mapEnvironment(id: .enforceButtonDestructiveRoleAppearance) { + $0.enforceButtonDestructiveRoleAppearance + }, + allowsWindowActivationEvents: inputs.base.mapEnvironment(id: .allowsWindowActivationEvents) { + $0.allowsWindowActivationEvents + } + )) + var childInputs = inputs + var frame: Attribute? + if inputs.needsGeometry { + let contentFrame = Attribute(ButtonContentFrame( + button: button, configuration: configuration, position: position, + size: size, pixelLength: inputs.pixelLength, contentComputer: .init() + )) + frame = contentFrame + childInputs.requestsLayoutComputer = true + childInputs.size = contentFrame[keyPath: \.frame.size] + childInputs.position = contentFrame[keyPath: \.frame.origin] + childInputs.containerPosition = contentFrame[keyPath: \.containerPosition] + childInputs.safeAreaInsets = .init() + } + if isInToolbar { + childInputs.addPlatformItemListKey(flags: TextPlatformItemListFlags.self, editOperation: .formUnion) + } + let environment = Attribute(ButtonContentEnvironment(button: button, environment: inputs.environment)) + environment.addInput(signal, options: ._4, token: 0) + environment.flags = .transactional + childInputs.environment = environment + let label = _GraphValue(LabelChild( + label: view[\.label].value, configuration: configuration, + isInTouchBar: inputs.base.interfaceIdiom.accepts(.touchBar) + )) + var outputs = LabelChild.Value.makeDebuggableView(view: label, inputs: childInputs) + let contentComputer = outputs.layoutComputer + if let frame, let contentComputer { + frame.mutateBody(as: ButtonContentFrame.self, invalidating: true) { + $0.$contentComputer = contentComputer + } + } + let metrics = Attribute(ButtonContentMetrics( + button: button, size: size, contentComputer: .init(contentComputer) + )) + if isInToolbar, let list = outputs.preferences.platformItemList { + button = Attribute(UpdatedButton.WithContainsText(button: button, platformItemList: list)) + } + if inputs.requestsLayoutComputer { + outputs.layoutComputer = Attribute(ButtonLayoutComputer( + button: button, configuration: configuration, contentComputer: .init(contentComputer) + )) + } + if inputs.preferences.requiresViewResponders { + outputs[ViewRespondersKey.self] = Attribute(ButtonResponder( + button: button, position: position, size: size, transform: inputs.transform, + children: outputs.viewResponders(), configuration: configuration, + layoutResponder: DefaultLayoutViewResponder(inputs: inputs) + )) + } + if needsDisplayList { + let identity = DisplayList.Identity() + inputs.pushIdentity(identity) + outputs.displayList = Attribute(ButtonDisplayList( + identity: identity, button: metrics, configuration: configuration, + position: position, size: size, containerPosition: inputs.containerPosition, + contentList: .init(outputs.displayList) + )) + } + // TODO: AccessibilityPlatformViewModifier + return outputs + } + + struct LabelChild: Rule { + @Attribute var label: Label + @Attribute var configuration: AppKitButtonConfiguration + var isInTouchBar: Bool + + var value: some View { + HStack(spacing: isInTouchBar ? 6 : 4) { + label.lineLimit(configuration.appearance.heightBehavior == .flexible ? nil : 1) + } + .multilineTextAlignment(.center) + .buttonDefaultRenderingMode() + } + } +} + +// MARK: - ButtonContentEnvironment + +private extension CachedEnvironment.ID { + static let springLoadingBehavior = CachedEnvironment.ID() + static let effectiveButtonRepeatTiming = CachedEnvironment.ID() + static let enforceButtonDestructiveRoleAppearance = CachedEnvironment.ID() + static let allowsWindowActivationEvents = CachedEnvironment.ID() +} + +private struct ButtonContentEnvironment: Rule { + @Attribute var button: OpenSwiftUIAppKitButton + @Attribute var environment: EnvironmentValues + + var value: EnvironmentValues { + var environment = environment + button.configuration?.appearance.updateEnvironment(&environment) + let contentView = button.contentView as! OpenSwiftUIAppKitButton.ContentViewHost + let style = ButtonContentStyle(contentStyle: contentView.contentStyle, seed: 0, button: button) + environment.defaultForegroundStyle = style.copyStyle(in: environment) + return environment + } +} + +// MARK: - ButtonContentFrame + +private struct ButtonContentFrame: Rule { + @Attribute var button: OpenSwiftUIAppKitButton + @Attribute var configuration: AppKitButtonConfiguration + @Attribute var position: CGPoint + @Attribute var size: ViewSize + @Attribute var pixelLength: CGFloat + @OptionalAttribute var contentComputer: LayoutComputer? + + struct Value { + var frame: ViewFrame + var containerPosition: CGPoint + } + + var value: Value { + let insets = button.metrics.contentInsets + let containerPosition = position.offset(by: insets) + let available = CGRect(origin: .zero, size: size.value).inset(by: insets).size + let proposal = _ProposedSize(available) + let contentSize = (contentComputer ?? .defaultValue).sizeThatFits(proposal) + var origin = containerPosition + if configuration.appearance.bezelStyle == .circular { + origin.x += contentSize.centeredIn(available).origin.x + } + var frame = ViewFrame(origin: origin, size: ViewSize(contentSize, proposal: proposal)) + frame.round(toMultipleOf: pixelLength) + return Value(frame: frame, containerPosition: containerPosition) + } +} + +// MARK: - ButtonLayoutComputer + +private struct ButtonLayoutComputer: StatefulRule { + @Attribute var button: OpenSwiftUIAppKitButton + @Attribute var configuration: AppKitButtonConfiguration + @OptionalAttribute var contentComputer: LayoutComputer? + + typealias Value = LayoutComputer + + mutating func updateValue() { + update(to: Engine( + button: button, contentComputer: contentComputer, + heightBehavior: configuration.appearance.heightBehavior + )) + } + + struct Engine: LayoutEngine { + let button: OpenSwiftUIAppKitButton + let contentComputer: LayoutComputer? + let heightBehavior: AppKitButtonAppearance.HeightBehavior + var cache = ViewSizeCache() + + mutating func sizeThatFits(_ proposal: _ProposedSize) -> CGSize { + var result = CGSize.zero + Update.syncMain { + let metrics = button.metrics + if button.bezelStyle == .circular { + result = metrics.size + return + } + let insets = metrics.contentInsets + let proposal = _ProposedSize( + width: proposal.width, + height: heightBehavior == .flexible ? proposal.height : metrics.size.height + ).inset(by: insets) + let computer = contentComputer ?? .defaultValue + let contentSize = cache.get(proposal) { computer.sizeThatFits(proposal) } + let height: CGFloat + switch heightBehavior { + case .fixed: height = metrics.size.height + case .fixedWithFlexibleFallback: height = max(metrics.size.height, contentSize.height + insets.vertical) + case .flexible: height = contentSize.height + insets.vertical + } + result = CGSize(width: contentSize.width + insets.horizontal, height: height) + } + return result + } + + func explicitAlignment(_ key: AlignmentKey, at size: ViewSize) -> CGFloat? { + var result: CGFloat? + Update.syncMain { + if button.isBordered { + let baselines = button._baselineOffsets(at: size.value) + if key == VerticalAlignment.firstTextBaseline.key { + result = baselines.firstTextBaseline.isNaN ? 0 : baselines.firstTextBaseline + } else if key == VerticalAlignment.lastTextBaseline.key { + result = size.height - (baselines.lastTextBaseline.isNaN ? 0 : baselines.lastTextBaseline) + } + } else { + result = (contentComputer ?? .defaultValue).explicitAlignment(key, at: size) + } + } + return result + } + } +} + +// MARK: - UpdatedButton + +private struct UpdatedButton: StatefulRule { + let button: OpenSwiftUIAppKitButton + @Attribute var configuration: AppKitButtonConfiguration + @Attribute var environment: EnvironmentValues + @Attribute var keyboardShortcut: KeyboardShortcut? + @Attribute var springLoading: SpringLoadingBehavior + @Attribute var buttonRepeatTiming: ButtonRepeatTiming? + @Attribute var forceDestructiveAppearance: Bool + @Attribute var allowsWindowActivationEvents: Bool? + + typealias Value = OpenSwiftUIAppKitButton + + mutating func updateValue() { + let (environment, changed) = $environment.changedValue() + if changed { + Graph.withoutUpdate { + button.adoptEnvironment(environment, hostedSubview: button) + } + } + let configuration = configuration + let keyboardShortcut = keyboardShortcut + let springLoading = springLoading + let timing = buttonRepeatTiming + let forceDestructiveAppearance = forceDestructiveAppearance + let allowsWindowActivationEvents = allowsWindowActivationEvents + Graph.withoutUpdate { + button.update( + configuration: configuration, keyboardShortcut: keyboardShortcut, + springLoading: springLoading, buttonRepeatTiming: timing, + forceDestructiveAppearance: forceDestructiveAppearance, + allowsWindowActivationEvents: allowsWindowActivationEvents + ) + } + value = button + } + + struct WithContainsText: StatefulRule { + @Attribute var button: OpenSwiftUIAppKitButton + @Attribute var platformItemList: PlatformItemList + + typealias Value = OpenSwiftUIAppKitButton + + mutating func updateValue() { + let containsText = platformItemList.items.contains { $0.text != nil } + Graph.withoutUpdate { + button.toolbarAppearance = button.configuration?.appearance.isInToolbar == true && containsText ? 2 : 0 + } + value = button + } + } +} + +// MARK: - ButtonContentMetrics + +private struct ButtonContentMetrics: StatefulRule { + @Attribute var button: OpenSwiftUIAppKitButton + @Attribute var size: ViewSize + @OptionalAttribute var contentComputer: LayoutComputer? + + typealias Value = OpenSwiftUIAppKitButton + + mutating func updateValue() { + let insets = button.metrics.contentInsets + let proposal = _ProposedSize(CGRect(origin: .zero, size: size.value).inset(by: insets).size) + let contentSize = (contentComputer ?? .defaultValue).sizeThatFits(proposal) + let baselineSize = ViewSize.fixed(CGSize(width: contentSize.width + insets.horizontal, height: contentSize.height)) + let first = contentComputer?.explicitAlignment(VerticalAlignment.firstTextBaseline.key, at: baselineSize) + let last = contentComputer?.explicitAlignment(VerticalAlignment.lastTextBaseline.key, at: baselineSize) + let button = button + Graph.withoutUpdate { + (button.contentView as! OpenSwiftUIAppKitButton.ContentViewHost).update( + height: contentSize.height, firstBaseline: first ?? .leastNormalMagnitude, + lastBaseline: last ?? .leastNormalMagnitude + ) + } + value = button + } +} + +// MARK: - ButtonDisplayList + +private struct ButtonDisplayList: Rule { + let identity: DisplayList.Identity + @Attribute var button: OpenSwiftUIAppKitButton + @Attribute var configuration: AppKitButtonConfiguration + @Attribute var position: CGPoint + @Attribute var size: ViewSize + @Attribute var containerPosition: CGPoint + @OptionalAttribute var contentList: DisplayList? + + var value: DisplayList { + let insets = button.alignmentRectInsets + let origin = CGPoint(x: position.x - containerPosition.x, y: position.y - containerPosition.y) + let frame = CGRect(origin: origin, size: size.value).outset(by: EdgeInsets( + top: insets.top, leading: insets.left, bottom: insets.bottom, trailing: insets.right + )) + var item = DisplayList.Item( + .effect(.platformGroup(button), contentList ?? DisplayList()), + frame: frame, identity: identity, version: .init(forUpdate: ()) + ) + item.canonicalize() + return DisplayList(item) + } +} + +// MARK: - ButtonResponder + +private struct ButtonResponder: StatefulRule { + @Attribute var button: OpenSwiftUIAppKitButton + @Attribute var position: CGPoint + @Attribute var size: ViewSize + @Attribute var transform: ViewTransform + @Attribute var children: [ViewResponder] + @Attribute var configuration: AppKitButtonConfiguration + var layoutResponder: DefaultLayoutViewResponder + var _buttonResponder: NSButtonResponder? + + typealias Value = [ViewResponder] + + mutating func updateValue() { + let responder = buttonResponder() + responder.hostView = button + responder.configuration = configuration + responder.helper.update( + data: (TrivialContentResponder(), false), size: $size.changedValue(), + position: $position.changedValue(), transform: $transform.changedValue(), parent: responder + ) + layoutResponder.updateChildren($children.changedValue()) + let helper = (button.contentView as! OpenSwiftUIAppKitButton.ContentViewHost).helper + helper.responder = responder + helper.focusRingView?.noteFocusRingMaskChanged() + if !hasValue { value = [responder] } + } + + mutating func buttonResponder() -> NSButtonResponder { + if let _buttonResponder { return _buttonResponder } + let responder = NSButtonResponder(layoutResponder: layoutResponder, configuration: configuration) + _buttonResponder = responder + return responder + } +} + +// MARK: - ButtonContentStyle + +private struct ButtonContentStyle: ShapeStyle, @unchecked Sendable { + var contentStyle: (any NSContentStyle)? + var seed: UInt32 + weak var button: NSButton? + + var fallbackColor: Color? { + guard let contentStyle else { return nil } + return Color(nsColor: NSColor(name: nil) { _ in + contentStyle.equivalentForegroundColorForTemplateImage ?? .clear + }) + } + + func _apply(to shape: inout _ShapeStyle_Shape) { + guard contentStyle != nil else { + HierarchicalShapeStyle.primary._apply(to: &shape) + return + } + switch shape.operation { + case .prepareText: + shape.result = .preparedText(.foregroundKeyColor) + case let .resolveStyle(name, levels): + guard !levels.isEmpty else { return } + let (color, blendMode) = resolve(in: shape.environment) + var resolvedColor = Color.Resolved(color) + resolvedColor.opacity *= shape.opacity(at: levels.lowerBound) + var style = _ShapeStyle_Pack.Style(.color(resolvedColor)) + style.applyBlend(GraphicsBlendMode(BlendMode(blendMode))) + shape.stylePack[name, levels.lowerBound] = style + case .fallbackColor: + if let fallbackColor { shape.result = .color(fallbackColor) } + default: + break + } + } + + func resolve(in environment: EnvironmentValues) -> (CGColor, CGBlendMode) { + let appearance = NSAppearance.appearance(from: environment, allowsVibrantBlending: nil) + ?? NSApp.effectiveAppearance + var color: CGColor? + var blendMode = CGBlendMode.normal + appearance.performAsCurrentDrawingAppearance { + if let contentStyle, let foreground = contentStyle.equivalentForegroundColorForTemplateImage { + color = foreground.cgColor + blendMode = CGBlendMode(rawValue: contentStyle.outputBlendModeForTemplateContent)! + } + } + if let button, + button.responds(to: #selector(NSButton.effectiveVibrancyBlendMode(for:))), + button.effectiveVibrancyBlendMode(for: appearance) == Int32(blendMode.rawValue), + let color { + return (color, .normal) + } + return (color ?? .clear, blendMode) + } + + typealias Resolved = Never +} + +#endif diff --git a/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonSpecs.swift b/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonSpecs.swift new file mode 100644 index 000000000..5665448f7 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonSpecs.swift @@ -0,0 +1,90 @@ +// +// BorderedButtonSpecs.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: B901BF3CF7B6DCD0C05CD02455913DF4 (SwiftUI) + +import OpenSwiftUICore + +// MARK: - BorderedButtonSpec + +struct BorderedButtonSpec: DynamicProperty { + @Environment(\.controlSize) var controlSize: ControlSize + @Environment(\.keyboardShortcut) var shortcut: KeyboardShortcut? + @Environment(\.dynamicTypeSize) var dynamicTypeSize: DynamicTypeSize + + var padding: EdgeInsets { + let horizontal: Double + let vertical: Double + switch controlSize { + case .mini, .small: + horizontal = 10 + vertical = 5 + case .regular: + horizontal = 12 + vertical = 7 + case .large, .extraLarge: + horizontal = 20 + vertical = 15 + } + let extra: Double = switch dynamicTypeSize { + case .accessibility1: 2 + case .accessibility2: 4 + case .accessibility3: 6 + case .accessibility4: 8 + case .accessibility5: 10 + default: 0 + } + return EdgeInsets( + top: vertical, + leading: horizontal + extra, + bottom: vertical, + trailing: horizontal + extra + ) + } + + var defaultFont: Font { + let style: Font.TextStyle = switch controlSize { + case .mini, .small: .subheadline + case .regular, .large, .extraLarge: .body + } + return .system(style, design: nil, weight: isDefault ? .bold : .regular) + } + + private var isDefault: Bool { + shortcut == .defaultAction + } +} + +// MARK: - BorderedButtonColorSpec + +struct BorderedButtonColorSpec { + let shapeColor: Color + var shapeOpacity: Double = 1 + var shapeScale: Double = 1 + var shapeMaterial: Material? + var shapeBlendMode: BlendMode = .normal + let labelStyle: LabelStyle + var labelOpacity: Double = 1 + var labelScale: Double = 1 + + enum LabelStyle: ShapeStyle { + case color(Color) + case primaryDark + case tertiary + case quaternary + + func _apply(to shape: inout _ShapeStyle_Shape) { + switch self { + case let .color(color): color._apply(to: &shape) + case .primaryDark: Color.white._apply(to: &shape) + case .tertiary: HierarchicalShapeStyle.tertiary._apply(to: &shape) + case .quaternary: HierarchicalShapeStyle.quaternary._apply(to: &shape) + } + } + + typealias Resolved = Never + } +} diff --git a/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonStyle.swift b/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonStyle.swift new file mode 100644 index 000000000..e41ffbfae --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonStyle.swift @@ -0,0 +1,90 @@ +// +// BorderedButtonStyle.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +public import OpenSwiftUICore + +// MARK: - PrimitiveButtonStyle + BorderedButtonStyle + +@available(OpenSwiftUI_v3_0, *) +extension PrimitiveButtonStyle where Self == BorderedButtonStyle { + /// A button style that applies standard border artwork based on the + /// button's context. + /// + /// To apply this style to a button, or to a view that contains buttons, use + /// the ``View/buttonStyle(_:)`` modifier. + @_alwaysEmitIntoClient + @MainActor + @preconcurrency + public static var bordered: BorderedButtonStyle { + .init() + } +} + +// MARK: - BorderedButtonStyle + +/// A button style that applies standard border artwork based on the button's +/// context. +/// +/// You can also use ``PrimitiveButtonStyle/bordered`` to construct this style. +@available(OpenSwiftUI_v3_0, *) +public struct BorderedButtonStyle: PrimitiveButtonStyle { + let tint: Color? + let isProminent: Bool + + /// Creates a bordered button style. + nonisolated public init() { + tint = nil + isProminent = false + } + + /// Creates a bordered button style with a tint color. + @available(*, deprecated, message: "Use ``View/tint(_)`` instead.") + @available(iOS, unavailable) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(visionOS, unavailable) + nonisolated public init(tint: Color) { + self.tint = tint + self.isProminent = false + } + + public func makeBody(configuration: Configuration) -> some View { + #if os(macOS) || os(iOS) || os(visionOS) + Button(configuration) + #if os(iOS) || os(visionOS) + .modifier(StaticIf( + idiom: .clarityUI, + then: ButtonStyleContainerModifier(style: PlatterButtonStyle(isProminent: isProminent)), + else: EmptyModifier() + )) + #endif + .modifier(StaticIf( + idiom: .widget, + then: ButtonStyleContainerModifier(style: WidgetBorderedButtonStyle()), + else: EmptyModifier() + )) + #if os(macOS) + .buttonStyle(BorderedButtonStyle_CarMac(tint: tint, isProminent: isProminent)) + #else + .modifier(StaticIf( + idiom: .carPlay, + then: PrimitiveButtonStyleContainerModifier( + style: BorderedButtonStyle_CarMac(tint: tint, isProminent: isProminent) + ), + else: EmptyModifier() + )) + .buttonStyle(BorderedButtonStyle_Phone(tint: tint, isProminent: isProminent)) + #endif + .input(ButtonContainerIsBorderedInput.self) + #else + _openSwiftUIPlatformUnimplementedFailure() + #endif + } +} + +@available(*, unavailable) +extension BorderedButtonStyle: Sendable {} diff --git a/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonStyle_CarMac.swift b/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonStyle_CarMac.swift new file mode 100644 index 000000000..5ed902e3d --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonStyle_CarMac.swift @@ -0,0 +1,163 @@ +// +// BorderedButtonStyle_CarMac.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: WIP (TBA) +// ID: 5EE504DA55D2DCE4B6A4398A8AB30726 (SwiftUI) + +import Foundation +import OpenSwiftUICore +#if os(macOS) +import AppKit +#endif + +// MARK: - BorderedButtonStyle_CarMac + +struct BorderedButtonStyle_CarMac: PrimitiveButtonStyle { + let tint: Color? + var isProminent: Bool + + init(tint: Color?, isProminent: Bool) { + self.tint = tint + self.isProminent = isProminent + } + + func makeBody(configuration: Configuration) -> some View { + _UnaryViewAdaptor(BorderedButton( + tint: tint, + isProminent: isProminent, + configuration: configuration + )) + } +} + +// MARK: - EnforceButtonDestructiveRoleAppearance + +struct EnforceButtonDestructiveRoleAppearance: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + var enforceButtonDestructiveRoleAppearance: Bool { + get { self[EnforceButtonDestructiveRoleAppearance.self] } + set { self[EnforceButtonDestructiveRoleAppearance.self] = newValue } + } +} + +#if os(macOS) +extension View { + func enforceButtonDestructiveRoleAppearance(_ enforce: Bool) -> some View { + environment(\.enforceButtonDestructiveRoleAppearance, enforce) + } +} +#endif + +// MARK: - BorderedButton [TBA] + +private struct BorderedButton: View { + let tint: Color? + var isProminent: Bool + var configuration: PrimitiveButtonStyleConfiguration + + @Environment(\.isEnabled) + var isEnabled: Bool + + @Environment(\.isInTouchBar) + var isInTouchBar: Bool + + @Environment(\.tintColor) + var controlTint: Color? + + @Environment(\.isToggleOn) + var isOn: Bool? + + @Environment(\.buttonBorderShape) + var borderShape: ButtonBorderShape + + @Environment(\.enforceButtonDestructiveRoleAppearance) + var forceDestructiveAppearance: Bool + + #if os(macOS) + var effectiveTint: Color? { + guard isProminent, isOn != false else { + return nil + } + return tint ?? controlTint ?? .accentColor + } + + func effectiveForeground(forRole role: ButtonRole?) -> Color? { + guard role == .destructive, forceDestructiveAppearance else { + return nil + } + return Color("_NSDestructiveActionControlTextColor", bundle: .kit) + } + #endif + + var body: some View { + #if os(macOS) + Button(configuration) + .modifier( + PrimitiveButtonStyleContainerModifier( + style: AppKitButtonStyle(appearance: toolbarButtonAppearance) + ) + .requiring(ToolbarStyleContext.self) + ) + .modifier( + PrimitiveButtonStyleContainerModifier(style: PlatformItemListButtonStyle()) + .requiring(ControlGroupStyleContext.self) + ) + .modifier( + PrimitiveButtonStyleContainerModifier(style: PlatformItemListButtonStyle()) + .requiring(MenuStyleContext.self) + ) + .buttonStyle(AppKitButtonStyle(appearance: defaultButtonAppearance)) + .foregroundColor(effectiveForeground(forRole: configuration.role)) + .dynamicToolbarStyleContext() + #else + // TODO: UIKitButton, UIKitSystemButtonConfigurationModifier, ToolbarButtonLabelModifier + _openSwiftUIUnimplementedFailure() + #endif + } + + #if os(macOS) + var toolbarButtonAppearance: AppKitButtonAppearance { + if isInTouchBar { + return AppKitButtonAppearance( + bezelStyle: AllowsVerticallyFlexibleMacButtons.isEnabled ? .init(rawValue: 28)! : .push, + tint: effectiveTint, + isInToolbar: false + ) + } else { + return AppKitButtonAppearance( + bezelStyle: .toolbar, + tint: effectiveTint, + isInToolbar: true + ) + } + } + + var defaultButtonAppearance: AppKitButtonAppearance { + let bezelStyle: NSButton.BezelStyle + switch borderShape.guts { + case .circle: + bezelStyle = .circular + default: + bezelStyle = AllowsVerticallyFlexibleMacButtons.isEnabled ? .init(rawValue: 28)! : .push + } + return AppKitButtonAppearance( + bezelStyle: bezelStyle, + tint: effectiveTint, + isInToolbar: false + ) + } + #else + var carPlayPaddingModifier: some ViewModifier { + StaticIf( + idiom: .carPlay, + then: _PaddingLayout(insets: EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)), + else: EmptyModifier() + ) + } + #endif +} diff --git a/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonStyle_Phone.swift b/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonStyle_Phone.swift new file mode 100644 index 000000000..ec89af2d9 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/Button/BorderedButtonStyle_Phone.swift @@ -0,0 +1,383 @@ +// +// BorderedButtonStyle_Phone.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 23BA9706730FD9E83431548B5C759F22 (SwiftUI) + +#if os(iOS) || os(visionOS) + +import OpenSwiftUICore + +// MARK: - BorderedButtonStyle_Phone + +struct BorderedButtonStyle_Phone: ButtonStyle { + var tint: Color? + var isProminent: Bool + + @Environment(\.isToggleOn) + var isOn: Bool? + + @Environment(\.buttonBorderShape) + var borderShape: ButtonBorderShape + + @Environment(\.dynamicTypeSize) + var dynamicTypeSize: DynamicTypeSize + + @Environment(\.tintColor) + var controlTint: Color? + + init(tint: Color?, isProminent: Bool) { + self.tint = tint + self.isProminent = isProminent + } + + func makeBody(configuration: Configuration) -> some View { + let button = ResolvedBorderedButton( + configuration: configuration, + style: self, + borderShape: borderShape + ) + let scale: Image.Scale = switch dynamicTypeSize { + case .xSmall, .small, .medium, .large, .xLarge, .xxLarge, .xxxLarge: + .medium + case .accessibility1, .accessibility2, .accessibility3, .accessibility4, .accessibility5: + .small + } + return button.imageScale(scale) + } +} + +// MARK: - UseImageBackground + +private struct UseImageBackground: ViewInputBoolFlag {} + +// MARK: - ResolvedBorderedButton + +private struct ResolvedBorderedButton: View { + @Environment(\.backgroundMaterial) + var backgroundMaterial: Material? + + @Environment(\.colorScheme) + var colorScheme: ColorScheme + + @Environment(\.isEnabled) + var isEnabled: Bool + + @Environment(\.accessibilityReduceTransparency) + var isReducedTransparencyEnabled: Bool + + @Environment(\.isToggleOn) + var isOn: Bool? + + var configuration: ButtonStyleConfiguration + var style: BorderedButtonStyle_Phone + var borderShape: ButtonBorderShape + let borderedButtonSpec: BorderedButtonSpec + + init( + configuration: ButtonStyleConfiguration, + style: BorderedButtonStyle_Phone, + borderShape: ButtonBorderShape + ) { + self.configuration = configuration + self.style = style + self.borderShape = borderShape + borderedButtonSpec = BorderedButtonSpec() + } + + var body: some View { + let label = HStack { + configuration.label + } + .environment(\.defaultFont, borderedButtonSpec.defaultFont) + .multilineTextAlignment(.center) + .buttonDefaultRenderingMode() + return StaticIf(UseImageBackground.self) { + label + .defaultForegroundStyle(labelStyle) + .padding(borderedButtonSpec.padding) + .background(imageBackgroundSpec.shapeColor.opacity(imageBackgroundSpec.shapeOpacity)) + .background(imageBackgroundSpec.shapeMaterial!) + .clipShape(ResolvedBorderShape( + controlSize: borderedButtonSpec.controlSize, + base: borderShape, + padding: .zero + )) + } else: { + label + .opacity((backgroundMaterial == nil ? opaqueBackgroundSpec : materialBackgroundSpec).labelOpacity) + .defaultForegroundStyle(labelStyle) + .padding(borderedButtonSpec.padding) + .background { + background + .compositingGroup() + .blendMode((backgroundMaterial == nil ? opaqueBackgroundSpec : materialBackgroundSpec).shapeBlendMode) + } + } + .contentShape([.interaction, .hoverEffect], ResolvedBorderShape( + controlSize: borderedButtonSpec.controlSize, + base: borderShape, + padding: .zero + )) + } + + var labelStyle: BorderedButtonColorSpec.LabelStyle { + if style.isOn == true, style.isProminent { + .color(.white) + } else { + (backgroundMaterial == nil ? opaqueBackgroundSpec : materialBackgroundSpec).labelStyle + } + } + + var imageBackgroundSpec: BorderedButtonColorSpec { + guard isEnabled else { + return BorderedButtonColorSpec( + shapeColor: .clear, + shapeMaterial: .ultraThin, + labelStyle: .color(Color(white: colorScheme == .light ? 0 : 1)), + labelOpacity: colorScheme == .light ? 0.25 : 0.4 + ) + } + let shapeColor: Color + let labelStyle: BorderedButtonColorSpec.LabelStyle + var shapeOpacity = 1.0 + var shapeMaterial = Material.thick + var labelOpacity = 1.0 + switch resolvedTint { + case let .tint(tint): + if colorScheme == .light { + shapeColor = tint.opacity(tint == .yellow ? 0.2 : 0.18) + labelStyle = .color(tint) + if configuration.isPressed { + shapeMaterial = .ultraThin + labelOpacity = 0.75 + } + } else { + shapeColor = tint.opacity(tint == .red || tint == .pink ? 0.15 : 0.2) + if configuration.isPressed { + shapeMaterial = .ultraThin + labelStyle = .color(Color(white: 1, opacity: 0.1).over(tint)) + } else { + labelStyle = .color(tint) + } + } + case let .prominent(tint): + labelStyle = .primaryDark + if configuration.isPressed { + shapeMaterial = .ultraThin + labelOpacity = 0.75 + if colorScheme == .light { + shapeColor = tint + shapeOpacity = 0.75 + } else { + shapeColor = Color(white: 1, opacity: 0.2).over(tint) + } + } else { + shapeColor = tint + } + case .automatic: + shapeColor = .clear + if configuration.isPressed { + if colorScheme == .light { + shapeMaterial = .thin + labelStyle = .color(defaultTint) + labelOpacity = 0.75 + } else { + shapeMaterial = .ultraThin + labelStyle = .color(Color(white: 1, opacity: 0.1).over(defaultTint)) + } + } else { + labelStyle = .color(defaultTint) + } + } + return BorderedButtonColorSpec( + shapeColor: shapeColor, + shapeOpacity: shapeOpacity, + shapeMaterial: shapeMaterial, + labelStyle: labelStyle, + labelOpacity: labelOpacity + ) + } + + var background: some View { + let spec = backgroundMaterial == nil ? opaqueBackgroundSpec : materialBackgroundSpec + return ResolvedBorderShape( + controlSize: borderedButtonSpec.controlSize, + base: borderShape, + padding: .zero + ) + .fill(spec.shapeColor.opacity(spec.shapeOpacity)) + } + + var materialBackgroundSpec: BorderedButtonColorSpec { + guard isEnabled else { + if colorScheme == .light { + return BorderedButtonColorSpec( + shapeColor: Color(white: 1, opacity: isReducedTransparencyEnabled ? 1 : 0.3), + labelStyle: .color(Color(white: 0)), + labelOpacity: 0.15 + ) + } else { + return BorderedButtonColorSpec( + shapeColor: Color(white: 0, opacity: isReducedTransparencyEnabled ? 1 : 0.16), + shapeBlendMode: isReducedTransparencyEnabled ? .normal : .plusDarker, + labelStyle: .quaternary + ) + } + } + let shapeColor: Color + let labelStyle: BorderedButtonColorSpec.LabelStyle + var shapeOpacity = 1.0 + var labelOpacity = 1.0 + switch resolvedTint { + case let .tint(tint): + if colorScheme == .light { + let color = tint.opacity(tint == .yellow ? 0.2 : 0.18) + shapeColor = isReducedTransparencyEnabled ? color.over(.white) : color + labelStyle = .color(tint) + if configuration.isPressed { + shapeOpacity = 0.65 + labelOpacity = 0.75 + } + } else { + let color = tint.opacity(tint == .red || tint == .pink ? 0.15 : 0.2) + if configuration.isPressed { + shapeColor = isReducedTransparencyEnabled ? color.over(.black) : color + labelStyle = .color(Color(white: 1, opacity: 0.1).over(tint)) + } else { + shapeColor = color.over(Color(white: 0, opacity: isReducedTransparencyEnabled ? 1 : 0.3)) + labelStyle = .color(tint) + } + } + case let .prominent(tint): + labelStyle = .primaryDark + if configuration.isPressed { + labelOpacity = 0.75 + if colorScheme == .light { + shapeColor = isReducedTransparencyEnabled ? tint.over(.white) : tint + shapeOpacity = 0.75 + } else { + shapeColor = Color(white: 1, opacity: 0.2).over(tint) + } + } else { + shapeColor = tint + } + case .automatic: + if colorScheme == .light { + shapeColor = Color(white: 1, opacity: isReducedTransparencyEnabled ? 1 : 0.6) + labelStyle = .color(defaultTint) + if configuration.isPressed { + shapeOpacity = isReducedTransparencyEnabled ? 1 : 0.65 + labelOpacity = 0.75 + } + } else { + shapeColor = Color(white: 0, opacity: isReducedTransparencyEnabled ? 1 : 0.5) + if configuration.isPressed { + shapeOpacity = isReducedTransparencyEnabled ? 1 : 0.65 + labelStyle = .color(Color(white: 1, opacity: 0.1).over(defaultTint)) + } else { + labelStyle = .color(defaultTint) + } + } + } + return BorderedButtonColorSpec( + shapeColor: shapeColor, + shapeOpacity: shapeOpacity, + labelStyle: labelStyle, + labelOpacity: labelOpacity + ) + } + + var opaqueBackgroundSpec: BorderedButtonColorSpec { + guard isEnabled else { + return BorderedButtonColorSpec( + shapeColor: .secondarySystemFill, + labelStyle: .tertiary, + labelOpacity: 0.75 + ) + } + let shapeColor: Color + let labelStyle: BorderedButtonColorSpec.LabelStyle + var shapeOpacity = 1.0 + var labelOpacity = 1.0 + switch resolvedTint { + case let .tint(tint): + if colorScheme == .light { + shapeColor = isOn == false ? .secondarySystemFill : tint.opacity(tint == .yellow ? 0.2 : 0.18) + labelStyle = .color(tint) + if configuration.isPressed { + shapeOpacity = 0.65 + labelOpacity = 0.75 + } + } else { + shapeColor = isOn == false ? .secondarySystemFill : tint.opacity(tint == .red || tint == .pink ? 0.2 : 0.25) + if configuration.isPressed { + shapeOpacity = 1.4 + labelStyle = .color(Color(white: 1, opacity: 0.1).over(tint)) + } else { + labelStyle = .color(tint) + } + } + case let .prominent(tint): + labelStyle = isOn == false ? .color(tint) : .primaryDark + if configuration.isPressed { + labelOpacity = 0.75 + if colorScheme == .light { + shapeColor = tint + shapeOpacity = 0.75 + } else { + shapeColor = Color(white: 1, opacity: 0.2).over(tint) + } + } else { + shapeColor = tint.opacity(isOn == false ? 0 : 1) + } + case .automatic: + if configuration.isPressed { + if colorScheme == .light { + shapeColor = .secondarySystemFill + shapeOpacity = 0.75 + labelStyle = .color(defaultTint) + labelOpacity = 0.75 + } else { + shapeColor = Color(white: 1, opacity: 0.05).over(.secondarySystemFill) + labelStyle = .color(Color(white: 1, opacity: 0.1).over(defaultTint)) + } + } else { + shapeColor = isOn == true ? defaultTint.opacity(defaultTint == .yellow ? 0.2 : 0.18) : .secondarySystemFill + labelStyle = .color(defaultTint) + } + } + return BorderedButtonColorSpec( + shapeColor: shapeColor, + shapeOpacity: shapeOpacity, + labelStyle: labelStyle, + labelOpacity: labelOpacity + ) + } + + var defaultTint: Color { + configuration.role == .destructive ? .red : .accentColor + } + + var resolvedTint: ResolvedTint { + if style.isProminent { + .prominent(style.tint ?? style.controlTint ?? defaultTint) + } else if let tint = style.tint ?? style.controlTint { + .tint(tint) + } else { + .automatic + } + } +} + +// MARK: - ResolvedTint + +private enum ResolvedTint { + case tint(Color) + case prominent(Color) + case automatic +} + +#endif diff --git a/Sources/OpenSwiftUI/View/Control/Button/BorderedProminentButtonStyle.swift b/Sources/OpenSwiftUI/View/Control/Button/BorderedProminentButtonStyle.swift new file mode 100644 index 000000000..e610c1a5b --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/Button/BorderedProminentButtonStyle.swift @@ -0,0 +1,90 @@ +// +// BorderedProminentButtonStyle.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +public import OpenSwiftUICore + +// MARK: - PrimitiveButtonStyle + BorderedProminentButtonStyle + +@available(OpenSwiftUI_v3_0, *) +extension PrimitiveButtonStyle where Self == BorderedProminentButtonStyle { + /// A button style that applies standard border prominent artwork based on + /// the button's context. + /// + /// To apply this style to a button, or to a view that contains buttons, use + /// the ``View/buttonStyle(_:)`` modifier. + @_alwaysEmitIntoClient + @MainActor + @preconcurrency + public static var borderedProminent: BorderedProminentButtonStyle { + .init() + } +} + +// MARK: - BorderedProminentButtonStyle + +/// A button style that applies standard border prominent artwork based +/// on the button's context. +/// +/// Use ``PrimitiveButtonStyle/borderedProminent`` to construct this style. +@available(OpenSwiftUI_v3_0, *) +public struct BorderedProminentButtonStyle: PrimitiveButtonStyle { + /// Creates a bordered prominent button style. + nonisolated public init() { + _openSwiftUIEmptyStub() + } + + public func makeBody(configuration: Configuration) -> some View { + #if canImport(Darwin) + Button(configuration) + #if os(iOS) || os(visionOS) + .modifier(StaticIf( + idiom: .clarityUI, + then: ButtonStyleContainerModifier(style: PlatterButtonStyle(isProminent: true)), + else: EmptyModifier() + )) + #endif + .modifier(StaticIf( + idiom: .widget, + then: ButtonStyleContainerModifier(style: WidgetBorderedProminentButtonStyle()), + else: EmptyModifier() + )) + #if os(macOS) + .buttonStyle(BorderedButtonStyle_CarMac(tint: nil, isProminent: true)) + #else + .modifier(StaticIf( + idiom: .carPlay, + then: PrimitiveButtonStyleContainerModifier( + style: BorderedButtonStyle_CarMac(tint: nil, isProminent: true) + ), + else: EmptyModifier() + )) + .buttonStyle(buttonStyleRepresentation) + #endif + .input(ButtonContainerIsBorderedInput.self) + #else + _openSwiftUIPlatformUnimplementedFailure() + #endif + } +} + +@available(*, unavailable) +extension BorderedProminentButtonStyle: Sendable {} + +#if os(iOS) || os(visionOS) + +// MARK: - BorderedProminentButtonStyle + ButtonStyleConvertible + +@_spi(UIFrameworks) +extension BorderedProminentButtonStyle: ButtonStyleConvertible { + @MainActor + @preconcurrency + public var buttonStyleRepresentation: some ButtonStyle { + BorderedButtonStyle_Phone(tint: nil, isProminent: true) + } +} + +#endif diff --git a/Sources/OpenSwiftUI/View/Control/Button/BorderlessButtonStyle.swift b/Sources/OpenSwiftUI/View/Control/Button/BorderlessButtonStyle.swift new file mode 100644 index 000000000..e0186b478 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/Button/BorderlessButtonStyle.swift @@ -0,0 +1,240 @@ +// +// BorderlessButtonStyle.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 8946ABD13E6925C5D5FDD316D4A45F59 (SwiftUI) + +public import OpenSwiftUICore + +// MARK: - PrimitiveButtonStyle + BorderlessButtonStyle + +@available(OpenSwiftUI_v1_0, *) +extension PrimitiveButtonStyle where Self == BorderlessButtonStyle { + /// A button style that doesn't apply a border. + /// + /// To apply this style to a button, or to a view that contains buttons, use + /// the ``View/buttonStyle(_:)`` modifier. + /// + /// On tvOS, this button style adds a default hover effect to the first + /// image of the button's content, if one exists. You can supply a different + /// hover effect by using the ``View/hoverEffect(_:)`` modifier in the + /// button's label. + @_alwaysEmitIntoClient + public static var borderless: BorderlessButtonStyle { + BorderlessButtonStyle() + } +} + +// MARK: - BorderlessButtonStyle + +/// A button style that doesn't apply a border. +/// +/// You can also use ``PrimitiveButtonStyle/borderless`` to construct this +/// style. +@available(OpenSwiftUI_v1_0, *) +public struct BorderlessButtonStyle: PrimitiveButtonStyle { + @Environment(\.tintColor) + private var controlTint: Color? + + #if os(macOS) + @Environment(\.isToggleOn) + private var isOn: Bool? + #endif + + @Environment(\.accessibilityShowButtonShapes) + private var showsShapes: Bool + + /// Creates a borderless button style. + public init() { + _openSwiftUIEmptyStub() + } + + #if os(macOS) + private var resolvedTint: Color? { + switch isOn { + case nil: controlTint + case true?: controlTint ?? .accentColor + case false?: .secondary + } + } + #endif + + public func makeBody(configuration: Configuration) -> some View { + #if canImport(Darwin) + Button(configuration) + .modifier( + StaticIf(in: .menu) { + PrimitiveButtonStyleContainerModifier(style: PlatformItemListButtonStyle()) + } else: { + EmptyModifier() + } + ) + #if os(macOS) + .buttonStyle(AppKitButtonStyle(appearance: .borderless(tint: resolvedTint))) + #else + .buttonStyle(buttonStyleRepresentation) + .underline(showsShapes, color: controlTint) + #endif + #else + _openSwiftUIPlatformUnimplementedFailure() + #endif + } +} + +@available(*, unavailable) +extension BorderlessButtonStyle: Sendable {} + +#if os(iOS) || os(visionOS) + +// MARK: - BorderlessButtonStyle + ButtonStyleConvertible + +@_spi(UIFrameworks) +extension BorderlessButtonStyle: ButtonStyleConvertible { + @MainActor + @preconcurrency + public var buttonStyleRepresentation: some ButtonStyle { + BorderlessButtonStyleBase() + } +} + +// MARK: - BorderlessButtonStyleBase + +private struct BorderlessButtonStyleBase: ButtonStyle { + @Environment(\.keyboardShortcut) var keyboardShortcut: KeyboardShortcut? + @Environment(\.controlSize) var controlSize: ControlSize + @Environment(\.isEnabled) var isEnabled: Bool + + init() { + _openSwiftUIEmptyStub() + } + + var defaultWeight: Font.Weight { + keyboardShortcut == .defaultAction ? .semibold : .regular + } + + var defaultFont: Font { + let style: Font.TextStyle = switch controlSize { + case .mini: .subheadline + case .small: .subheadline + case .regular: .body + case .large: .body + case .extraLarge: .body + } + return .system(style, design: nil, weight: defaultWeight) + } + + func makeBody(configuration: Configuration) -> some View { + let label = HStack { + configuration.label + .modifier(OpacityButtonHighlightModifier(highlighted: configuration.isPressed)) + } + .contentShape(Rectangle()) + .environment(\.defaultFont, defaultFont) + .multilineTextAlignment(isLinkedOnOrAfter(.v3) ? .center : .leading) + .buttonDefaultRenderingMode() + .defaultForegroundStyle(BorderlessButtonLabelShapeStyle( + role: configuration.role, + isEnabled: isEnabled, + defaultForegroundStyle: TintShapeStyle() + )) + return StaticIf(IsConditionallyBorderedPredicate.self) { + ConditionallyBorderedButton( + base: label, + controlSize: controlSize, + borderedButtonSpec: BorderedButtonSpec() + ) + } else: { + label + } + } +} + +// MARK: - BorderlessButtonLabelShapeStyle + +struct BorderlessButtonLabelShapeStyle