From 235503b8c2453745c79dcc951629ec73ada26310 Mon Sep 17 00:00:00 2001 From: John Estropia Date: Thu, 5 Dec 2024 12:20:41 +0900 Subject: [PATCH] cleanup --- Development/Demo/MyBook.swift | 44 +++- Development/Demo/RootView.swift | 6 +- .../Preview/PreviewRegistryWrapper.swift | 203 +++++++++++++++ .../StorybookKit/Internals/machOLoader.swift | 232 +----------------- Sources/StorybookKit/Primitives/Book.swift | 52 +++- .../StorybookKit/Primitives/BookPage.swift | 14 +- .../StorybookKit/Primitives/BookPreview.swift | 12 +- .../StorybookKit/Primitives/BookStore.swift | 6 +- .../BookNodePreview.swift | 6 +- 9 files changed, 323 insertions(+), 252 deletions(-) create mode 100644 Sources/StorybookKit/Internals/Preview/PreviewRegistryWrapper.swift diff --git a/Development/Demo/MyBook.swift b/Development/Demo/MyBook.swift index d544523..b6be398 100644 --- a/Development/Demo/MyBook.swift +++ b/Development/Demo/MyBook.swift @@ -140,6 +140,43 @@ let myBook = Book.init( .foregroundStyle(Color.green) } +struct StorybookTrait: PreviewModifier { + func body(content: Content, context: Void) -> some View { + content + } +} + +@available(iOS 18.0, *) +extension PreviewTrait where T == Preview.ViewTraits { + + @MainActor public static var storybook: PreviewTrait { + return .init(.modifier(StorybookTrait())) + } +} + +struct Storybook: View { + + let content: AnyView + init( + @ViewBuilder content: () -> Content + ) { + self.content = .init(content()) + } + + var body: some View { + content + } +} + +@available(iOS 18.0, *) +#Preview(traits: .storybook) { + Text("My Component") +} + +#Preview { + Text("My Component") +} + #Preview { Text("NO TITLE!") .foregroundStyle(Color.purple) @@ -272,10 +309,10 @@ let myBook = Book.init( } } -#Preview("Some title 2") { +#Preview("Title") { #StorybookPreview { BookPreview { _ in - MyLabel(title: "MyLabel 2") + MyLabel(title: "Test") } } } @@ -284,9 +321,6 @@ let myBook = Book.init( BookPreview { _ in MyLabel(title: "Test") } - BookPreview { _ in - MyLabel(title: "Test") - } } #StorybookPage { diff --git a/Development/Demo/RootView.swift b/Development/Demo/RootView.swift index 19d565c..4e6b238 100644 --- a/Development/Demo/RootView.swift +++ b/Development/Demo/RootView.swift @@ -39,8 +39,10 @@ struct RootView: View { } if #available(iOS 17.0, *) { - Book(title: "#Preview macro") { - Book.allBookPreviews() + if let nodes = Book.allBookPreviews() { + Book(title: "#Preview macro") { + nodes + } } } } diff --git a/Sources/StorybookKit/Internals/Preview/PreviewRegistryWrapper.swift b/Sources/StorybookKit/Internals/Preview/PreviewRegistryWrapper.swift new file mode 100644 index 0000000..b40983b --- /dev/null +++ b/Sources/StorybookKit/Internals/Preview/PreviewRegistryWrapper.swift @@ -0,0 +1,203 @@ +import DeveloperToolsSupport +import Foundation +import SwiftUI +import UIKit + +@available(iOS 17.0, *) +struct PreviewRegistryWrapper: Comparable { + + let previewType: any DeveloperToolsSupport.PreviewRegistry.Type + let module: String + + init(_ previewType: any DeveloperToolsSupport.PreviewRegistry.Type) { + self.previewType = previewType + self.module = previewType.fileID.components(separatedBy: "/").first! + } + + var fileID: String { previewType.fileID } + var line: Int { previewType.line } + var column: Int { previewType.column } + + @MainActor + var makeView: (@MainActor () -> any View) { + let preview: FieldReader = .init(try! previewType.makePreview()) + let title: String? = preview["displayName"] + let source: FieldReader = preview["source"] + switch source.typeName { + + case "SwiftUI.ViewPreviewSource": // iOS 17 + let makeView: MakeFunctionWrapper = .init(source["makeView"]) + return { + VStack { + if let title, !title.isEmpty { + Text(title) + .font(.system(size: 17, weight: .semibold)) + } + AnyView(makeView()) + 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 + return { + VStack { + if let title, !title.isEmpty { + Text(title) + .font(.system(size: 17, weight: .semibold)) + } + Text("\(fileID):\(line)") + .font(.caption.monospacedDigit()) + Text("UIView Preview not supported (UIKit.UIViewPreviewSource)") + .foregroundStyle(Color.red) + .font(.caption.monospacedDigit()) + BookSpacer(height: 16) + } + } + + case "DeveloperToolsSupport.DefaultPreviewSource": // iOS 18 + let makeBody: MakeFunctionWrapper = .init(source["structure", "singlePreview", "makeBody"]) + return { + VStack { + if let title, !title.isEmpty { + Text(title) + .font(.system(size: 17, weight: .semibold)) + } + AnyView(makeBody()) + Text("\(fileID):\(line)") + .font(.caption.monospacedDigit()) + BookSpacer(height: 16) + } + } + + case "DeveloperToolsSupport.DefaultPreviewSource<__C.UIView>": // iOS 18 + let makeBody: MakeFunctionWrapper = .init(source["structure", "singlePreview", "makeBody"]) + return { + BookPreview( + fileID, + line, + title: title ?? source.typeName, + viewBlock: { _ in + makeBody() + } + ) + } + + case "DeveloperToolsSupport.DefaultPreviewSource<__C.UIViewController>": // iOS 18 + let makeBody: MakeFunctionWrapper = .init(source["structure", "singlePreview", "makeBody"]) + return { + BookPresent( + title: title ?? source.typeName, + presentingViewControllerBlock: { + makeBody() + } + ) + } + + case let sourceTypeName: + return { + VStack { + if let title, !title.isEmpty { + Text(title) + .font(.system(size: 17, weight: .semibold)) + } + Text("\(fileID):\(line)") + .font(.caption.monospacedDigit()) + Text("Failed to load preview (\(sourceTypeName))") + .foregroundStyle(Color.red) + .font(.caption.monospacedDigit()) + BookSpacer(height: 16) + } + } + } + } + + + // MARK: Comparable + + static func < (lhs: PreviewRegistryWrapper, rhs: PreviewRegistryWrapper) -> Bool { + if lhs.module == rhs.module { + return lhs.line < rhs.line + } + return lhs.module < rhs.module + } + + + // MARK: Equatable + + static func == (lhs: PreviewRegistryWrapper, rhs: PreviewRegistryWrapper) -> Bool { + lhs.line == rhs.line && lhs.module == rhs.module + } + + + // MARK: - FieldReader + + private struct FieldReader { + + let instance: Any + let typeName: String + + init(_ instance: Any) { + self.instance = instance + self.typeName = String(reflecting: type(of: instance)) + let mirror: Mirror = .init(reflecting: instance) + self.fields = .init( + uniqueKeysWithValues: mirror.children.compactMap { (label, value) in + label.map({ ($0, value) }) + } + ) + } + + subscript(_ key: String, _ nextKeys: String...) -> T { + if nextKeys.isEmpty { + return fields[key] as! T + } + else { + return Self.traverse(from: fields[key]!, nextKeys: nextKeys) as! T + } + } + + subscript(_ key: String, _ nextKeys: String...) -> FieldReader { + .init(Self.traverse(from: fields[key]!, nextKeys: nextKeys)) + } + + private let fields: [String: Any] + + private static func traverse>(from first: Any, nextKeys: C) -> Any { + if let key = nextKeys.first { + let mirror: Mirror = .init(reflecting: first) + return self.traverse( + from: mirror.children.first(where: { $0.label == key })!.value, + nextKeys: nextKeys.dropFirst() + ) + } + else { + return first + } + } + } + + + // MARK: - MakeFunctionWrapper + + @MainActor + private struct MakeFunctionWrapper { + + 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() + } + } +} diff --git a/Sources/StorybookKit/Internals/machOLoader.swift b/Sources/StorybookKit/Internals/machOLoader.swift index 61100a3..6aaf2e7 100644 --- a/Sources/StorybookKit/Internals/machOLoader.swift +++ b/Sources/StorybookKit/Internals/machOLoader.swift @@ -50,12 +50,12 @@ extension Book { @available(iOS 17.0, *) static func findAllPreviews( excludeStorybookPageMacro: Bool = true - ) -> [BookPage]? { + ) -> [PreviewRegistryWrapper]? { let moduleName = Bundle.main.bundleURL.deletingPathExtension().lastPathComponent guard !moduleName.isEmpty else { return nil } - var results: [BookPage] = [] + var results: [PreviewRegistryWrapper] = [] for imageIndex in 0 ..< _dyld_image_count() { self.findAllPreviews( inImageIndex: .init(imageIndex), @@ -138,7 +138,7 @@ extension Book { private static func findAllPreviews( inImageIndex imageIndex: UInt32, excludeStorybookPageMacro: Bool, - results: inout [BookPage] + results: inout [PreviewRegistryWrapper] ) { // Follows same approach here: https://github.com/apple/swift-testing/blob/main/Sources/TestingInternals/Discovery.cpp#L318 guard @@ -167,7 +167,6 @@ extension Book { 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 @@ -197,227 +196,12 @@ extension Book { 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() -// dump(preview) - 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) -// makeBookView = { title in -// BookPreview( -// fileID, -// line, -// title: title, -// viewBlock: { _ in -// 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 - } - } - - if let title, let makeBookView { - bookPagesByFileID[previewType.fileID, default: []].append( - ( - line: previewType.line, - title: title, - bookView: makeBookView - ) - ) - } - } - } - 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)) - } - } - ) - } - ) - ) + guard + let previewType = anyType as? DeveloperToolsSupport.PreviewRegistry.Type + else { + continue } + results.append(.init(previewType)) } } } diff --git a/Sources/StorybookKit/Primitives/Book.swift b/Sources/StorybookKit/Primitives/Book.swift index 459066f..1a14678 100644 --- a/Sources/StorybookKit/Primitives/Book.swift +++ b/Sources/StorybookKit/Primitives/Book.swift @@ -29,8 +29,56 @@ public struct Book: BookView, Identifiable { /// All `#Preview`s as `BookPage`s @available(iOS 17.0, *) - public static func allBookPreviews() -> [BookPage] { - self.findAllPreviews() ?? [] + public static func allBookPreviews() -> [Node]? { + guard let sortedPreviewRegistries = self.findAllPreviews() else { + return nil + } + var fileIDsByModule: [String: Set] = [:] + var registriesByFileID: [String: [PreviewRegistryWrapper]] = [:] + for item in sortedPreviewRegistries { + fileIDsByModule[item.module, default: []].insert(item.fileID) + registriesByFileID[item.fileID, default: []].append(item) + } + return fileIDsByModule.keys.sorted().map { module in + return Node.folder( + .init( + title: module, + contents: { [fileIDs = fileIDsByModule[module]!.sorted()] in + fileIDs.map { fileID in + return Node.page( + .init( + fileID, + 0, + title: .init(fileID[module.endIndex...]), + destination: { [registries = registriesByFileID[fileID]!] in + ScrollView { + LazyVStack( + alignment: .center, + spacing: 16, + pinnedViews: .sectionHeaders + ) { + Section( + content: { + ForEach.inefficient(items: registries) { registry in + AnyView(registry.makeView()) + } + }, + header: { + Text(fileID) + .truncationMode(.head) + .font(.caption.monospacedDigit()) + } + ) + } + } + } + ) + ) + } + } + ) + ) + } } /// All conformers to `BookProvider`, including those declared from the `#StorybookPage` macro diff --git a/Sources/StorybookKit/Primitives/BookPage.swift b/Sources/StorybookKit/Primitives/BookPage.swift index 9af1160..fcf69f5 100644 --- a/Sources/StorybookKit/Primitives/BookPage.swift +++ b/Sources/StorybookKit/Primitives/BookPage.swift @@ -59,16 +59,16 @@ public struct BookPage: BookView, Identifiable { public let title: String public let destination: AnyView public nonisolated let declarationIdentifier: DeclarationIdentifier - private let file: String - private let line: Int + private let fileID: any StringProtocol + private let line: any FixedWidthInteger public init( - _ file: String = #fileID, - _ line: Int = #line, + _ fileID: any StringProtocol = #fileID, + _ line: any FixedWidthInteger = #line, title: String, @ViewBuilder destination: @MainActor () -> Destination ) { - self.file = file + self.fileID = fileID self.line = line self.title = title self.destination = AnyView(destination()) @@ -83,14 +83,14 @@ public struct BookPage: BookView, Identifiable { } .listStyle(.plain) .onAppear(perform: { - context?.onOpen(page: self) + context?.onOpen(pageID: id) }) } label: { HStack { Image.init(systemName: "doc") VStack(alignment: .leading) { Text(title) - Text("\(file.description):\(line.description)") + Text("\(fileID):\(line)") .font(.caption.monospacedDigit()) .opacity(0.8) } diff --git a/Sources/StorybookKit/Primitives/BookPreview.swift b/Sources/StorybookKit/Primitives/BookPreview.swift index a2cec2a..820052a 100644 --- a/Sources/StorybookKit/Primitives/BookPreview.swift +++ b/Sources/StorybookKit/Primitives/BookPreview.swift @@ -46,20 +46,20 @@ public struct BookPreview: BookView { public let declarationIdentifier: DeclarationIdentifier - private let file: String - private let line: Int + private let fileID: any StringProtocol + private let line: any FixedWidthInteger private let title: String? private var frameConstraint: FrameConstraint = .init() public init( - _ file: String = #fileID, - _ line: Int = #line, + _ fileID: any StringProtocol = #fileID, + _ line: any FixedWidthInteger = #line, title: String? = nil, viewBlock: @escaping @MainActor (inout Context) -> UIView ) { self.title = title - self.file = file + self.fileID = fileID self.line = line self.viewBlock = viewBlock @@ -95,7 +95,7 @@ public struct BookPreview: BookView { controlView - Text("\(file.description):\(line.description)") + Text("\(fileID):\(line)") .font(.caption.monospacedDigit()) BookSpacer(height: 16) diff --git a/Sources/StorybookKit/Primitives/BookStore.swift b/Sources/StorybookKit/Primitives/BookStore.swift index 2d6c962..ffa02bf 100644 --- a/Sources/StorybookKit/Primitives/BookStore.swift +++ b/Sources/StorybookKit/Primitives/BookStore.swift @@ -44,13 +44,13 @@ public final class BookStore: ObservableObject { } - func onOpen(page: BookPage) { + func onOpen(pageID: DeclarationIdentifier) { - guard allPages.keys.contains(page.id) else { + guard allPages.keys.contains(pageID) else { return } - let index = page.declarationIdentifier.index + let index = pageID.index var current = userDefaults.array(forKey: "history") as? [Int] ?? [] if let index = current.firstIndex(of: index) { diff --git a/Sources/StorybookKitTextureSupport/BookNodePreview.swift b/Sources/StorybookKitTextureSupport/BookNodePreview.swift index c04d415..ef3e294 100644 --- a/Sources/StorybookKitTextureSupport/BookNodePreview.swift +++ b/Sources/StorybookKitTextureSupport/BookNodePreview.swift @@ -30,13 +30,13 @@ public struct BookNodePreview: BookView { private var backing: BookPreview public init( - _ file: String = #fileID, - _ line: Int = #line, + _ fileID: any StringProtocol = #fileID, + _ line: any FixedWidthInteger = #line, title: String? = nil, nodeBlock: @escaping @MainActor (inout BookPreview.Context) -> ASDisplayNode ) { - self.backing = .init(file, line, title: title) { context in + self.backing = .init(fileID, line, title: title) { context in let body = nodeBlock(&context) let node = AnyDisplayNode { _, size in