diff --git a/Development/Demo/MyBook.swift b/Development/Demo/MyBook.swift index a461f14..d544523 100644 --- a/Development/Demo/MyBook.swift +++ b/Development/Demo/MyBook.swift @@ -135,6 +135,77 @@ let myBook = Book.init( } ) +#Preview("Simple Sample") { + Text("Yo there!") + .foregroundStyle(Color.green) +} + +#Preview { + Text("NO TITLE!") + .foregroundStyle(Color.purple) +} + +@available(iOS 17.0, *) +#Preview("States Sample") { + @Previewable @State var state: Int = 1 + + VStack { + Text("Aloha \(state)") + Button( + action: { state += 1 }, + label: { Text("+") } + ) + } +} + +@available(iOS 17.0, *) +#Preview("UIView Sample") { + { + var state: Int = 1 + + let label = UILabel() + label.text = "Test \(state)" + + let button = UIButton( + configuration: .bordered(), + primaryAction: .init { _ in + state += 1 + label.text = "Test \(state)" + } + ) + button.setTitle("+", for: .normal) + let stack = UIStackView(arrangedSubviews: [label, button]) + stack.spacing = 8 + return stack + }() +} + +@available(iOS 17.0, *) +#Preview("UIViewController Sample") { + { + let controller = UIViewController() + controller.view.backgroundColor = .systemMint + var state: Int = 1 + + let label = UILabel() + label.text = "Test \(state)" + + let button = UIButton( + configuration: .bordered(), + primaryAction: .init { _ in + state += 1 + label.text = "Test \(state)" + } + ) + button.setTitle("+", for: .normal) + let stack = UIStackView(arrangedSubviews: [label, button]) + stack.spacing = 8 + stack.frame = controller.view.bounds + controller.view.addSubview(stack) + return controller + }() +} + #StorybookPage(title: "UILabel updating text") { BookPreview { context in let label = UILabel() diff --git a/Development/Demo/RootView.swift b/Development/Demo/RootView.swift index 7ef8f23..19d565c 100644 --- a/Development/Demo/RootView.swift +++ b/Development/Demo/RootView.swift @@ -37,6 +37,12 @@ struct RootView: View { Book.allStorybookPages() .map({ $0.bookBody }) } + + if #available(iOS 17.0, *) { + Book(title: "#Preview macro") { + Book.allBookPreviews() + } + } } ) ) diff --git a/Development/Storybook.xcodeproj/project.pbxproj b/Development/Storybook.xcodeproj/project.pbxproj index 1792ffe..b709384 100644 --- a/Development/Storybook.xcodeproj/project.pbxproj +++ b/Development/Storybook.xcodeproj/project.pbxproj @@ -324,7 +324,7 @@ CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Demo/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -344,7 +344,7 @@ CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Demo/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Development/Storybook.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Development/Storybook.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d0403d3..9514101 100644 --- a/Development/Storybook.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Development/Storybook.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "f5a894bbdc3287a91c8c33f864cfb447314305f7fc66cabf1c369e8c3a67521d", "pins" : [ { "identity" : "descriptors", @@ -32,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-macro-testing.git", "state" : { - "branch" : "main", - "revision" : "15916c0c328339f54c15d616465d79700e3f7de8" + "revision" : "20c1a8f3b624fb5d1503eadcaa84743050c350f4", + "version" : "0.5.2" } }, { @@ -41,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "e7b77228b34057041374ebef00c0fd7739d71a2b", - "version" : "1.15.3" + "revision" : "7b0bbbae90c41f848f90ac7b4df6c4f50068256d", + "version" : "1.17.5" } }, { @@ -50,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", - "version" : "509.1.1" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { @@ -86,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/FluidGroup/TextureBridging.git", "state" : { - "branch" : "main", - "revision" : "4383f8a9846a0507d2c22ee9fac22153f9b86fed" + "revision" : "4383f8a9846a0507d2c22ee9fac22153f9b86fed", + "version" : "3.2.1" } }, { @@ -95,10 +96,10 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/FluidGroup/TextureSwiftSupport.git", "state" : { - "branch" : "main", - "revision" : "5bae50cab3798dccb8b98c3ffbc70320ae66b45a" + "revision" : "fb748d6a9d0a2dca0635227e1db0360fd26e0e24", + "version" : "3.20.1" } } ], - "version" : 2 + "version" : 3 } diff --git a/Sources/StorybookKit/Internals/machOLoader.swift b/Sources/StorybookKit/Internals/machOLoader.swift index daeb00c..b78983a 100644 --- a/Sources/StorybookKit/Internals/machOLoader.swift +++ b/Sources/StorybookKit/Internals/machOLoader.swift @@ -47,6 +47,25 @@ extension Book { return results } + @available(iOS 17.0, *) + static func findAllPreviews( + excludeStorybookPageMacro: Bool = true + ) -> [BookPage]? { + let moduleName = Bundle.main.bundleURL.deletingPathExtension().lastPathComponent + guard !moduleName.isEmpty else { + return nil + } + var results: [BookPage] = [] + for imageIndex in 0 ..< _dyld_image_count() { + self.findAllPreviews( + inImageIndex: .init(imageIndex), + excludeStorybookPageMacro: excludeStorybookPageMacro, + results: &results + ) + } + return results + } + private static func findAllBookProviders( inImageIndex imageIndex: UInt32, filterByStorybookPageMacro: Bool, @@ -113,42 +132,294 @@ extension Book { metadataAccessFunction, to: Any.Type.self ) - if #available(iOS 17.0, *) { - if let previewType = anyType as? any DeveloperToolsSupport.PreviewRegistry.Type { - print("========") - print("fileID: \(previewType.fileID)") - print("line: \(previewType.line)") - print("column: \(previewType.column)") - - let preview = try! previewType.makePreview() - dump(preview) - - let mirror: Mirror = .init(reflecting: preview) - for child in mirror.children { - switch child.label { - case "displayName": - print("displayName: \(child.value)") - - case "source": - let mirror: Mirror = .init(reflecting: child.value) - let factory = mirror.children.first!.value as! @MainActor () -> any SwiftUI.View -// let view = AnyView(factory()) - print("view:") - let anyView: AnyView = MainActor.assumeIsolated(factory) as! AnyView - dump(anyView) - print("========") - - default: + guard let bookProviderType = anyType as? any BookProvider.Type else { + continue + } + results.append(bookProviderType) + } + } + + @available(iOS 17.0, *) + private static func findAllPreviews( + inImageIndex imageIndex: UInt32, + excludeStorybookPageMacro: Bool, + results: inout [BookPage] + ) { + // Follows same approach here: https://github.com/apple/swift-testing/blob/main/Sources/TestingInternals/Discovery.cpp#L318 + guard + let headerRawPtr: UnsafeRawPointer = _dyld_get_image_header(imageIndex) + .map(UnsafeRawPointer.init(_:)) + else { + return + } + let headerPtr = headerRawPtr.assumingMemoryBound( + to: mach_header_64.self + ) + // https://derekselander.github.io/dsdump/ + var size: UInt = 0 + guard + let sectionRawPtr = getsectiondata( + headerPtr, + SEG_TEXT, + "__swift5_types", + &size + ) + .map(UnsafeRawPointer.init(_:)) + else { + return + } + let capacity: Int = .init(size) / MemoryLayout.size + let sectionPtr = sectionRawPtr.assumingMemoryBound( + to: SwiftTypeMetadataRecord.self + ) + var bookPagesByFileID: [String: [(line: Int, title: String, bookView: (String) -> any View)]] = [:] + for index in 0 ..< capacity { + let record = sectionPtr.advanced(by: index) + guard + let contextDescriptor = record.pointee.contextDescriptor( + from: record + ) + else { + continue + } + guard !contextDescriptor.pointee.isGeneric() else { + continue + } + guard + contextDescriptor.pointee.kind().canConformToProtocol, + !excludeStorybookPageMacro || !self._magicSubstring.withCString( + { + let nameCString = contextDescriptor.resolvePointer(for: \.name) + return nil != strstr(nameCString, $0) + } + ) + else { + continue + } + let metadataClosure = contextDescriptor.resolveValue(for: \.metadataAccessFunction) + let metadata = metadataClosure(0xFF) + guard + let metadataAccessFunction = metadata.value + else { + continue + } + let anyType = unsafeBitCast( + metadataAccessFunction, + to: Any.Type.self + ) + + @MainActor + struct MakeViewFactory { + + typealias Closure = @MainActor () -> T + private let closure: Closure + + init(_ closure: Any) { + // TODO: We need a workaround to avoid implicit @Sendable from @MainActor closures + self.closure = unsafeBitCast( + closure, + to: Closure.self + ) + } + + func callAsFunction() -> T { + closure() + } + } + + if let previewType = anyType as? any DeveloperToolsSupport.PreviewRegistry.Type { + let fileID = previewType.fileID + let line = previewType.line + let preview = try! previewType.makePreview() + + var title: String? + var makeBookView: ((String) -> any View)? + let previewMirror: Mirror = .init(reflecting: preview) + for previewChild in previewMirror.children { + switch previewChild.label { + case "displayName": + title = (previewChild.value as! String?) ?? "" + + case "source": + let sourceMirror: Mirror = .init(reflecting: previewChild.value) + switch String(reflecting: sourceMirror.subjectType) { + + case "SwiftUI.ViewPreviewSource": // iOS 17 + guard + let anyFactory = sourceMirror.children + .first(where: { $0.label == "makeView" }) + else { + break + } + let factory = MakeViewFactory(anyFactory.value) + makeBookView = { title in + VStack { + if !title.isEmpty { + Text(title) + .font(.system(size: 17, weight: .semibold)) + } + AnyView(factory()) + Text("\(fileID):\(line)") + .font(.caption.monospacedDigit()) + BookSpacer(height: 16) + } + } + + case "UIKit.UIViewPreviewSource": // iOS 17 + // Unsupported due to iOS 17 not supporting casting between non-sendable closure types + break +// guard +// let anyFactory = sourceMirror.children +// .first(where: { $0.label == "makeView" }) +// else { +// break +// } +// let factory = MakeViewFactory(anyFactory.value) +// let view = factory() +// print(view) +// dump(view) +// anyView = .init(view) + + case "DeveloperToolsSupport.DefaultPreviewSource": // iOS 18 + guard + let structureMirror = sourceMirror.children + .first(where: { $0.label == "structure" }) + .map({ Mirror.init(reflecting: $0.value) }), + let previewMirror = structureMirror.children + .first(where: { $0.label == "singlePreview" }) + .map({ Mirror.init(reflecting: $0.value) }), + let anyFactory = previewMirror.children + .first(where: { $0.label == "makeBody" }) + else { + break + } + let factory = MakeViewFactory(anyFactory.value) + makeBookView = { title in + VStack { + if !title.isEmpty { + Text(title) + .font(.system(size: 17, weight: .semibold)) + } + AnyView(factory()) + Text("\(fileID):\(line)") + .font(.caption.monospacedDigit()) + BookSpacer(height: 16) + } + } + + case "DeveloperToolsSupport.DefaultPreviewSource<__C.UIView>": // iOS 18 + guard + let structureMirror = sourceMirror.children + .first(where: { $0.label == "structure" }) + .map({ Mirror.init(reflecting: $0.value) }), + let previewMirror = structureMirror.children + .first(where: { $0.label == "singlePreview" }) + .map({ Mirror.init(reflecting: $0.value) }), + let anyFactory = previewMirror.children + .first(where: { $0.label == "makeBody" }) + else { + break + } + let factory = MakeViewFactory(anyFactory.value) + makeBookView = { title in + BookPreview( + fileID, + line, + title: title, + viewBlock: { _ in + factory() + } + ) + } + + case "DeveloperToolsSupport.DefaultPreviewSource<__C.UIViewController>": // iOS 18 + guard + let structureMirror = sourceMirror.children + .first(where: { $0.label == "structure" }) + .map({ Mirror.init(reflecting: $0.value) }), + let previewMirror = structureMirror.children + .first(where: { $0.label == "singlePreview" }) + .map({ Mirror.init(reflecting: $0.value) }), + let anyFactory = previewMirror.children + .first(where: { $0.label == "makeBody" }) + else { + break + } + let factory = MakeViewFactory(anyFactory.value) + makeBookView = { title in + BookPresent( + title: title, + presentingViewControllerBlock: { + factory() + } + ) + } + + case let sourceTypeName: + print(sourceTypeName) break } + + case "traits": + let traitsMirror: Mirror = .init(reflecting: previewChild.value) + dump(traitsMirror) + break + + default: + print(previewChild.label) + break } - print("========") + } + + if let title, let makeBookView { + bookPagesByFileID[previewType.fileID, default: []].append( + ( + line: previewType.line, + title: title, + bookView: makeBookView + ) + ) } } - guard let bookProviderType = anyType as? any BookProvider.Type else { - continue + } + bookPagesByFileID.sorted { $0.key < $1.key }.forEach { (file, items) in + switch items.count { + case 0: + return + + case 1: + let (line, title, makeBookView) = items[0] + results.append( + BookPage( + file, + line, + title: title, + destination: { + AnyView(makeBookView(title)) + } + ) + ) + + default: + results.append( + BookPage( + file, + 0, + title: file, + destination: { + BookSection( + title: file, + content: { + ForEach.inefficient(items: items.sorted { $0.line < $1.line }) { (line, title, makeBookView) in + BookSpacer(height: 16) + AnyView(makeBookView(title)) + } + } + ) + } + ) + ) } - results.append(bookProviderType) } } } diff --git a/Sources/StorybookKit/Primitives/Book.swift b/Sources/StorybookKit/Primitives/Book.swift index 74f81e9..459066f 100644 --- a/Sources/StorybookKit/Primitives/Book.swift +++ b/Sources/StorybookKit/Primitives/Book.swift @@ -27,6 +27,12 @@ public struct Book: BookView, Identifiable { public let title: String public let contents: [Node] + /// All `#Preview`s as `BookPage`s + @available(iOS 17.0, *) + public static func allBookPreviews() -> [BookPage] { + self.findAllPreviews() ?? [] + } + /// All conformers to `BookProvider`, including those declared from the `#StorybookPage` macro public static func allBookProviders() -> [any BookProvider.Type] { self.findAllBookProviders(filterByStorybookPageMacro: false) ?? [] diff --git a/Sources/StorybookKit/Primitives/BookPage.swift b/Sources/StorybookKit/Primitives/BookPage.swift index 2ed9210..9af1160 100644 --- a/Sources/StorybookKit/Primitives/BookPage.swift +++ b/Sources/StorybookKit/Primitives/BookPage.swift @@ -59,12 +59,12 @@ public struct BookPage: BookView, Identifiable { public let title: String public let destination: AnyView public nonisolated let declarationIdentifier: DeclarationIdentifier - private let file: StaticString - private let line: UInt + private let file: String + private let line: Int public init( - _ file: StaticString = #fileID, - _ line: UInt = #line, + _ file: String = #fileID, + _ line: Int = #line, title: String, @ViewBuilder destination: @MainActor () -> Destination ) { diff --git a/Sources/StorybookKit/Primitives/BookPreview.swift b/Sources/StorybookKit/Primitives/BookPreview.swift index 6289f68..a2cec2a 100644 --- a/Sources/StorybookKit/Primitives/BookPreview.swift +++ b/Sources/StorybookKit/Primitives/BookPreview.swift @@ -46,14 +46,14 @@ public struct BookPreview: BookView { public let declarationIdentifier: DeclarationIdentifier - private let file: StaticString - private let line: UInt + private let file: String + private let line: Int private let title: String? private var frameConstraint: FrameConstraint = .init() public init( - _ file: StaticString = #fileID, - _ line: UInt = #line, + _ file: String = #fileID, + _ line: Int = #line, title: String? = nil, viewBlock: @escaping @MainActor (inout Context) -> UIView ) { diff --git a/Sources/StorybookKitTextureSupport/BookNodePreview.swift b/Sources/StorybookKitTextureSupport/BookNodePreview.swift index ed4c2bd..c04d415 100644 --- a/Sources/StorybookKitTextureSupport/BookNodePreview.swift +++ b/Sources/StorybookKitTextureSupport/BookNodePreview.swift @@ -30,8 +30,8 @@ public struct BookNodePreview: BookView { private var backing: BookPreview public init( - _ file: StaticString = #fileID, - _ line: UInt = #line, + _ file: String = #fileID, + _ line: Int = #line, title: String? = nil, nodeBlock: @escaping @MainActor (inout BookPreview.Context) -> ASDisplayNode ) {