From 067e78ea8516bc0e10f9d0022a4f59432b29acea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?furby=E2=84=A2?= Date: Sat, 15 Feb 2025 01:54:09 -0700 Subject: [PATCH 01/10] AppKit: Implement NSViewRepresentable. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: furby™ --- Package.swift | 2 +- Sources/AppKitBackend/BaseWidget.swift | 113 ++++++++++ .../AppKitBackend/NSViewRepresentable.swift | 206 ++++++++++++++++++ 3 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 Sources/AppKitBackend/BaseWidget.swift create mode 100644 Sources/AppKitBackend/NSViewRepresentable.swift diff --git a/Package.swift b/Package.swift index 1ae7b31fe..eb92e3dc6 100644 --- a/Package.swift +++ b/Package.swift @@ -61,7 +61,7 @@ switch ProcessInfo.processInfo.environment["SCUI_LIBRARY_TYPE"] { let package = Package( name: "swift-cross-ui", - platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .macCatalyst(.v13)], + platforms: [.macOS(.v11), .iOS(.v13), .tvOS(.v13), .macCatalyst(.v13)], products: [ .library(name: "SwiftCrossUI", type: libraryType, targets: ["SwiftCrossUI"]), .library(name: "AppKitBackend", type: libraryType, targets: ["AppKitBackend"]), diff --git a/Sources/AppKitBackend/BaseWidget.swift b/Sources/AppKitBackend/BaseWidget.swift new file mode 100644 index 000000000..d14d6f1b0 --- /dev/null +++ b/Sources/AppKitBackend/BaseWidget.swift @@ -0,0 +1,113 @@ +import AppKit + +public class BaseWidget: NSView { + private var leftConstraint: NSLayoutConstraint? + private var topConstraint: NSLayoutConstraint? + private var widthConstraint: NSLayoutConstraint? + private var heightConstraint: NSLayoutConstraint? + + var x = 0 { + didSet { + if x != oldValue { + updateLeftConstraint() + } + } + } + + var y = 0 { + didSet { + if y != oldValue { + updateTopConstraint() + } + } + } + + var width = 0 { + didSet { + if width != oldValue { + updateWidthConstraint() + } + } + } + + var height = 0 { + didSet { + if height != oldValue { + updateHeightConstraint() + } + } + } + + init() { + super.init(frame: .zero) + + self.translatesAutoresizingMaskIntoConstraints = false + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not used for this view") + } + + private func updateLeftConstraint() { + leftConstraint?.isActive = false + guard let superview else { return } + leftConstraint = self.leftAnchor.constraint( + equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x)) + leftConstraint!.isActive = true + } + + private func updateTopConstraint() { + topConstraint?.isActive = false + guard let superview else { return } + topConstraint = self.topAnchor.constraint( + equalTo: superview.safeAreaLayoutGuide.topAnchor, constant: CGFloat(y)) + topConstraint!.isActive = true + } + + private func updateWidthConstraint() { + widthConstraint?.isActive = false + widthConstraint = self.widthAnchor.constraint(equalToConstant: CGFloat(width)) + widthConstraint!.isActive = true + } + + private func updateHeightConstraint() { + heightConstraint?.isActive = false + heightConstraint = self.heightAnchor.constraint(equalToConstant: CGFloat(height)) + heightConstraint!.isActive = true + } + + public override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + + updateLeftConstraint() + updateTopConstraint() + } +} + +class WrapperWidget: BaseWidget { + init(child: View) { + super.init() + + self.addSubview(child) + child.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + child.topAnchor.constraint(equalTo: self.topAnchor), + child.leadingAnchor.constraint(equalTo: self.leadingAnchor), + child.bottomAnchor.constraint(equalTo: self.bottomAnchor), + child.trailingAnchor.constraint(equalTo: self.trailingAnchor), + ]) + } + + override convenience init() { + self.init(child: View(frame: .zero)) + } + + var child: View { + subviews[0] as! View + } + + override var intrinsicContentSize: CGSize { + child.intrinsicContentSize + } +} diff --git a/Sources/AppKitBackend/NSViewRepresentable.swift b/Sources/AppKitBackend/NSViewRepresentable.swift new file mode 100644 index 000000000..d2dc3cae7 --- /dev/null +++ b/Sources/AppKitBackend/NSViewRepresentable.swift @@ -0,0 +1,206 @@ +import SwiftCrossUI +import AppKit + +public struct NSViewRepresentableContext { + public let coordinator: Coordinator + public internal(set) var environment: EnvironmentValues +} + +public protocol NSViewRepresentable: View +where Content == Never { + associatedtype NSViewType: NSView + associatedtype Coordinator = Void + + /// Create the initial NSView instance. + func makeNSView(context: NSViewRepresentableContext) -> NSViewType + + /// Update the view with new values. + /// - Parameters: + /// - nsView: The view to update. + /// - context: The context, including the coordinator and potentially new environment + /// values. + /// - Note: This may be called even when `context` has not changed. + func updateNSView(_ nsView: NSViewType, context: NSViewRepresentableContext) + + /// Make the coordinator for this view. + /// + /// The coordinator is used when the view needs to communicate changes to the rest of + /// the view hierarchy (i.e. through bindings), and is often the view's delegate. + func makeCoordinator() -> Coordinator + + /// Compute the view's size. + /// - Parameters: + /// - proposal: The proposed frame for the view to render in. + /// - nsVIew: The view being queried for its preferred size. + /// - context: The context, including the coordinator and environment values. + /// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size`` + /// property is what frame the view will actually be rendered with if the current layout + /// pass is not a dry run, while the other properties are used to inform the layout engine + /// how big or small the view can be. The ``SwiftCrossUI/ViewSize/idealSize`` property + /// should not vary with the `proposal`, and should only depend on the view's contents. + /// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore + /// may occupy the entire screen). + /// + /// The default implementation uses `nsView.intrinsicContentSize` and `nsView.sizeThatFits(_:)` + /// to determine the return value. + func determineViewSize( + for proposal: SIMD2, nsView: NSViewType, + context: NSViewRepresentableContext + ) -> ViewSize + + /// Called to clean up the view when it's removed. + /// - Parameters: + /// - nsVIew: The view being dismantled. + /// - coordinator: The coordinator. + /// + /// This method is called after all AppKit lifecycle methods, such as + /// `nsView.didMoveToSuperview()`. + /// + /// The default implementation does nothing. + static func dismantleNSView(_ nsView: NSViewType, coordinator: Coordinator) +} + +extension NSViewRepresentable { + public static func dismantleNSView(_: NSViewType, coordinator _: Coordinator) { + // no-op + } + + public func determineViewSize( + for proposal: SIMD2, nsView: NSViewType, + context _: NSViewRepresentableContext + ) -> ViewSize { + let intrinsicSize = nsView.intrinsicContentSize + let sizeThatFits = nsView.fittingSize + + let roundedSizeThatFits = SIMD2( + Int(sizeThatFits.width.rounded(.up)), + Int(sizeThatFits.height.rounded(.up))) + let roundedIntrinsicSize = SIMD2( + Int(intrinsicSize.width.rounded(.awayFromZero)), + Int(intrinsicSize.height.rounded(.awayFromZero))) + + return ViewSize( + size: SIMD2( + intrinsicSize.width < 0.0 ? proposal.x : roundedSizeThatFits.x, + intrinsicSize.height < 0.0 ? proposal.y : roundedSizeThatFits.y + ), + // The 10 here is a somewhat arbitrary constant value so that it's always the same. + // See also `Color` and `Picker`, which use the same constant. + idealSize: SIMD2( + intrinsicSize.width < 0.0 ? 10 : roundedIntrinsicSize.x, + intrinsicSize.height < 0.0 ? 10 : roundedIntrinsicSize.y + ), + minimumWidth: max(0, roundedIntrinsicSize.x), + minimumHeight: max(0, roundedIntrinsicSize.x), + maximumWidth: nil, + maximumHeight: nil + ) + } +} + +extension View +where Self: NSViewRepresentable { + public var body: Never { + preconditionFailure("This should never be called") + } + + public func children( + backend _: Backend, + snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?, + environment _: EnvironmentValues + ) -> any ViewGraphNodeChildren { + EmptyViewChildren() + } + + public func layoutableChildren( + backend _: Backend, + children _: any ViewGraphNodeChildren + ) -> [LayoutSystem.LayoutableChild] { + [] + } + + public func asWidget( + _: any ViewGraphNodeChildren, + backend _: Backend + ) -> Backend.Widget { + if let widget = RepresentingWidget(representable: self) as? Backend.Widget { + return widget + } else { + fatalError("NSViewRepresentable requested by \(Backend.self)") + } + } + + public func update( + _ widget: Backend.Widget, + children _: any ViewGraphNodeChildren, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend _: Backend, + dryRun: Bool + ) -> ViewUpdateResult { + let representingWidget = widget as! RepresentingWidget + representingWidget.update(with: environment) + + let size = + representingWidget.representable.determineViewSize( + for: proposedSize, + nsView: representingWidget.subview, + context: representingWidget.context! + ) + + if !dryRun { + representingWidget.width = size.size.x + representingWidget.height = size.size.y + } + + return ViewUpdateResult.leafView(size: size) + } +} + +extension NSViewRepresentable +where Coordinator == Void { + public func makeCoordinator() { + return () + } +} + +final class RepresentingWidget: BaseWidget { + var representable: Representable + var context: NSViewRepresentableContext? + + lazy var subview: Representable.NSViewType = { + let view = representable.makeNSView(context: context!) + + self.addSubview(view) + + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: self.topAnchor), + view.leadingAnchor.constraint(equalTo: self.leadingAnchor), + view.trailingAnchor.constraint(equalTo: self.trailingAnchor), + view.bottomAnchor.constraint(equalTo: self.bottomAnchor), + ]) + + return view + }() + + func update(with environment: EnvironmentValues) { + if context == nil { + context = .init(coordinator: representable.makeCoordinator(), environment: environment) + } else { + context!.environment = environment + representable.updateNSView(subview, context: context!) + } + } + + init(representable: Representable) { + self.representable = representable + super.init() + } + + deinit { + if let context { + Representable.dismantleNSView(subview, coordinator: context.coordinator) + } + } +} From be74a9ebb6757ce18db348dd32ec56940ea1aa81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?furby=E2=84=A2?= Date: Sat, 15 Feb 2025 02:17:22 -0700 Subject: [PATCH 02/10] AppKit: specify main actors on NSViewRepresentable. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: furby™ --- Sources/AppKitBackend/NSViewRepresentable.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/AppKitBackend/NSViewRepresentable.swift b/Sources/AppKitBackend/NSViewRepresentable.swift index d2dc3cae7..b9d2037e3 100644 --- a/Sources/AppKitBackend/NSViewRepresentable.swift +++ b/Sources/AppKitBackend/NSViewRepresentable.swift @@ -12,6 +12,7 @@ where Content == Never { associatedtype Coordinator = Void /// Create the initial NSView instance. + @MainActor func makeNSView(context: NSViewRepresentableContext) -> NSViewType /// Update the view with new values. @@ -20,12 +21,14 @@ where Content == Never { /// - context: The context, including the coordinator and potentially new environment /// values. /// - Note: This may be called even when `context` has not changed. + @MainActor func updateNSView(_ nsView: NSViewType, context: NSViewRepresentableContext) /// Make the coordinator for this view. /// /// The coordinator is used when the view needs to communicate changes to the rest of /// the view hierarchy (i.e. through bindings), and is often the view's delegate. + @MainActor func makeCoordinator() -> Coordinator /// Compute the view's size. From 09bb095c957693e9a3289c89accd9a2c88a92fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?furby=E2=84=A2?= Date: Sat, 15 Feb 2025 03:09:00 -0700 Subject: [PATCH 03/10] UIKit: add main actors. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: furby™ --- Sources/UIKitBackend/UIViewRepresentable.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/UIKitBackend/UIViewRepresentable.swift b/Sources/UIKitBackend/UIViewRepresentable.swift index 0c4f45093..5b20929cd 100644 --- a/Sources/UIKitBackend/UIViewRepresentable.swift +++ b/Sources/UIKitBackend/UIViewRepresentable.swift @@ -12,6 +12,7 @@ where Content == Never { associatedtype Coordinator = Void /// Create the initial UIView instance. + @MainActor func makeUIView(context: UIViewRepresentableContext) -> UIViewType /// Update the view with new values. @@ -20,12 +21,14 @@ where Content == Never { /// - context: The context, including the coordinator and potentially new environment /// values. /// - Note: This may be called even when `context` has not changed. + @MainActor func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext) /// Make the coordinator for this view. /// /// The coordinator is used when the view needs to communicate changes to the rest of /// the view hierarchy (i.e. through bindings), and is often the view's delegate. + @MainActor func makeCoordinator() -> Coordinator /// Compute the view's size. From c63d3ef793d3e545cc03fd69db7473e0ce515e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?furby=E2=84=A2?= Date: Tue, 18 Feb 2025 01:12:07 -0700 Subject: [PATCH 04/10] Make UIKit implementation agnostic of iOS/visionOS. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: furby™ --- Sources/UIKitBackend/UIKitBackend+Passive.swift | 2 +- Sources/UIKitBackend/UIKitBackend.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/UIKitBackend/UIKitBackend+Passive.swift b/Sources/UIKitBackend/UIKitBackend+Passive.swift index 3a4aa6251..8bcbcfb42 100644 --- a/Sources/UIKitBackend/UIKitBackend+Passive.swift +++ b/Sources/UIKitBackend/UIKitBackend+Passive.swift @@ -15,7 +15,7 @@ extension UIKitBackend { case .leading: .natural case .trailing: - UIScreen.main.traitCollection.layoutDirection == .rightToLeft ? .left : .right + UITraitCollection.current.layoutDirection == .rightToLeft ? .left : .right } paragraphStyle.lineBreakMode = .byWordWrapping diff --git a/Sources/UIKitBackend/UIKitBackend.swift b/Sources/UIKitBackend/UIKitBackend.swift index 59a0a0d08..8cd47ba4f 100644 --- a/Sources/UIKitBackend/UIKitBackend.swift +++ b/Sources/UIKitBackend/UIKitBackend.swift @@ -71,7 +71,7 @@ public final class UIKitBackend: AppBackend { design: .default ) - switch UIScreen.main.traitCollection.userInterfaceStyle { + switch UITraitCollection.current.userInterfaceStyle { case .light: environment.colorScheme = .light case .dark: From 457c4218f7fdc96cc295df9d0e1603c13e36b365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?furby=E2=84=A2?= Date: Tue, 18 Feb 2025 07:57:15 -0700 Subject: [PATCH 05/10] bump swift-image-formats for dependency centralization. * Part of our efforts to condense and streamline all the various C/C++ dependencies under one central repository, this is important because Swift makes it all too easy to have 30 different zlib libraries for example, which leads to a headache of duplicated symbols and what not. * That central repository is https://github.com/the-swift-collective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: furby™ --- Package.resolved | 24 ++++++++++++------------ Package.swift | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Package.resolved b/Package.resolved index 408b2c0a4..b6420789d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9bdf10c7a21892e3f103174fa966d5a212af3dfca1608849b243319bfbb3d005", + "originHash" : "d7660de3524ddfd94fb5ed75c380bbcda1ec9d99cc0b74dee8d2f70756063c78", "pins" : [ { "identity" : "jpeg", @@ -19,6 +19,15 @@ "version" : "1.6.45" } }, + { + "identity" : "libwebp", + "kind" : "remoteSourceControl", + "location" : "https://github.com/the-swift-collective/libwebp", + "state" : { + "revision" : "5f745a17b9a5c2a4283f17c2cde4517610ab5f99", + "version" : "1.4.1" + } + }, { "identity" : "swift-cwinrt", "kind" : "remoteSourceControl", @@ -51,17 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-image-formats", "state" : { - "revision" : "05a0169a2a5e9365a058e9aa13da5937be6e2586", - "version" : "0.3.1" - } - }, - { - "identity" : "swift-libwebp", - "kind" : "remoteSourceControl", - "location" : "https://github.com/stackotter/swift-libwebp", - "state" : { - "revision" : "61dc3787c764022ad2f5ab4f9994a569afe86f9f", - "version" : "0.2.0" + "revision" : "697bd8aa62bef7d74a1383454e3534a527769e41", + "version" : "0.3.2" } }, { diff --git a/Package.swift b/Package.swift index eb92e3dc6..eeea9de6d 100644 --- a/Package.swift +++ b/Package.swift @@ -95,7 +95,7 @@ let package = Package( ), .package( url: "https://github.com/stackotter/swift-image-formats", - .upToNextMinor(from: "0.3.1") + .upToNextMinor(from: "0.3.2") ), .package( url: "https://github.com/wabiverse/swift-windowsappsdk", From 0d34485d47bca8add39b8e2329c652b071fccb4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?furby=E2=84=A2?= Date: Sat, 1 Mar 2025 00:11:07 -0700 Subject: [PATCH 06/10] Remove BaseWidget for AppKit backend. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: furby™ --- Examples/Package.resolved | 22 ++-- Package.swift | 2 +- Sources/AppKitBackend/BaseWidget.swift | 113 ------------------ .../AppKitBackend/NSViewRepresentable.swift | 88 +------------- 4 files changed, 16 insertions(+), 209 deletions(-) delete mode 100644 Sources/AppKitBackend/BaseWidget.swift diff --git a/Examples/Package.resolved b/Examples/Package.resolved index 4986771fb..4a1fbaca4 100644 --- a/Examples/Package.resolved +++ b/Examples/Package.resolved @@ -27,6 +27,15 @@ "version" : "1.6.45" } }, + { + "identity" : "libwebp", + "kind" : "remoteSourceControl", + "location" : "https://github.com/the-swift-collective/libwebp", + "state" : { + "revision" : "5f745a17b9a5c2a4283f17c2cde4517610ab5f99", + "version" : "1.4.1" + } + }, { "identity" : "pathkit", "kind" : "remoteSourceControl", @@ -130,8 +139,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-image-formats", "state" : { - "revision" : "05a0169a2a5e9365a058e9aa13da5937be6e2586", - "version" : "0.3.1" + "revision" : "697bd8aa62bef7d74a1383454e3534a527769e41", + "version" : "0.3.2" } }, { @@ -143,15 +152,6 @@ "version" : "0.4.0" } }, - { - "identity" : "swift-libwebp", - "kind" : "remoteSourceControl", - "location" : "https://github.com/stackotter/swift-libwebp", - "state" : { - "revision" : "61dc3787c764022ad2f5ab4f9994a569afe86f9f", - "version" : "0.2.0" - } - }, { "identity" : "swift-log", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index eeea9de6d..4865160ee 100644 --- a/Package.swift +++ b/Package.swift @@ -61,7 +61,7 @@ switch ProcessInfo.processInfo.environment["SCUI_LIBRARY_TYPE"] { let package = Package( name: "swift-cross-ui", - platforms: [.macOS(.v11), .iOS(.v13), .tvOS(.v13), .macCatalyst(.v13)], + platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .macCatalyst(.v13)], products: [ .library(name: "SwiftCrossUI", type: libraryType, targets: ["SwiftCrossUI"]), .library(name: "AppKitBackend", type: libraryType, targets: ["AppKitBackend"]), diff --git a/Sources/AppKitBackend/BaseWidget.swift b/Sources/AppKitBackend/BaseWidget.swift deleted file mode 100644 index d14d6f1b0..000000000 --- a/Sources/AppKitBackend/BaseWidget.swift +++ /dev/null @@ -1,113 +0,0 @@ -import AppKit - -public class BaseWidget: NSView { - private var leftConstraint: NSLayoutConstraint? - private var topConstraint: NSLayoutConstraint? - private var widthConstraint: NSLayoutConstraint? - private var heightConstraint: NSLayoutConstraint? - - var x = 0 { - didSet { - if x != oldValue { - updateLeftConstraint() - } - } - } - - var y = 0 { - didSet { - if y != oldValue { - updateTopConstraint() - } - } - } - - var width = 0 { - didSet { - if width != oldValue { - updateWidthConstraint() - } - } - } - - var height = 0 { - didSet { - if height != oldValue { - updateHeightConstraint() - } - } - } - - init() { - super.init(frame: .zero) - - self.translatesAutoresizingMaskIntoConstraints = false - } - - @available(*, unavailable) - public required init?(coder: NSCoder) { - fatalError("init(coder:) is not used for this view") - } - - private func updateLeftConstraint() { - leftConstraint?.isActive = false - guard let superview else { return } - leftConstraint = self.leftAnchor.constraint( - equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x)) - leftConstraint!.isActive = true - } - - private func updateTopConstraint() { - topConstraint?.isActive = false - guard let superview else { return } - topConstraint = self.topAnchor.constraint( - equalTo: superview.safeAreaLayoutGuide.topAnchor, constant: CGFloat(y)) - topConstraint!.isActive = true - } - - private func updateWidthConstraint() { - widthConstraint?.isActive = false - widthConstraint = self.widthAnchor.constraint(equalToConstant: CGFloat(width)) - widthConstraint!.isActive = true - } - - private func updateHeightConstraint() { - heightConstraint?.isActive = false - heightConstraint = self.heightAnchor.constraint(equalToConstant: CGFloat(height)) - heightConstraint!.isActive = true - } - - public override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - - updateLeftConstraint() - updateTopConstraint() - } -} - -class WrapperWidget: BaseWidget { - init(child: View) { - super.init() - - self.addSubview(child) - child.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - child.topAnchor.constraint(equalTo: self.topAnchor), - child.leadingAnchor.constraint(equalTo: self.leadingAnchor), - child.bottomAnchor.constraint(equalTo: self.bottomAnchor), - child.trailingAnchor.constraint(equalTo: self.trailingAnchor), - ]) - } - - override convenience init() { - self.init(child: View(frame: .zero)) - } - - var child: View { - subviews[0] as! View - } - - override var intrinsicContentSize: CGSize { - child.intrinsicContentSize - } -} diff --git a/Sources/AppKitBackend/NSViewRepresentable.swift b/Sources/AppKitBackend/NSViewRepresentable.swift index b9d2037e3..004268287 100644 --- a/Sources/AppKitBackend/NSViewRepresentable.swift +++ b/Sources/AppKitBackend/NSViewRepresentable.swift @@ -6,8 +6,7 @@ public struct NSViewRepresentableContext { public internal(set) var environment: EnvironmentValues } -public protocol NSViewRepresentable: View -where Content == Never { +public protocol NSViewRepresentable: View where Content == Never { associatedtype NSViewType: NSView associatedtype Coordinator = Void @@ -46,6 +45,7 @@ where Content == Never { /// /// The default implementation uses `nsView.intrinsicContentSize` and `nsView.sizeThatFits(_:)` /// to determine the return value. + @MainActor func determineViewSize( for proposal: SIMD2, nsView: NSViewType, context: NSViewRepresentableContext @@ -101,8 +101,7 @@ extension NSViewRepresentable { } } -extension View -where Self: NSViewRepresentable { +extension View where Self: NSViewRepresentable { public var body: Never { preconditionFailure("This should never be called") } @@ -121,89 +120,10 @@ where Self: NSViewRepresentable { ) -> [LayoutSystem.LayoutableChild] { [] } - - public func asWidget( - _: any ViewGraphNodeChildren, - backend _: Backend - ) -> Backend.Widget { - if let widget = RepresentingWidget(representable: self) as? Backend.Widget { - return widget - } else { - fatalError("NSViewRepresentable requested by \(Backend.self)") - } - } - - public func update( - _ widget: Backend.Widget, - children _: any ViewGraphNodeChildren, - proposedSize: SIMD2, - environment: EnvironmentValues, - backend _: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let representingWidget = widget as! RepresentingWidget - representingWidget.update(with: environment) - - let size = - representingWidget.representable.determineViewSize( - for: proposedSize, - nsView: representingWidget.subview, - context: representingWidget.context! - ) - - if !dryRun { - representingWidget.width = size.size.x - representingWidget.height = size.size.y - } - - return ViewUpdateResult.leafView(size: size) - } } -extension NSViewRepresentable -where Coordinator == Void { +extension NSViewRepresentable where Coordinator == Void { public func makeCoordinator() { return () } } - -final class RepresentingWidget: BaseWidget { - var representable: Representable - var context: NSViewRepresentableContext? - - lazy var subview: Representable.NSViewType = { - let view = representable.makeNSView(context: context!) - - self.addSubview(view) - - view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - view.topAnchor.constraint(equalTo: self.topAnchor), - view.leadingAnchor.constraint(equalTo: self.leadingAnchor), - view.trailingAnchor.constraint(equalTo: self.trailingAnchor), - view.bottomAnchor.constraint(equalTo: self.bottomAnchor), - ]) - - return view - }() - - func update(with environment: EnvironmentValues) { - if context == nil { - context = .init(coordinator: representable.makeCoordinator(), environment: environment) - } else { - context!.environment = environment - representable.updateNSView(subview, context: context!) - } - } - - init(representable: Representable) { - self.representable = representable - super.init() - } - - deinit { - if let context { - Representable.dismantleNSView(subview, coordinator: context.coordinator) - } - } -} From db85feb2906ffe3b11205555a8723cab4b807e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?furby=E2=84=A2?= Date: Sat, 1 Mar 2025 00:13:32 -0700 Subject: [PATCH 07/10] UIKit: add support for swift 6 concurrency. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: furby™ --- Sources/UIKitBackend/UIViewRepresentable.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/UIKitBackend/UIViewRepresentable.swift b/Sources/UIKitBackend/UIViewRepresentable.swift index 5b20929cd..2bad1c9a8 100644 --- a/Sources/UIKitBackend/UIViewRepresentable.swift +++ b/Sources/UIKitBackend/UIViewRepresentable.swift @@ -46,6 +46,7 @@ where Content == Never { /// /// The default implementation uses `uiView.intrinsicContentSize` and `uiView.systemLayoutSizeFitting(_:)` /// to determine the return value. + @MainActor func determineViewSize( for proposal: SIMD2, uiView: UIViewType, context: UIViewRepresentableContext From 499a7450181aedb070d450f6c4c76abfd074c9d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?furby=E2=84=A2?= Date: Sat, 1 Mar 2025 10:48:03 -0700 Subject: [PATCH 08/10] AppKit: Add asWidget and update methods back. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: furby™ --- .../AppKitBackend/NSViewRepresentable.swift | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/Sources/AppKitBackend/NSViewRepresentable.swift b/Sources/AppKitBackend/NSViewRepresentable.swift index 004268287..11368e45f 100644 --- a/Sources/AppKitBackend/NSViewRepresentable.swift +++ b/Sources/AppKitBackend/NSViewRepresentable.swift @@ -120,6 +120,39 @@ extension View where Self: NSViewRepresentable { ) -> [LayoutSystem.LayoutableChild] { [] } + + public func asWidget( + _: any ViewGraphNodeChildren, + backend _: Backend + ) -> Backend.Widget { + if let widget = RepresentingWidget(representable: self) as? Backend.Widget { + return widget + } else { + fatalError("NSViewRepresentable requested by \(Backend.self)") + } + } + + @MainActor + public func update( + _ widget: Backend.Widget, + children _: any ViewGraphNodeChildren, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend _: Backend, + dryRun: Bool + ) -> ViewUpdateResult { + let representingWidget = widget as! RepresentingWidget + representingWidget.update(with: environment) + + let size = + representingWidget.representable.determineViewSize( + for: proposedSize, + nsView: representingWidget.subview, + context: representingWidget.context! + ) + + return ViewUpdateResult.leafView(size: size) + } } extension NSViewRepresentable where Coordinator == Void { @@ -127,3 +160,32 @@ extension NSViewRepresentable where Coordinator == Void { return () } } + + +final class RepresentingWidget { + var representable: Representable + var context: NSViewRepresentableContext? + + @MainActor + lazy var subview: Representable.NSViewType = { + let view = representable.makeNSView(context: context!) + + view.translatesAutoresizingMaskIntoConstraints = false + + return view + }() + + @MainActor + func update(with environment: EnvironmentValues) { + if context == nil { + context = .init(coordinator: representable.makeCoordinator(), environment: environment) + } else { + context!.environment = environment + representable.updateNSView(subview, context: context!) + } + } + + init(representable: Representable) { + self.representable = representable + } +} From f377e83f1b2f91f89e725386e8a100a8a88e3e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?furby=E2=84=A2?= Date: Sat, 1 Mar 2025 16:57:23 -0700 Subject: [PATCH 09/10] appkit: revert to swift 6 compatible but lopsided support. * This ensures scui works with swift 6 strict concurrency - however it is lopsided in that makeCoordinator() is not marked with a main actor. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: furby™ --- Sources/AppKitBackend/BaseWidget.swift | 123 ++++++++++++++++++ .../AppKitBackend/NSViewRepresentable.swift | 25 +++- 2 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 Sources/AppKitBackend/BaseWidget.swift diff --git a/Sources/AppKitBackend/BaseWidget.swift b/Sources/AppKitBackend/BaseWidget.swift new file mode 100644 index 000000000..487a96b02 --- /dev/null +++ b/Sources/AppKitBackend/BaseWidget.swift @@ -0,0 +1,123 @@ +import AppKit + +public class BaseWidget: NSView { + private var leftConstraint: NSLayoutConstraint? + private var topConstraint: NSLayoutConstraint? + private var widthConstraint: NSLayoutConstraint? + private var heightConstraint: NSLayoutConstraint? + + var x = 0 { + didSet { + if x != oldValue { + updateLeftConstraint() + } + } + } + + var y = 0 { + didSet { + if y != oldValue { + updateTopConstraint() + } + } + } + + var width = 0 { + didSet { + if width != oldValue { + updateWidthConstraint() + } + } + } + + var height = 0 { + didSet { + if height != oldValue { + updateHeightConstraint() + } + } + } + + init() { + super.init(frame: .zero) + + self.translatesAutoresizingMaskIntoConstraints = false + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not used for this view") + } + + private func updateLeftConstraint() { + leftConstraint?.isActive = false + guard let superview else { return } + if #available(macOS 11.0, *) { + leftConstraint = self.leftAnchor.constraint( + equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x)) + } else { + leftConstraint = self.leftAnchor.constraint( + equalTo: superview.leftAnchor, constant: CGFloat(x)) + } + leftConstraint!.isActive = true + } + + private func updateTopConstraint() { + topConstraint?.isActive = false + guard let superview else { return } + if #available(macOS 11.0, *) { + topConstraint = self.topAnchor.constraint( + equalTo: superview.safeAreaLayoutGuide.topAnchor, constant: CGFloat(x)) + } else { + topConstraint = self.topAnchor.constraint( + equalTo: superview.topAnchor, constant: CGFloat(x)) + } + topConstraint!.isActive = true + } + + private func updateWidthConstraint() { + widthConstraint?.isActive = false + widthConstraint = self.widthAnchor.constraint(equalToConstant: CGFloat(width)) + widthConstraint!.isActive = true + } + + private func updateHeightConstraint() { + heightConstraint?.isActive = false + heightConstraint = self.heightAnchor.constraint(equalToConstant: CGFloat(height)) + heightConstraint!.isActive = true + } + + public override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + + updateLeftConstraint() + updateTopConstraint() + } +} + +class WrapperWidget: BaseWidget { + init(child: View) { + super.init() + + self.addSubview(child) + child.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + child.topAnchor.constraint(equalTo: self.topAnchor), + child.leadingAnchor.constraint(equalTo: self.leadingAnchor), + child.bottomAnchor.constraint(equalTo: self.bottomAnchor), + child.trailingAnchor.constraint(equalTo: self.trailingAnchor), + ]) + } + + override convenience init() { + self.init(child: View(frame: .zero)) + } + + var child: View { + subviews[0] as! View + } + + override var intrinsicContentSize: CGSize { + child.intrinsicContentSize + } +} diff --git a/Sources/AppKitBackend/NSViewRepresentable.swift b/Sources/AppKitBackend/NSViewRepresentable.swift index 11368e45f..d1538fef5 100644 --- a/Sources/AppKitBackend/NSViewRepresentable.swift +++ b/Sources/AppKitBackend/NSViewRepresentable.swift @@ -27,7 +27,6 @@ public protocol NSViewRepresentable: View where Content == Never { /// /// The coordinator is used when the view needs to communicate changes to the rest of /// the view hierarchy (i.e. through bindings), and is often the view's delegate. - @MainActor func makeCoordinator() -> Coordinator /// Compute the view's size. @@ -151,6 +150,11 @@ extension View where Self: NSViewRepresentable { context: representingWidget.context! ) + if !dryRun { + representingWidget.width = size.size.x + representingWidget.height = size.size.y + } + return ViewUpdateResult.leafView(size: size) } } @@ -162,20 +166,26 @@ extension NSViewRepresentable where Coordinator == Void { } -final class RepresentingWidget { +final class RepresentingWidget: BaseWidget { var representable: Representable var context: NSViewRepresentableContext? - @MainActor lazy var subview: Representable.NSViewType = { let view = representable.makeNSView(context: context!) + self.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: self.topAnchor), + view.leadingAnchor.constraint(equalTo: self.leadingAnchor), + view.trailingAnchor.constraint(equalTo: self.trailingAnchor), + view.bottomAnchor.constraint(equalTo: self.bottomAnchor), + ]) return view }() - @MainActor func update(with environment: EnvironmentValues) { if context == nil { context = .init(coordinator: representable.makeCoordinator(), environment: environment) @@ -187,5 +197,12 @@ final class RepresentingWidget { init(representable: Representable) { self.representable = representable + super.init() + } + + deinit { + if let context { + Representable.dismantleNSView(subview, coordinator: context.coordinator) + } } } From f4cf5ef9c863cb891c1733181992db24358bc8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?furby=E2=84=A2?= Date: Sat, 1 Mar 2025 17:29:02 -0700 Subject: [PATCH 10/10] remove an additional, no longer needed MainActor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: furby™ --- Sources/AppKitBackend/NSViewRepresentable.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/AppKitBackend/NSViewRepresentable.swift b/Sources/AppKitBackend/NSViewRepresentable.swift index d1538fef5..52763e511 100644 --- a/Sources/AppKitBackend/NSViewRepresentable.swift +++ b/Sources/AppKitBackend/NSViewRepresentable.swift @@ -44,7 +44,6 @@ public protocol NSViewRepresentable: View where Content == Never { /// /// The default implementation uses `nsView.intrinsicContentSize` and `nsView.sizeThatFits(_:)` /// to determine the return value. - @MainActor func determineViewSize( for proposal: SIMD2, nsView: NSViewType, context: NSViewRepresentableContext @@ -131,7 +130,6 @@ extension View where Self: NSViewRepresentable { } } - @MainActor public func update( _ widget: Backend.Widget, children _: any ViewGraphNodeChildren,