Add SwiftUI hierarchy support to view debugger - #414
Conversation
DebugSwift: Coverage: 14.14
Example.app: Coverage: 13.07
ExampleTests.xctest: Coverage: 97.0
Generated by 🚫 Danger Swift against 2dff17c |
2772e4d to
2dff17c
Compare
The 3D snapshot view now renders real UIKit subviews with proper frames and pixel snapshots, while the hierarchy table shows the declarative SwiftUI semantic tree (VStack, Text, Button, etc.) instead of internal infrastructure classes like DisplayList.View and PlatformView. This uses a two-tree approach: the 3D snapshot uses the UIKit subview tree (useSwiftUIHierarchy: false) for real pixel snapshots, and the hierarchy table uses the SwiftUI semantic tree (useSwiftUIHierarchy: true). Selection sync between the two views maps by underlying UIView pointer identity. Also adds a Makefile for fast build-and-run on the simulator. Co-authored-by: oh-my-pi <https://omp.sh>
A _UIHostingView renders its content directly into the hosting view and exposes zero UIView subviews, so the 3D snapshot tree (useSwiftUIHierarchy: false) was a single leaf with no children. SnapshotView then treats the scene as a flat plane via `isLeft` (hides the depth/spacing sliders) and the view debugger's 3D viewdraw becomes useless for SwiftUI screens. When the 3D snapshot path hits a SwiftUI hosting view that has no UIKit subviews, fall back to the SwiftUI semantic tree so its children render as layered planes in the SceneKit scene, mirroring the hierarchy table. Add regression tests (ViewDebuggerSwiftUIRenderTests) over a Button-only SwiftUI surface: the 3D snapshot tree must have children, contain SwiftUIElement nodes, not be a flat plane (isLeft == false), and reach the SwiftUI hierarchy from a window-level snapshot — while plain UIViews keep walking UIView.subviews unchanged. Co-authored-by: oh-my-pi <https://omp.sh>
2dff17c to
c548673
Compare
Update: 3D snapshot viewdraw fixed + regression testsRoot causeA Fix (
|
Before this change every SwiftUI semantic sibling shared the parent's frame, so three Buttons in a VStack overlapped at the same X/Y in the 3D scene (only separated by z-depth) — they were not visually separated. Distribute the assigned frame among SwiftUI children as non-overlapping slices along the layout axis inherited from the nearest VStack/HStack/ ZStack, propagating through transparent containers (TupleView, ModifiedContent, Group, AnyView) so the real siblings get distinct positions. A VStack of three Buttons now yields three stacked, separated planes instead of one. Also stop the SwiftUI hierarchy builder from emitting duplicate and infrastructure nodes (the internal Tree<…> wrapper, layout descriptors like _VStackLayout, Optional<CGFloat> spacing, ButtonRole plumbing). Left in, these split the frame distribution across phantom siblings and doubled the Buttons. Added isInfrastructureType and a TupleView content skip in the generic Mirror walk. Tests (ViewDebuggerSwiftUIRenderTests) over a VStack of three Buttons: - three distinct Button elements - non-overlapping frames - stacked vertically with strictly increasing Y origins All prior view-debugger tests still pass. Co-authored-by: oh-my-pi <https://omp.sh>
Update: 3 buttons now visually separated in the 3D viewProblemEvery SwiftUI semantic sibling shared the parent's frame, so three Buttons in a VStack overlapped at the same X/Y in the 3D scene — they were only separated by z-depth, not visually separated. FixFrame distribution ( Hierarchy dedup ( Result — three Buttons in a VStack now render as three separated planesNon-overlapping, same X column, strictly increasing Y — exactly "separate the 3 buttons". Tests (
|
getAllValues() formatted each stack-trace line with "\(trace.info)", which used Swift's default struct description and pasted "Info(title: "0x0001 main", detail: "")" instead of the readable frame symbol. Print the frame title directly (and the detail when present), so Copy Text pastes: Stack Trace: 0x0001 main 0x0002 foo 0x0003 bar Regression test asserts the copied stack trace contains the bare frame symbols and does NOT contain "Info(title:". Co-authored-by: oh-my-pi <https://omp.sh>
Five of the eight detail titles are authored with a trailing colon
("App Version:", "Build Version:", …) while three are not ("Error",
"Date"). getAllValues() appended ": " unconditionally, producing
double colons in the copied report ("App Version:: 1.11.3").
Strip a single trailing colon from each title before joining so the
copied Details section is consistent:
App Version: 1.11.3
Build Version: 1
iOS Version: 26.3
Updated tests assert the single-colon form and that no "::" appears.
Co-authored-by: oh-my-pi <https://omp.sh>
screens backed by non-KVC hosting controllers swiftUITree(for:) had a KVC fallback `viewController.value(forKey: "rootView")` that raised NSUnknownKeyException (an Obj-C exception, not a Swift error) on SwiftUI-internal hosting controllers that do not expose rootView via KVC — e.g. UIHostingController<ModifiedContent<…NavigationSearchColumnModifier…>> used by NavigationSplitView's sidebar. DebugSwift's own UncaughtExceptionHandler captured that as a crash, killing the app whenever the view debugger was opened on a screen using such a controller. Remove the KVC fallback. Mirror reflection (on the _UIHostingView and on the controller) is safe and never raises Obj-C exceptions; if it can't find rootView, the view falls back to the subview path instead of crashing. The real crash report in nsexception_crashes.json had the stack: swiftUITree ViewElement.children getter Snapshot.init(element:) InAppViewDebugger.presentForWindow WindowManager.presentViewDebugger Regression test uses a FakeHostingController with "HostingController" in its type name and no rootView key to reproduce the branch; it must not crash and must fall back to 0 children. Co-authored-by: oh-my-pi <https://omp.sh>
…3D view
A ScrollView's _UIHostingView exposes only SwiftUI-internal UIKit subviews
(PlatformContainer, HostingScrollView, …), so the 3D snapshot tree walked
those and collapsed the OSLog Console Test screen to a single flat card —
its six buttons were never divided.
Fixes:
- ViewElement.children: always prefer the SwiftUI semantic tree for a
hosting view (both hierarchy-table and 3D-snapshot modes). A hosting
view's UIKit subviews are always SwiftUI internals, never the
developer's content, so walking them produces a flat tree of
infrastructure nodes for ScrollView/Form/List just as it did for a
plain VStack.
- SwiftUIHierarchyBuilder: match TupleView/_ConditionalContent by prefix
so a ModifiedContent<VStack<TupleView<…>>> isn't mistaken for a
TupleView (that caused the content to be emitted 2-3×, splitting the
frame distribution across phantom siblings). Skip the duplicate
`content` stored property when `_tree`/ScrollView is present, and
treat SwiftUI configuration/gesture/ButtonAction plumbing as
infrastructure so it isn't surfaced as nodes.
- SwiftUIElement: flatten transparent wrappers (ScrollView,
ModifiedContent, TupleView, raw tuples, Group, _ConditionalContent,
AnyView, ForEach) so the real layout container (VStack) receives the
full parent frame and subdivides it among the real siblings. Teach
childLayout that ScrollView/List/Form/LazyVStack stack content
vertically. isTransparentWrapper excludes VStack/HStack/ZStack (whose
generic names contain "TupleView") so real layout containers aren't
flattened away.
Result: ScrollView > VStack { Button×6 } now yields six buttons with
non-overlapping vertical frames (402×142 at y = 0, 146, 293, 439, 585,
732), same X column, strictly increasing Y — the buttons are divided.
Regression tests (OSLogRenderDebugTests) over a ScrollView > VStack with
six buttons: six distinct Button elements, non-overlapping frames,
stacked vertically with strictly increasing Y. All 54 view-debugger and
crash tests still pass.
Co-authored-by: oh-my-pi <https://omp.sh>
presentViewDebugger filtered windows below alert level and, when more
than one candidate existed, showed a picker with no default. In apps
that host their own floating overlay window at a normal window level
(e.g. FloatingController.UltimoFloatingGrid3D), the user could pick —
or the single-window fast path could grab — the overlay instead of the
app's main content window, rendering a single empty plane and none of
the real UI.
Sort the key window first in the candidate list so:
- the single-window fast path grabs the key window when only one
candidate remains, and
- the picker lists the key window first, labeled "(key)", so it's
the obvious default choice.
Co-authored-by: oh-my-pi <https://omp.sh>
…creens The 3D snapshot collapsed the OSLog Console Test screen (and any screen wrapped in NavigationView/ScrollView) to a single flat card because the hosting view's `rootView` was never reached: - `UIHostingController.rootView` is a generic, non-@objc, non-KVC-compliant computed property. Swift `Mirror` never lists it (only stored properties), and KVC `value(forKey: "rootView")` raises `NSUnknownKeyException` on the controller (not KVC-compliant) and returns a wrong value on the view. So `swiftUITree` returned nil, the hosting view fell back to empty `view.subviews`, and the snapshot tree stopped at the `_UIHostingView` with zero children — the buttons inside were invisible to the 3D view. Fix: read `rootView` through a type-erased protocol. Define `AnyHostingController { var rootViewValue: Any { get } }` and conform `UIHostingController` to it — the conformance captures the generic `Content` and returns `rootView as Any` via the public Swift API, with no KVC and no Obj-C exception. Walk the responder chain to a hosting controller and read `rootViewValue`. This is crash-safe (no KVC, so no `NSUnknownKeyException`) and reaches the real SwiftUI tree. Fix extractBody to call `body` on custom views without trapping on primitives: `body()` traps ("body() should not be called on …") on primitive SwiftUI views (NavigationView, ScrollView, Button, …), so gate the call behind an `isPrimitiveViewType` type-name check (incl. Optional/IdentityOptional) and only invoke `bodyAccessor()` on user views. Primitives continue to expose content via Mirror stored properties (`_tree`, `content`, …). Removed the Obj-C DebugSwiftObjC target/exception-catcher and the KVC/ Mirror fallbacks: the protocol path replaces them entirely and is both crash-safe and correct. Result: NavigationView > ScrollView > VStack { Button×6 } now yields six Button elements with non-overlapping, vertically stacked, distinct-Y frames in the 3D snapshot tree. Regression tests (OSLogRenderDebugTests) assert six elements, non-overlapping frames, and stacked distinct Y. All 55 view-debugger + crash tests pass. Co-authored-by: oh-my-pi <https://omp.sh>
…rash) The previous extractBody invoked the View extension's bodyAccessor() on every node whose Mirror walk yielded no children — but SwiftUI *leaf* primitives (Color, Spacer, EmptyView, SubscriptionView<A, B>, …) also have zero Mirror children, so the fallback fired for them and called body(), which traps with "body() should not be called on …" and kills the app through DebugSwift's own CrashSignalHandler (SIGTRAP). The type- name allowlist could not enumerate every primitive; SubscriptionView (from .onReceive/.task modifiers) was missed and crashed opening the view debugger on a screen that used one. Two complementary guards, no allowlist: 1. Mirror-first extraction with a body() fallback. buildNode now runs the Mirror-based content extraction (steps 1–5) FIRST and only falls back to calling body() when Mirror yields no children. Primitives that DO expose stored content (_tree, content, tuple, …) never reach the fallback. 2. Module guard in extractBody. String(reflecting: type(of:)) yields the fully-qualified path (SwiftUI.Color, SwiftUI.SubscriptionView<A, B>). extractBody returns nil for anything whose module is SwiftUI/ SwiftUICore, so leaf primitives — which have no Mirror children — are still skipped before body() is ever called. User views (in the app module) pass and get their body called as intended. Removed the primitiveViewTypeNames allowlist entirely: the module guard is exhaustive and can't be broken by a new SwiftUI primitive. All 54 view-debugger + crash tests pass (incl. the OSLog 6-button separation tests and the non-KVC-compliant controller crash test). Co-authored-by: oh-my-pi <https://omp.sh>
The SubscriptionView SIGTRAP recurred because every prior strategy still called body on a child somewhere: the type-name allowlist missed SubscriptionView, and the fallback-when-Mirror-is-empty heuristic fired on leaf primitives (Color/Spacer/SubscriptionView have no Mirror children either). No allowlist can enumerate SwiftUI primitives, and Swift fatal errors (body should not be called on) cannot be caught by do/catch or Obj-C @Try — they raise SIGTRAP through DebugSwift's own CrashSignalHandler and kill the app. New strategy: evaluate body exactly ONCE, at the root, then never again. - buildTree calls extractRootBody on the hosting controller's rootView (a user-defined custom view, always safe), getting past the one computed property Mirror cannot see. The result is handed to buildNode. - buildNode recurses PURELY via Mirror stored properties (_tree, content, tuple). It never calls body. Primitives can only ever appear as children (the result of some view body, or nested in a primitive stored _tree/content), so by never calling body on a child, no primitive is ever asked for its body. Crash-proof by construction, not by guessing type names. Guard: if the root is itself a SwiftUI-module view (someone hosts Color.red directly), extractRootBody skips body and lets Mirror handle it — never call body on a primitive, even the root. elements(for:in:) keeps the root node as the top-level element instead of flattening it away, so a single-Button screen renders the Button (with its Text child) rather than collapsing to just the Text. Removed the fragile primitiveViewTypeNames allowlist and the per-node extractBody fallback: both replaced by root-only body. Regression test testViewWithOnReceiveDoesNotCrashViewDebugger: a view using onReceive (wraps content in SubscriptionView) must not crash the view debugger. All 55 view-debugger + crash tests pass. Co-authored-by: oh-my-pi <https://omp.sh>
97270ed to
91c611a
Compare
The 3D snapshot now divides SwiftUI siblings into separate planes (root-only body + Mirror recursion), but each plane rendered empty white: SwiftUIElement.snapshotImage was nil because semantic nodes have no UIView to drawHierarchy, and SnapshotView falls back to UIColor.white for nil snapshot images. The separation worked (fanned stack) but the buttons were invisible. Fix: capture the hosting _UIHostingView's pixels ONCE at the UIKit->SwiftUI boundary (ViewElement.children, 3D-snapshot path only), then thread the CGImage down through every SwiftUIElement. Each node crops the screenshot to its assignedFrame (same coordinate space as the hosting view) so its 3D plane shows the real rendered pixels for that region — a button's background color and label — instead of an empty white plane. - SwiftUIElement: added hostingSnapshot param, snapshotImage crops it to assignedFrame. - ViewElement.children: capture snapshotView(view) on the 3D path and pass to SwiftUIElement.elements(for:in:hostingSnapshot:). - Hierarchy-table path (useSwiftUIHierarchy) skips the capture (no planes to render there). All 56 view-debugger + crash tests pass. The 6 OSLog buttons now divide into separated planes AND each plane carries its real cropped content. Co-authored-by: oh-my-pi <https://omp.sh>
|
Closing in favor of a focused PR for the camera/z-position tuning. The SwiftUI hierarchy work on this branch is parked for now. |
Summary
The view debugger now properly handles SwiftUI-based apps with a two-tree approach:
useSwiftUIHierarchy: false) — renders real frames and pixel snapshots instead of broken white planesuseSwiftUIHierarchy: true) — shows declarative views (VStack, Text, Button, etc.) instead of internal infrastructure classes (DisplayList.View, PlatformView)Selection sync
Selections between the two views are synchronized by matching the underlying
UIViewpointer identity via DFS. SwiftUI semantic nodes (which have no underlying UIView) fall back to the root hosting view.New files
SwiftUIElement.swift— bridges SwiftUI hierarchy nodes to theElementprotocolSwiftUIHierarchyBuilder.swift— uses Mirror-based reflection to build the SwiftUI view treeViewDebuggerSwiftUITests.swift— tests for the SwiftUI element hierarchyOther
Makefilefor fast build-and-run on the simulator (make run)Test plan