diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h new file mode 100644 index 0000000000..581f55aad0 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.h @@ -0,0 +1,42 @@ +// +// FLEXAppKitColor.h +// FLEX +// +// A resolved color fact: the unambiguous sRGB hex snapshot, plus — for live +// NSColor inputs — the catalog/dynamic name where available (what a native +// reimplementation actually uses) and the appearance context it was resolved +// under. A live catalog/dynamic NSColor is resolved through an appearance + a +// concrete color space (otherwise reading components throws or yields the wrong +// appearance). +// +// CGColor inputs (e.g. a CALayer's backgroundColor) are already FLATTENED by the +// time the walker sees them: the dynamic/catalog identity was baked away when the +// view set the layer color, so for a CGColor only `hex` is populated (the baked +// sRGB value); `catalogName` and `appearanceName` are nil and `inAppearance:` is +// a no-op. Read view-level colors as NSColor (not via the layer) to recover the +// catalog name and appearance. +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitColor : NSObject + +/// Resolve an NSColor (or a CGColorRef) under `appearance`. Returns nil only if +/// the input is nil. `appearance` may be nil (resolves under the current default). ++ (nullable FLEXAppKitColor *)colorFromColor:(nullable id)nsColorOrCGColor + inAppearance:(nullable id)appearance; + +/// sRGB hex "#RRGGBBAA"; nil if the color could not be resolved to components. +@property (nonatomic, readonly, copy, nullable) NSString *hex; +/// Catalog/dynamic name (e.g. "controlAccentColor") where the color is a catalog +/// color; nil otherwise. +@property (nonatomic, readonly, copy, nullable) NSString *catalogName; +/// The appearance the color was resolved under (e.g. "NSAppearanceNameDarkAqua"). +@property (nonatomic, readonly, copy, nullable) NSString *appearanceName; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m new file mode 100644 index 0000000000..85cde3394f --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitColor.m @@ -0,0 +1,94 @@ +// +// FLEXAppKitColor.m +// FLEX +// + +#import "FLEXAppKitColor.h" + +#if TARGET_OS_OSX + +#import + +@interface FLEXAppKitColor () +@property (nonatomic, copy, nullable) NSString *hex; +@property (nonatomic, copy, nullable) NSString *catalogName; +@property (nonatomic, copy, nullable) NSString *appearanceName; +@end + +/// sRGB hex of a color already converted to sRGB, or nil. The caller must convert +/// first — reading components on a non-RGB color throws. +static NSString *FLEXHexOfSRGBColor(NSColor *srgb) { + if (srgb == nil) { + return nil; + } + int r = (int)lround(srgb.redComponent * 255.0); + int g = (int)lround(srgb.greenComponent * 255.0); + int b = (int)lround(srgb.blueComponent * 255.0); + int a = (int)lround(srgb.alphaComponent * 255.0); + return [NSString stringWithFormat:@"#%02X%02X%02X%02X", r, g, b, a]; +} + +@implementation FLEXAppKitColor + ++ (nullable FLEXAppKitColor *)colorFromColor:(nullable id)input + inAppearance:(nullable id)appearance { + if (input == nil) { + return nil; + } + + // CGColor (e.g. a layer's backgroundColor): already a flat, baked color. It + // cannot carry a catalog name and is NOT re-resolvable to a different + // appearance — the dynamic identity was lost when the view baked it into the + // layer. Capture the baked sRGB hex as-is; catalogName/appearanceName stay nil. + if (![input isKindOfClass:[NSColor class]] + && CFGetTypeID((__bridge CFTypeRef)input) == CGColorGetTypeID()) { + NSColor *flat = [NSColor colorWithCGColor:(__bridge CGColorRef)input]; + NSString *hex = FLEXHexOfSRGBColor([flat colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]); + if (hex == nil) { + return nil; // unconvertible (e.g. a pattern color) — no misleading value + } + FLEXAppKitColor *result = [FLEXAppKitColor new]; + result.hex = hex; + return result; + } + + if (![input isKindOfClass:[NSColor class]]) { + return nil; + } + NSColor *color = input; + FLEXAppKitColor *result = [FLEXAppKitColor new]; + + // Catalog/dynamic NAME, only where the color genuinely is a catalog color + // (reading colorNameComponent on a non-catalog color throws). + if (@available(macOS 10.14, *)) { + if (color.type == NSColorTypeCatalog) { + result.catalogName = color.colorNameComponent; + } + } + + NSAppearance *resolveAppearance = [appearance isKindOfClass:[NSAppearance class]] ? appearance : nil; + result.appearanceName = resolveAppearance.name; + + // Resolve to sRGB UNDER the appearance — required for a live catalog/dynamic + // NSColor, which otherwise returns nil or resolves under the wrong appearance. + __block NSColor *resolved = nil; + void (^toSRGB)(void) = ^{ + resolved = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; + }; + if (resolveAppearance != nil) { + if (@available(macOS 11.0, *)) { + [resolveAppearance performAsCurrentDrawingAppearance:toSRGB]; + } else { + toSRGB(); + } + } else { + toSRGB(); + } + + result.hex = FLEXHexOfSRGBColor(resolved); + return result; +} + +@end + +#endif // TARGET_OS_OSX diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h new file mode 100644 index 0000000000..5eceb4edaa --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.h @@ -0,0 +1,32 @@ +// +// FLEXAppKitFont.h +// FLEX +// +// Decomposed NSFont facts read off a font carrier. Emits the raw CoreText +// weight trait AND the nearest named weight — never a lossy NSFontManager +// (1–14) conversion. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitFont : NSObject + +/// Decompose the font carried by `object` (or its `-cell`), or nil if it carries none. ++ (nullable FLEXAppKitFont *)fontForObject:(id)object; + +@property (nonatomic, readonly, copy) NSString *familyName; +@property (nonatomic, readonly) double pointSize; +/// Raw CoreText NSFontWeightTrait, in [-1.0, 1.0]. 0.0 when the descriptor omits it. +@property (nonatomic, readonly) double weightTrait; +/// Nearest named weight ("regular", "semibold", …) to `weightTrait`. +@property (nonatomic, readonly, copy) NSString *weightName; +/// PostScript name (e.g. ".SFNS-Regular"), or nil if unavailable. +@property (nonatomic, readonly, copy, nullable) NSString *postScriptName; +/// Symbolic traits present on the font ("bold", "italic", "monoSpace", …). +@property (nonatomic, readonly, copy) NSArray *traits; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m new file mode 100644 index 0000000000..d9de2870f5 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitFont.m @@ -0,0 +1,113 @@ +// +// FLEXAppKitFont.m +// FLEX +// + +#import "FLEXAppKitFont.h" + +#if TARGET_OS_OSX + +#import + +@interface FLEXAppKitFont () +@property (nonatomic, copy) NSString *familyName; +@property (nonatomic) double pointSize; +@property (nonatomic) double weightTrait; +@property (nonatomic, copy) NSString *weightName; +@property (nonatomic, copy, nullable) NSString *postScriptName; +@property (nonatomic, copy) NSArray *traits; +@end + +/// The font, read off `object` directly or off its `-cell`, or nil. The carrier set is +/// "any object responding to -font" — not a hardcoded class list. +static NSFont *FLEXFontFromCarrier(id object) { + if (object == nil) { + return nil; + } + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + if ([object respondsToSelector:@selector(font)]) { + id font = [object performSelector:@selector(font)]; + if ([font isKindOfClass:[NSFont class]]) { + return font; + } + } + if ([object respondsToSelector:@selector(cell)]) { + id cell = [object performSelector:@selector(cell)]; + if ([cell respondsToSelector:@selector(font)]) { + id font = [cell performSelector:@selector(font)]; + if ([font isKindOfClass:[NSFont class]]) { + return font; + } + } + } +#pragma clang diagnostic pop + + return nil; +} + +/// Nearest named weight to a raw CoreText trait, using AppKit's own constants so the +/// thresholds track the platform rather than hardcoded folklore numbers. +static NSString *FLEXNearestWeightName(CGFloat weight) { + const struct { CGFloat value; NSString *name; } weights[] = { + { NSFontWeightUltraLight, @"ultraLight" }, + { NSFontWeightThin, @"thin" }, + { NSFontWeightLight, @"light" }, + { NSFontWeightRegular, @"regular" }, + { NSFontWeightMedium, @"medium" }, + { NSFontWeightSemibold, @"semibold" }, + { NSFontWeightBold, @"bold" }, + { NSFontWeightHeavy, @"heavy" }, + { NSFontWeightBlack, @"black" }, + }; + + NSString *nearest = @"regular"; + CGFloat bestDelta = CGFLOAT_MAX; + for (size_t i = 0; i < sizeof(weights) / sizeof(weights[0]); i++) { + CGFloat delta = ABS(weight - weights[i].value); + if (delta < bestDelta) { + bestDelta = delta; + nearest = weights[i].name; + } + } + return nearest; +} + +static NSArray *FLEXSymbolicTraitNames(NSFontDescriptorSymbolicTraits traits) { + NSMutableArray *names = [NSMutableArray array]; + if (traits & NSFontDescriptorTraitBold) { [names addObject:@"bold"]; } + if (traits & NSFontDescriptorTraitItalic) { [names addObject:@"italic"]; } + if (traits & NSFontDescriptorTraitExpanded) { [names addObject:@"expanded"]; } + if (traits & NSFontDescriptorTraitCondensed) { [names addObject:@"condensed"]; } + if (traits & NSFontDescriptorTraitMonoSpace) { [names addObject:@"monoSpace"]; } + if (traits & NSFontDescriptorTraitVertical) { [names addObject:@"vertical"]; } + if (traits & NSFontDescriptorTraitUIOptimized) { [names addObject:@"uiOptimized"]; } + return names; +} + +@implementation FLEXAppKitFont + ++ (nullable FLEXAppKitFont *)fontForObject:(id)object { + NSFont *font = FLEXFontFromCarrier(object); + if (font == nil) { + return nil; + } + + NSDictionary *traitsDict = [font.fontDescriptor objectForKey:NSFontTraitsAttribute]; + NSNumber *weightNumber = traitsDict[NSFontWeightTrait]; + CGFloat weight = weightNumber != nil ? weightNumber.doubleValue : 0.0; + + FLEXAppKitFont *result = [FLEXAppKitFont new]; + result.familyName = font.familyName ?: font.fontName; + result.pointSize = font.pointSize; + result.weightTrait = weight; + result.weightName = FLEXNearestWeightName(weight); + result.postScriptName = font.fontName; + result.traits = FLEXSymbolicTraitNames(font.fontDescriptor.symbolicTraits); + return result; +} + +@end + +#endif // TARGET_OS_OSX diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h new file mode 100644 index 0000000000..84bf9d5f3d --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.h @@ -0,0 +1,28 @@ +// +// FLEXAppKitJSON.h +// FLEX +// +// Projects the walker's snapshot model into JSON-serializable Foundation +// dictionaries (floats at fixed precision, nils as NSNull) so the output is +// deterministic and round-trips through NSJSONSerialization. The final string +// serialization is left to the consumer — this returns Foundation collections. +// + +#import + +@class FLEXAppKitViewSnapshot; +@class FLEXAppKitWindowSnapshot; +@class FLEXConstraintNode; + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitJSON : NSObject + ++ (NSArray *)dictionariesForWindows:(NSArray *)windows; ++ (NSDictionary *)dictionaryForWindow:(FLEXAppKitWindowSnapshot *)window; ++ (NSDictionary *)dictionaryForView:(FLEXAppKitViewSnapshot *)view; ++ (NSDictionary *)dictionaryForConstraintNode:(FLEXConstraintNode *)node; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m new file mode 100644 index 0000000000..52aeeba107 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitJSON.m @@ -0,0 +1,160 @@ +// +// FLEXAppKitJSON.m +// FLEX +// + +#import "FLEXAppKitJSON.h" + +#if TARGET_OS_OSX + +#import "FLEXAppKitViewSnapshot.h" +#import "FLEXAppKitWindowSnapshot.h" +#import "FLEXAppKitFont.h" +#import "FLEXAppKitLayer.h" +#import "FLEXAppKitColor.h" +#import "FLEXConstraintNode.h" + +static id orNull(id _Nullable value) { + return value ?: [NSNull null]; +} + +/// Fixed precision (1 dp) for diffable, deterministic output. +static NSNumber *num1(double value) { + return @(round(value * 10.0) / 10.0); +} + +static NSDictionary *rectDict(CGRect r) { + return @{ @"x": num1(r.origin.x), @"y": num1(r.origin.y), + @"w": num1(r.size.width), @"h": num1(r.size.height) }; +} + +@implementation FLEXAppKitJSON + ++ (id)colorDict:(FLEXAppKitColor *)color { + if (color == nil) { + return [NSNull null]; + } + return @{ @"hex": orNull(color.hex), + @"catalogName": orNull(color.catalogName), + @"appearanceName": orNull(color.appearanceName) }; +} + ++ (id)fontDict:(FLEXAppKitFont *)font { + if (font == nil) { + return [NSNull null]; + } + return @{ @"family": orNull(font.familyName), + @"size": num1(font.pointSize), + @"weightTrait": @(font.weightTrait), + @"weightName": orNull(font.weightName), + @"postScriptName": orNull(font.postScriptName), + @"traits": font.traits ?: @[] }; +} + ++ (id)layerDict:(FLEXAppKitLayer *)layer { + if (layer == nil) { + return [NSNull null]; + } + NSMutableArray *sublayers = [NSMutableArray array]; + for (FLEXAppKitLayer *sub in layer.sublayers) { + [sublayers addObject:[self layerDict:sub]]; + } + return @{ @"class": orNull(layer.className), + @"cornerRadius": num1(layer.cornerRadius), + @"masksToBounds": @(layer.masksToBounds), + @"opacity": num1(layer.opacity), + @"borderWidth": num1(layer.borderWidth), + @"backgroundColor": [self colorDict:layer.backgroundColor], + @"borderColor": [self colorDict:layer.borderColor], + @"shadowOpacity": num1(layer.shadowOpacity), + @"shadowRadius": num1(layer.shadowRadius), + @"shadowOffset": @{ @"w": num1(layer.shadowOffset.width), @"h": num1(layer.shadowOffset.height) }, + @"shadowColor": [self colorDict:layer.shadowColor], + @"sublayerCount": @(layer.sublayerCount), + @"truncated": @(layer.truncated), + @"sublayers": sublayers }; +} + ++ (NSDictionary *)dictionaryForView:(FLEXAppKitViewSnapshot *)view { + NSMutableArray *children = [NSMutableArray array]; + for (FLEXAppKitViewSnapshot *child in view.children) { + [children addObject:[self dictionaryForView:child]]; + } + return @{ @"class": orNull(view.className), + @"superclasses": view.superclasses ?: @[], + @"frame": rectDict(view.frame), + @"frameTopLeft": rectDict(view.frameTopLeft), + @"isFlipped": @(view.isFlipped), + @"hidden": @(view.hidden), + @"alpha": num1(view.alpha), + @"identifier": orNull(view.identifier), + @"text": orNull(view.text), + @"axRole": orNull(view.axRole), + @"font": [self fontDict:view.font], + @"material": orNull(view.material), + @"blendingMode": orNull(view.blendingMode), + @"layer": [self layerDict:view.layer], + @"constraintsCount": @(view.constraintsCount), + @"swiftUIBoundary": @(view.swiftUIBoundary), + @"childCount": @(view.childCount), + @"truncated": @(view.truncated), + @"children": children }; +} + ++ (NSDictionary *)dictionaryForWindow:(FLEXAppKitWindowSnapshot *)window { + NSMutableArray *childWindows = [NSMutableArray array]; + for (FLEXAppKitWindowSnapshot *child in window.childWindows) { + [childWindows addObject:[self dictionaryForWindow:child]]; + } + return @{ @"class": orNull(window.className), + @"title": orNull(window.title), + @"identifier": orNull(window.identifier), + @"isKeyWindow": @(window.isKeyWindow), + @"isMainWindow": @(window.isMainWindow), + @"isVisible": @(window.isVisible), + @"isPanel": @(window.isPanel), + @"frame": rectDict(window.frame), + @"contentView": window.contentView ? [self dictionaryForView:window.contentView] : [NSNull null], + @"childWindows": childWindows }; +} + ++ (NSArray *)dictionariesForWindows:(NSArray *)windows { + NSMutableArray *result = [NSMutableArray array]; + for (FLEXAppKitWindowSnapshot *window in windows) { + [result addObject:[self dictionaryForWindow:window]]; + } + return result; +} + ++ (id)constraintItemDict:(FLEXConstraintItem *)item { + return @{ @"class": orNull(item.className), + @"attribute": orNull(item.attribute), + @"kind": orNull(item.kind), + @"isTarget": @(item.isTarget) }; +} + ++ (NSDictionary *)dictionaryForConstraintNode:(FLEXConstraintNode *)node { + NSMutableArray *constraints = [NSMutableArray array]; + for (FLEXConstraint *c in node.constraints) { + [constraints addObject:@{ @"first": [self constraintItemDict:c.first], + @"relation": orNull(c.relation), + @"second": [self constraintItemDict:c.second], + @"multiplier": num1(c.multiplier), + @"constant": num1(c.constant), + @"priority": num1(c.priority), + @"active": @(c.active), + @"identifier": orNull(c.identifier) }]; + } + return @{ @"translatesAutoresizingMaskIntoConstraints": @(node.translatesAutoresizingMaskIntoConstraints), + @"intrinsicContentSize": @{ @"w": num1(node.intrinsicContentSize.width), + @"h": num1(node.intrinsicContentSize.height) }, + @"hugging": @{ @"horizontal": num1(node.huggingHorizontal), + @"vertical": num1(node.huggingVertical) }, + @"compressionResistance": @{ @"horizontal": num1(node.compressionResistanceHorizontal), + @"vertical": num1(node.compressionResistanceVertical) }, + @"constraints": constraints }; +} + +@end + +#endif // TARGET_OS_OSX diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h new file mode 100644 index 0000000000..f615ff3060 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.h @@ -0,0 +1,43 @@ +// +// FLEXAppKitLayer.h +// FLEX +// +// CALayer facts where a view is layer-backed, plus the recursive sublayer tree. +// This is a structure PARALLEL to the view tree: layer.sublayers != view.subviews, +// and standalone sublayers (backing no view) are included here. CALayer is the +// same class on macOS and iOS, so this is cross-platform. +// + +#import +#import + +@class FLEXAppKitColor; + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitLayer : NSObject + ++ (instancetype)layerFromLayer:(CALayer *)layer inAppearance:(nullable id)appearance; + +@property (nonatomic, readonly, copy) NSString *className; +@property (nonatomic, readonly) double cornerRadius; +@property (nonatomic, readonly) BOOL masksToBounds; +@property (nonatomic, readonly) double opacity; +@property (nonatomic, readonly) double borderWidth; +@property (nonatomic, readonly, nullable) FLEXAppKitColor *backgroundColor; +@property (nonatomic, readonly, nullable) FLEXAppKitColor *borderColor; +@property (nonatomic, readonly) double shadowOpacity; +@property (nonatomic, readonly) double shadowRadius; +@property (nonatomic, readonly) CGSize shadowOffset; +@property (nonatomic, readonly, nullable) FLEXAppKitColor *shadowColor; +/// The parallel sublayer tree, including standalone sublayers backing no view. +@property (nonatomic, readonly, copy) NSArray *sublayers; +/// Number of direct sublayers, always reported even when `sublayers` is truncated. +@property (nonatomic, readonly) NSInteger sublayerCount; +/// True when sublayers were omitted because the depth bound was reached — guards +/// against pathological deep layer trees (CATiledLayer/WebKit/Metal) blowing the stack. +@property (nonatomic, readonly) BOOL truncated; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m new file mode 100644 index 0000000000..9820809533 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitLayer.m @@ -0,0 +1,78 @@ +// +// FLEXAppKitLayer.m +// FLEX +// + +#import "FLEXAppKitLayer.h" + +#if TARGET_OS_OSX + +#import "FLEXAppKitColor.h" +#import + +@interface FLEXAppKitLayer () +@property (nonatomic, copy) NSString *className; +@property (nonatomic) double cornerRadius; +@property (nonatomic) BOOL masksToBounds; +@property (nonatomic) double opacity; +@property (nonatomic) double borderWidth; +@property (nonatomic, nullable) FLEXAppKitColor *backgroundColor; +@property (nonatomic, nullable) FLEXAppKitColor *borderColor; +@property (nonatomic) double shadowOpacity; +@property (nonatomic) double shadowRadius; +@property (nonatomic) CGSize shadowOffset; +@property (nonatomic, nullable) FLEXAppKitColor *shadowColor; +@property (nonatomic, copy) NSArray *sublayers; +@property (nonatomic) NSInteger sublayerCount; +@property (nonatomic) BOOL truncated; +@end + +/// CALayer trees are normally shallow, but pathological backing (CATiledLayer +/// pyramids, WebKit compositing, Metal/AVPlayer stacks) can be deep; cap recursion +/// so a walk can never overflow the stack on a hostile tree. +static const NSInteger kFLEXMaxLayerDepth = 64; + +@implementation FLEXAppKitLayer + ++ (instancetype)layerFromLayer:(CALayer *)layer inAppearance:(nullable id)appearance { + return [self layerFromLayer:layer inAppearance:appearance depth:0]; +} + ++ (instancetype)layerFromLayer:(CALayer *)layer + inAppearance:(nullable id)appearance + depth:(NSInteger)depth { + FLEXAppKitLayer *result = [FLEXAppKitLayer new]; + result.className = NSStringFromClass(object_getClass(layer)); + result.cornerRadius = layer.cornerRadius; + result.masksToBounds = layer.masksToBounds; + result.opacity = layer.opacity; + result.borderWidth = layer.borderWidth; + result.backgroundColor = [FLEXAppKitColor colorFromColor:(__bridge id)layer.backgroundColor + inAppearance:appearance]; + result.borderColor = [FLEXAppKitColor colorFromColor:(__bridge id)layer.borderColor + inAppearance:appearance]; + result.shadowOpacity = layer.shadowOpacity; + result.shadowRadius = layer.shadowRadius; + result.shadowOffset = layer.shadowOffset; + result.shadowColor = [FLEXAppKitColor colorFromColor:(__bridge id)layer.shadowColor + inAppearance:appearance]; + + NSArray *sublayers = layer.sublayers; + result.sublayerCount = (NSInteger)sublayers.count; + if (sublayers.count > 0 && depth >= kFLEXMaxLayerDepth) { + result.truncated = YES; + result.sublayers = @[]; + } else { + NSMutableArray *subs = + [NSMutableArray arrayWithCapacity:sublayers.count]; + for (CALayer *sub in sublayers) { + [subs addObject:[self layerFromLayer:sub inAppearance:appearance depth:depth + 1]]; + } + result.sublayers = subs; + } + return result; +} + +@end + +#endif // TARGET_OS_OSX diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h new file mode 100644 index 0000000000..1c1d834993 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.h @@ -0,0 +1,78 @@ +// +// FLEXAppKitViewSnapshot.h +// FLEX +// +// An immutable per-node record produced by FLEXAppKitWalker — the macOS analog +// of FHSViewSnapshot. Captures only the facts read on the main thread; holds no +// live NSView, so it is safe to serialize off-main. +// + +#import +#import + +@class FLEXAppKitFont; +@class FLEXAppKitLayer; + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitViewSnapshot : NSObject + +/// The real runtime class via object_getClass — the private subclass, not an AX role. +@property (nonatomic, readonly, copy) NSString *className; + +/// Raw NSView frame, in its superview's (bottom-left origin) coordinates. +@property (nonatomic, readonly) CGRect frame; + +/// Normalized top-left rect, relative to the full window frame (titlebar included). +@property (nonatomic, readonly) CGRect frameTopLeft; + +/// The view's own isFlipped — emitted alongside frame so a consumer never infers +/// a top-left origin from `frame` alone. +@property (nonatomic, readonly) BOOL isFlipped; + +@property (nonatomic, readonly) BOOL hidden; +@property (nonatomic, readonly) double alpha; +@property (nonatomic, readonly, copy, nullable) NSString *identifier; + +/// Displayed string where the view is text-bearing (NSControl/NSText); nil otherwise. +/// What the selector grammar's `text` predicate matches against. +@property (nonatomic, readonly, copy, nullable) NSString *text; + +/// The AX role (NSAccessibility), to cross-reference the AX dump. +@property (nonatomic, readonly, copy, nullable) NSString *axRole; + +/// Runtime class hierarchy from the view's class up to NSView (exclusive of the +/// view's own class, which is `className`). An --include field. +@property (nonatomic, readonly, copy) NSArray *superclasses; + +/// Number of NSLayoutConstraints touching this view (both directions). The full +/// list is produced separately; this is the default-projection count. +@property (nonatomic, readonly) NSInteger constraintsCount; + +/// Number of subviews, always reported even when `children` is truncated. +@property (nonatomic, readonly) NSInteger childCount; + +/// True when subviews were omitted because the depth bound was reached. A leaf is +/// never truncated; `childCount` still reports the real subview count. +@property (nonatomic, readonly) BOOL truncated; + +/// True at an NSHostingView (SwiftUI's AppKit host): below it the class names are +/// SwiftUI internals, but the layer-backed scaffold is still real and traversed. +@property (nonatomic, readonly) BOOL swiftUIBoundary; + +/// NSVisualEffectView.material / blendingMode (string names), where applicable. +@property (nonatomic, readonly, copy, nullable) NSString *material; +@property (nonatomic, readonly, copy, nullable) NSString *blendingMode; + +/// Decomposed font where the view (or its cell) carries one; nil otherwise. +@property (nonatomic, readonly, nullable) FLEXAppKitFont *font; + +/// Layer facts where the view is layer-backed (wantsLayer / non-nil layer); nil +/// otherwise. A nil layer is normal, not a failure (NSView is not always backed). +@property (nonatomic, readonly, nullable) FLEXAppKitLayer *layer; + +@property (nonatomic, readonly, copy) NSArray *children; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m new file mode 100644 index 0000000000..8f91c31b76 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot.m @@ -0,0 +1,9 @@ +// +// FLEXAppKitViewSnapshot.m +// FLEX +// + +#import "FLEXAppKitViewSnapshot_Internal.h" + +@implementation FLEXAppKitViewSnapshot +@end diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h new file mode 100644 index 0000000000..7db304dad7 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitViewSnapshot_Internal.h @@ -0,0 +1,35 @@ +// +// FLEXAppKitViewSnapshot_Internal.h +// FLEX +// +// Internal readwrite surface so FLEXAppKitWalker can populate an otherwise +// immutable snapshot during construction. Not part of the public contract. +// + +#import "FLEXAppKitViewSnapshot.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitViewSnapshot () +@property (nonatomic, copy) NSString *className; +@property (nonatomic) CGRect frame; +@property (nonatomic) CGRect frameTopLeft; +@property (nonatomic) BOOL isFlipped; +@property (nonatomic) BOOL hidden; +@property (nonatomic) double alpha; +@property (nonatomic, copy, nullable) NSString *identifier; +@property (nonatomic, copy, nullable) NSString *text; +@property (nonatomic, copy, nullable) NSString *axRole; +@property (nonatomic, copy) NSArray *superclasses; +@property (nonatomic) NSInteger constraintsCount; +@property (nonatomic) NSInteger childCount; +@property (nonatomic) BOOL truncated; +@property (nonatomic) BOOL swiftUIBoundary; +@property (nonatomic, copy, nullable) NSString *material; +@property (nonatomic, copy, nullable) NSString *blendingMode; +@property (nonatomic, nullable) FLEXAppKitFont *font; +@property (nonatomic, nullable) FLEXAppKitLayer *layer; +@property (nonatomic, copy) NSArray *children; +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h new file mode 100644 index 0000000000..4b750c7d15 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.h @@ -0,0 +1,50 @@ +// +// FLEXAppKitWalker.h +// FLEX +// +// The macOS view-tree walker: NSApp → NSWindow → NSView, capturing the per-node +// facts in FLEXAppKitViewSnapshot. The macOS analog of FHSView. +// +// Threading: every method must be called on the main thread — AppKit view state +// is main-thread-only; reading it off-main is undefined behavior. +// + +#import + +@class FLEXAppKitViewSnapshot; +@class FLEXAppKitWindowSnapshot; +@class NSView; +@class NSWindow; + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitWalker : NSObject + +/// Snapshot every NSApp window as a tree root (key/main/panel identified), each +/// with its contentView subtree. The rooted entry point for a full app walk. ++ (NSArray *)snapshotApplicationWindows; + +/// As above, bounded to `maxDepth` levels below each window's contentView. Nodes +/// at the bound report `truncated` + `childCount` with `children` omitted. ++ (NSArray *)snapshotApplicationWindowsWithMaxDepth:(NSInteger)maxDepth; + +/// Recursively snapshot `view` and its subtree (unbounded depth). Frames are +/// normalized against `window`'s full frame (titlebar included); pass the view's +/// window. When `window` is nil, `frameTopLeft` falls back to the raw frame. ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view inWindow:(nullable NSWindow *)window; + +/// As above, bounded to `maxDepth` levels below `view`. A node at the bound with +/// subviews reports `truncated == YES` + `childCount` and omits `children`. ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view + inWindow:(nullable NSWindow *)window + maxDepth:(NSInteger)maxDepth; + +/// The deepest view at `point` (window base coordinates, bottom-left origin), +/// snapshotted as a single node with its children omitted. The macOS substitute +/// for touch hit-testing. Returns nil if nothing is hit. ++ (nullable FLEXAppKitViewSnapshot *)snapshotForHitTestAtPoint:(CGPoint)point + inWindow:(NSWindow *)window; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m new file mode 100644 index 0000000000..f821af486a --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWalker.m @@ -0,0 +1,265 @@ +// +// FLEXAppKitWalker.m +// FLEX +// + +#import "FLEXAppKitWalker.h" + +#if TARGET_OS_OSX + +#import "FLEXAppKitViewSnapshot_Internal.h" +#import "FLEXAppKitWindowSnapshot_Internal.h" +#import "FLEXAppKitFont.h" +#import "FLEXAppKitLayer.h" +#import +#import + +@interface FLEXAppKitWalker () ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view + inWindow:(nullable NSWindow *)window + depth:(NSInteger)depth + maxDepth:(NSInteger)maxDepth; +@end + +/// True if the view's class chain contains an NSHostingView (SwiftUI's host). The +/// generic NSHostingView has a mangled Swift name, so match by substring +/// across the hierarchy rather than isKindOfClass against a single concrete class. +static BOOL FLEXIsSwiftUIBoundary(NSView *view) { + for (Class cls = object_getClass(view); cls != Nil; cls = class_getSuperclass(cls)) { + const char *name = class_getName(cls); + if (name != NULL && strstr(name, "NSHostingView") != NULL) { + return YES; + } + } + return NO; +} + +/// Class hierarchy from the immediate superclass up to (and including) NSObject. +static NSArray *FLEXSuperclassNames(NSView *view) { + NSMutableArray *names = [NSMutableArray array]; + Class cls = class_getSuperclass(object_getClass(view)); + while (cls != Nil) { + [names addObject:NSStringFromClass(cls)]; + if (cls == [NSObject class]) { + break; + } + cls = class_getSuperclass(cls); + } + return names; +} + +/// Displayed text for the text-bearing view bases; nil otherwise. +static NSString *FLEXTextForView(NSView *view) { + NSString *text = nil; + if ([view isKindOfClass:[NSControl class]]) { + text = [(NSControl *)view stringValue]; + } else if ([view isKindOfClass:[NSText class]]) { + text = [(NSText *)view string]; + } + return text.length > 0 ? text : nil; +} + +static NSString *FLEXMaterialName(NSVisualEffectMaterial material) { + switch (material) { + case NSVisualEffectMaterialTitlebar: return @"titlebar"; + case NSVisualEffectMaterialSelection: return @"selection"; + case NSVisualEffectMaterialMenu: return @"menu"; + case NSVisualEffectMaterialPopover: return @"popover"; + case NSVisualEffectMaterialSidebar: return @"sidebar"; + case NSVisualEffectMaterialHeaderView: return @"headerView"; + case NSVisualEffectMaterialSheet: return @"sheet"; + case NSVisualEffectMaterialWindowBackground: return @"windowBackground"; + case NSVisualEffectMaterialHUDWindow: return @"hudWindow"; + case NSVisualEffectMaterialFullScreenUI: return @"fullScreenUI"; + case NSVisualEffectMaterialToolTip: return @"toolTip"; + case NSVisualEffectMaterialContentBackground: return @"contentBackground"; + case NSVisualEffectMaterialUnderWindowBackground: return @"underWindowBackground"; + case NSVisualEffectMaterialUnderPageBackground: return @"underPageBackground"; + default: return [NSString stringWithFormat:@"material(%ld)", (long)material]; + } +} + +static NSString *FLEXBlendingModeName(NSVisualEffectBlendingMode mode) { + switch (mode) { + case NSVisualEffectBlendingModeBehindWindow: return @"behindWindow"; + case NSVisualEffectBlendingModeWithinWindow: return @"withinWindow"; + default: return [NSString stringWithFormat:@"blendingMode(%ld)", (long)mode]; + } +} + +@implementation FLEXAppKitWalker + ++ (NSArray *)snapshotApplicationWindows { + return [self snapshotApplicationWindowsWithMaxDepth:NSIntegerMax]; +} + ++ (NSArray *)snapshotApplicationWindowsWithMaxDepth:(NSInteger)maxDepth { + NSApplication *app = NSApplication.sharedApplication; + NSWindow *keyWindow = app.keyWindow; + NSWindow *mainWindow = app.mainWindow; + + // Only top-level windows are roots: a window attached as a sheet or held as a + // child window is nested under its parent, not emitted as a separate root. + NSMutableSet *visited = [NSMutableSet set]; + NSMutableArray *result = [NSMutableArray array]; + for (NSWindow *window in app.windows) { + if (window.parentWindow != nil || window.sheetParent != nil) { + continue; + } + FLEXAppKitWindowSnapshot *snapshot = [self windowSnapshotFor:window + key:keyWindow + main:mainWindow + maxDepth:maxDepth + visited:visited]; + if (snapshot != nil) { + [result addObject:snapshot]; + } + } + return result; +} + ++ (nullable FLEXAppKitWindowSnapshot *)windowSnapshotFor:(NSWindow *)window + key:(nullable NSWindow *)keyWindow + main:(nullable NSWindow *)mainWindow + maxDepth:(NSInteger)maxDepth + visited:(NSMutableSet *)visited { + NSValue *box = [NSValue valueWithNonretainedObject:window]; + if ([visited containsObject:box]) { + return nil; // guard against a window appearing in two relationships + } + [visited addObject:box]; + + FLEXAppKitWindowSnapshot *snapshot = [FLEXAppKitWindowSnapshot new]; + snapshot.className = NSStringFromClass(object_getClass(window)); + snapshot.title = window.title; + snapshot.identifier = window.identifier; + snapshot.isKeyWindow = (window == keyWindow); + snapshot.isMainWindow = (window == mainWindow); + snapshot.isVisible = window.isVisible; + snapshot.isPanel = [window isKindOfClass:[NSPanel class]]; + snapshot.frame = window.frame; + NSView *content = window.contentView; + snapshot.contentView = content ? [self snapshotForView:content + inWindow:window + depth:0 + maxDepth:maxDepth] + : nil; + + NSMutableArray *children = [NSMutableArray array]; + for (NSWindow *child in window.childWindows) { + FLEXAppKitWindowSnapshot *childSnapshot = [self windowSnapshotFor:child + key:keyWindow + main:mainWindow + maxDepth:maxDepth + visited:visited]; + if (childSnapshot != nil) { + [children addObject:childSnapshot]; + } + } + NSWindow *sheet = window.attachedSheet; + if (sheet != nil) { + FLEXAppKitWindowSnapshot *sheetSnapshot = [self windowSnapshotFor:sheet + key:keyWindow + main:mainWindow + maxDepth:maxDepth + visited:visited]; + if (sheetSnapshot != nil) { + [children addObject:sheetSnapshot]; + } + } + snapshot.childWindows = children; + return snapshot; +} + ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view inWindow:(nullable NSWindow *)window { + return [self snapshotForView:view inWindow:window depth:0 maxDepth:NSIntegerMax]; +} + ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view + inWindow:(nullable NSWindow *)window + maxDepth:(NSInteger)maxDepth { + return [self snapshotForView:view inWindow:window depth:0 maxDepth:maxDepth]; +} + ++ (FLEXAppKitViewSnapshot *)snapshotForView:(NSView *)view + inWindow:(nullable NSWindow *)window + depth:(NSInteger)depth + maxDepth:(NSInteger)maxDepth { + FLEXAppKitViewSnapshot *snapshot = [FLEXAppKitViewSnapshot new]; + snapshot.className = NSStringFromClass(object_getClass(view)); + snapshot.frame = view.frame; + snapshot.frameTopLeft = [self topLeftFrameForView:view inWindow:window]; + snapshot.isFlipped = view.isFlipped; + snapshot.hidden = view.isHidden; + snapshot.alpha = view.alphaValue; + snapshot.identifier = view.identifier; + snapshot.text = FLEXTextForView(view); + snapshot.axRole = view.accessibilityRole; + snapshot.superclasses = FLEXSuperclassNames(view); + snapshot.constraintsCount = (NSInteger)view.constraints.count; + snapshot.swiftUIBoundary = FLEXIsSwiftUIBoundary(view); + snapshot.font = [FLEXAppKitFont fontForObject:view]; + + if ([view isKindOfClass:[NSVisualEffectView class]]) { + NSVisualEffectView *effect = (NSVisualEffectView *)view; + snapshot.material = FLEXMaterialName(effect.material); + snapshot.blendingMode = FLEXBlendingModeName(effect.blendingMode); + } + + // Layer facts only where the view is layer-backed — a nil layer is normal. + if (view.layer != nil) { + snapshot.layer = [FLEXAppKitLayer layerFromLayer:view.layer + inAppearance:view.effectiveAppearance]; + } + + NSArray *subviews = view.subviews; + snapshot.childCount = (NSInteger)subviews.count; + if (subviews.count > 0 && depth >= maxDepth) { + // Depth bound reached: omit children but record how many were pruned. + snapshot.truncated = YES; + snapshot.children = @[]; + } else { + NSMutableArray *children = + [NSMutableArray arrayWithCapacity:subviews.count]; + for (NSView *subview in subviews) { + [children addObject:[self snapshotForView:subview + inWindow:window + depth:depth + 1 + maxDepth:maxDepth]]; + } + snapshot.children = children; + } + return snapshot; +} + ++ (nullable FLEXAppKitViewSnapshot *)snapshotForHitTestAtPoint:(CGPoint)point + inWindow:(NSWindow *)window { + // hitTest: wants the point in the receiver's superview coordinates; the window's + // root view (the border/theme view) has the window base coordinate system, so a + // window-base point is correct for it. + NSView *root = window.contentView.superview ?: window.contentView; + NSView *hit = [root hitTest:point]; + if (hit == nil) { + return nil; + } + return [self snapshotForView:hit inWindow:window maxDepth:0]; +} + +/// Normalized top-left rect, full-window-frame-relative (titlebar included). +/// Computed through screen coordinates so per-view isFlipped is resolved by +/// AppKit's own conversion rather than manual y-flipping. ++ (CGRect)topLeftFrameForView:(NSView *)view inWindow:(nullable NSWindow *)window { + if (window == nil) { + return view.frame; + } + NSRect inWindow = [view convertRect:view.bounds toView:nil]; + NSRect inScreen = [window convertRectToScreen:inWindow]; + NSRect windowFrame = window.frame; + CGFloat x = NSMinX(inScreen) - NSMinX(windowFrame); + CGFloat yFromTop = NSMaxY(windowFrame) - NSMaxY(inScreen); + return CGRectMake(x, yFromTop, NSWidth(inScreen), NSHeight(inScreen)); +} + +@end + +#endif // TARGET_OS_OSX diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h new file mode 100644 index 0000000000..b427d08684 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.h @@ -0,0 +1,37 @@ +// +// FLEXAppKitWindowSnapshot.h +// FLEX +// +// A top-level NSWindow root produced by FLEXAppKitWalker. Each on-screen window +// is a tree root; its contentView subtree hangs below. +// + +#import +#import + +@class FLEXAppKitViewSnapshot; + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitWindowSnapshot : NSObject + +/// The real runtime NSWindow subclass via object_getClass. +@property (nonatomic, readonly, copy) NSString *className; +@property (nonatomic, readonly, copy, nullable) NSString *title; +@property (nonatomic, readonly, copy, nullable) NSString *identifier; +@property (nonatomic, readonly) BOOL isKeyWindow; +@property (nonatomic, readonly) BOOL isMainWindow; +@property (nonatomic, readonly) BOOL isVisible; +@property (nonatomic, readonly) BOOL isPanel; +/// Window frame in screen coordinates (bottom-left origin). +@property (nonatomic, readonly) CGRect frame; +/// Snapshot of the window's contentView subtree; nil if there is no contentView. +@property (nonatomic, readonly, nullable) FLEXAppKitViewSnapshot *contentView; + +/// Transient/child windows nested under this one — attached sheets, child windows +/// (NSPopover content, panels). Only top-level windows are roots; these are not. +@property (nonatomic, readonly, copy) NSArray *childWindows; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.m b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.m new file mode 100644 index 0000000000..c10e5085ef --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot.m @@ -0,0 +1,9 @@ +// +// FLEXAppKitWindowSnapshot.m +// FLEX +// + +#import "FLEXAppKitWindowSnapshot_Internal.h" + +@implementation FLEXAppKitWindowSnapshot +@end diff --git a/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h new file mode 100644 index 0000000000..808820ba34 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXAppKitWindowSnapshot_Internal.h @@ -0,0 +1,25 @@ +// +// FLEXAppKitWindowSnapshot_Internal.h +// FLEX +// +// Internal readwrite surface for FLEXAppKitWalker. Not part of the public contract. +// + +#import "FLEXAppKitWindowSnapshot.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface FLEXAppKitWindowSnapshot () +@property (nonatomic, copy) NSString *className; +@property (nonatomic, copy, nullable) NSString *title; +@property (nonatomic, copy, nullable) NSString *identifier; +@property (nonatomic) BOOL isKeyWindow; +@property (nonatomic) BOOL isMainWindow; +@property (nonatomic) BOOL isVisible; +@property (nonatomic) BOOL isPanel; +@property (nonatomic) CGRect frame; +@property (nonatomic, nullable) FLEXAppKitViewSnapshot *contentView; +@property (nonatomic, copy) NSArray *childWindows; +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.h b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.h new file mode 100644 index 0000000000..5181c5be1b --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.h @@ -0,0 +1,63 @@ +// +// FLEXConstraintNode.h +// FLEX +// +// Auto Layout extraction for one view: every NSLayoutConstraint touching it in +// BOTH directions (where it is the first item and the second item), serialized as +// first.attr (relation) second.attr * multiplier + constant @ priority, plus the +// node's intrinsic-sizing facts. NSLayoutConstraint is the same class on macOS and +// iOS, so this is cross-platform. +// +// Node-id stringification of each item is the server's concern; this captures the +// AppKit facts + each item's class and role (target / view / layoutGuide / none). +// + +#import +#import + +@class NSView; + +NS_ASSUME_NONNULL_BEGIN + +/// One side of a constraint. +@interface FLEXConstraintItem : NSObject +/// Runtime class of the item; nil for the absent second item of a constant constraint. +@property (nonatomic, readonly, copy, nullable) NSString *className; +/// "leading" / "width" / "notAnAttribute" ... +@property (nonatomic, readonly, copy) NSString *attribute; +/// "view" | "layoutGuide" | "other" | "none" +@property (nonatomic, readonly, copy) NSString *kind; +/// True when this item is the view the FLEXConstraintNode describes. +@property (nonatomic, readonly) BOOL isTarget; +@end + +@interface FLEXConstraint : NSObject +@property (nonatomic, readonly) FLEXConstraintItem *first; +@property (nonatomic, readonly, copy) NSString *relation; // "lessThanOrEqual"/"equal"/"greaterThanOrEqual" +@property (nonatomic, readonly) FLEXConstraintItem *second; +@property (nonatomic, readonly) double multiplier; +@property (nonatomic, readonly) double constant; +@property (nonatomic, readonly) double priority; +@property (nonatomic, readonly) BOOL active; +@property (nonatomic, readonly, copy, nullable) NSString *identifier; +@end + +@interface FLEXConstraintNode : NSObject + +/// Extract the constraints touching `view` in both directions, plus its +/// intrinsic-sizing facts. Must be called on the main thread. ++ (instancetype)constraintsForView:(NSView *)view; + +@property (nonatomic, readonly) BOOL translatesAutoresizingMaskIntoConstraints; +/// Raw intrinsicContentSize; an axis with no intrinsic metric is NSViewNoIntrinsicMetric (-1). +@property (nonatomic, readonly) CGSize intrinsicContentSize; +@property (nonatomic, readonly) double huggingHorizontal; +@property (nonatomic, readonly) double huggingVertical; +@property (nonatomic, readonly) double compressionResistanceHorizontal; +@property (nonatomic, readonly) double compressionResistanceVertical; +/// The constraints touching the view, both directions, deduplicated. +@property (nonatomic, readonly, copy) NSArray *constraints; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m new file mode 100644 index 0000000000..ee102e5918 --- /dev/null +++ b/Classes/ViewHierarchy/AppKit/FLEXConstraintNode.m @@ -0,0 +1,174 @@ +// +// FLEXConstraintNode.m +// FLEX +// + +#import "FLEXConstraintNode.h" + +#if TARGET_OS_OSX + +#import +#import + +static NSString *FLEXAttrName(NSLayoutAttribute attr) { + switch (attr) { + case NSLayoutAttributeLeft: return @"left"; + case NSLayoutAttributeRight: return @"right"; + case NSLayoutAttributeTop: return @"top"; + case NSLayoutAttributeBottom: return @"bottom"; + case NSLayoutAttributeLeading: return @"leading"; + case NSLayoutAttributeTrailing: return @"trailing"; + case NSLayoutAttributeWidth: return @"width"; + case NSLayoutAttributeHeight: return @"height"; + case NSLayoutAttributeCenterX: return @"centerX"; + case NSLayoutAttributeCenterY: return @"centerY"; + case NSLayoutAttributeLastBaseline: return @"lastBaseline"; + case NSLayoutAttributeFirstBaseline: return @"firstBaseline"; + case NSLayoutAttributeNotAnAttribute: return @"notAnAttribute"; + default: return [NSString stringWithFormat:@"attr(%ld)", (long)attr]; + } +} + +static NSString *FLEXRelationName(NSLayoutRelation relation) { + switch (relation) { + case NSLayoutRelationLessThanOrEqual: return @"lessThanOrEqual"; + case NSLayoutRelationEqual: return @"equal"; + case NSLayoutRelationGreaterThanOrEqual: return @"greaterThanOrEqual"; + default: return [NSString stringWithFormat:@"relation(%ld)", (long)relation]; + } +} + +#pragma mark - + +@interface FLEXConstraintItem () +@property (nonatomic, copy, nullable) NSString *className; +@property (nonatomic, copy) NSString *attribute; +@property (nonatomic, copy) NSString *kind; +@property (nonatomic) BOOL isTarget; +@end + +@implementation FLEXConstraintItem + ++ (FLEXConstraintItem *)itemFor:(nullable id)item + attribute:(NSLayoutAttribute)attribute + target:(NSView *)target { + FLEXConstraintItem *result = [FLEXConstraintItem new]; + result.attribute = FLEXAttrName(attribute); + if (item == nil) { + result.kind = @"none"; + return result; + } + result.className = NSStringFromClass(object_getClass(item)); + result.isTarget = (item == target); + if ([item isKindOfClass:[NSView class]]) { + result.kind = @"view"; + } else if ([item isKindOfClass:[NSLayoutGuide class]]) { + result.kind = @"layoutGuide"; + } else { + result.kind = @"other"; + } + return result; +} + +@end + +#pragma mark - + +@interface FLEXConstraint () +@property (nonatomic) FLEXConstraintItem *first; +@property (nonatomic, copy) NSString *relation; +@property (nonatomic) FLEXConstraintItem *second; +@property (nonatomic) double multiplier; +@property (nonatomic) double constant; +@property (nonatomic) double priority; +@property (nonatomic) BOOL active; +@property (nonatomic, copy, nullable) NSString *identifier; +@end + +@implementation FLEXConstraint + ++ (FLEXConstraint *)constraintFrom:(NSLayoutConstraint *)constraint target:(NSView *)target { + FLEXConstraint *result = [FLEXConstraint new]; + result.first = [FLEXConstraintItem itemFor:constraint.firstItem + attribute:constraint.firstAttribute + target:target]; + result.relation = FLEXRelationName(constraint.relation); + result.second = [FLEXConstraintItem itemFor:constraint.secondItem + attribute:constraint.secondAttribute + target:target]; + result.multiplier = constraint.multiplier; + result.constant = constraint.constant; + result.priority = constraint.priority; + result.active = constraint.isActive; + result.identifier = constraint.identifier; + return result; +} + +@end + +#pragma mark - + +@interface FLEXConstraintNode () +@property (nonatomic) BOOL translatesAutoresizingMaskIntoConstraints; +@property (nonatomic) CGSize intrinsicContentSize; +@property (nonatomic) double huggingHorizontal; +@property (nonatomic) double huggingVertical; +@property (nonatomic) double compressionResistanceHorizontal; +@property (nonatomic) double compressionResistanceVertical; +@property (nonatomic, copy) NSArray *constraints; +@end + +@implementation FLEXConstraintNode + ++ (instancetype)constraintsForView:(NSView *)view { + FLEXConstraintNode *node = [FLEXConstraintNode new]; + node.translatesAutoresizingMaskIntoConstraints = view.translatesAutoresizingMaskIntoConstraints; + node.intrinsicContentSize = view.intrinsicContentSize; + node.huggingHorizontal = + [view contentHuggingPriorityForOrientation:NSLayoutConstraintOrientationHorizontal]; + node.huggingVertical = + [view contentHuggingPriorityForOrientation:NSLayoutConstraintOrientationVertical]; + node.compressionResistanceHorizontal = + [view contentCompressionResistancePriorityForOrientation:NSLayoutConstraintOrientationHorizontal]; + node.compressionResistanceVertical = + [view contentCompressionResistancePriorityForOrientation:NSLayoutConstraintOrientationVertical]; + + NSMutableArray *out = [NSMutableArray array]; + NSMutableSet *seen = [NSMutableSet set]; + + void (^collect)(NSArray *, BOOL) = + ^(NSArray *constraints, BOOL requireTouch) { + for (NSLayoutConstraint *constraint in constraints) { + if (![constraint isKindOfClass:[NSLayoutConstraint class]]) { + continue; + } + if (requireTouch + && constraint.firstItem != view + && constraint.secondItem != view) { + continue; + } + NSValue *box = [NSValue valueWithNonretainedObject:constraint]; + if ([seen containsObject:box]) { + continue; + } + [seen addObject:box]; + [out addObject:[FLEXConstraint constraintFrom:constraint target:view]]; + } + }; + + // Constraints that TOUCH this view (first or second item), in both directions: + // its own constraints that reference it, plus any ancestor-held constraint that + // references it (AppKit has no public reverse index). A view also holds + // constraints purely between its descendants — those don't touch it and are + // excluded (they do not touch the view). + collect(view.constraints, YES); + for (NSView *ancestor = view.superview; ancestor != nil; ancestor = ancestor.superview) { + collect(ancestor.constraints, YES); + } + node.constraints = out; + return node; +} + +@end + +#endif // TARGET_OS_OSX diff --git a/DevProbe/main.m b/DevProbe/main.m new file mode 100644 index 0000000000..a9521d7c8d --- /dev/null +++ b/DevProbe/main.m @@ -0,0 +1,253 @@ +// +// main.m — FLEXAppKitProbe +// +// A scoped correctness harness for FLEXAppKitWalker. Builds only against FLEXAppKit +// (not the UIKit FLEX target), so it runs on macOS via `swift run FLEXAppKitProbe`. +// Asserts the walker's output against geometry/font/color/layer facts computed +// independently. This is dev tooling, not part of the upstream library. +// + +#import +#import +#import +#import +@import FLEXAppKit; + +@interface FLEXProbeView : NSView +@end +@implementation FLEXProbeView +@end + +@interface FLEXFlippedView : NSView +@end +@implementation FLEXFlippedView +- (BOOL)isFlipped { return YES; } +@end + +static int gFailures = 0; + +static void check(BOOL cond, NSString *msg) { + printf(" %s: %s\n", cond ? "ok" : "FAIL", msg.UTF8String); + if (!cond) { gFailures++; } +} + +static BOOL approx(CGFloat a, CGFloat b) { return fabs(a - b) <= 0.5; } + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + + // 1. Real runtime class via object_getClass + FLEXProbeView *custom = [[FLEXProbeView alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + FLEXAppKitViewSnapshot *cs = [FLEXAppKitWalker snapshotForView:custom inWindow:nil]; + check([cs.className isEqualToString:@"FLEXProbeView"], + [NSString stringWithFormat:@"real class == FLEXProbeView (got %@)", cs.className]); + + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 300) + styleMask:NSWindowStyleMaskTitled + backing:NSBackingStoreBuffered + defer:NO]; + window.title = @"ProbeWindow"; + NSView *content = window.contentView; + CGFloat winH = window.frame.size.height; + CGFloat titlebar = winH - 300; + + // 2a. Non-flipped subview: raw frame + normalized top-left + isFlipped + NSView *plain = [[NSView alloc] initWithFrame:NSMakeRect(50, 40, 100, 20)]; + [content addSubview:plain]; + FLEXAppKitViewSnapshot *ps = [FLEXAppKitWalker snapshotForView:plain inWindow:window]; + check(approx(ps.frame.origin.x, 50) && approx(ps.frame.origin.y, 40), + @"raw frame preserved in AppKit (bottom-left) coords"); + check(ps.isFlipped == NO, @"isFlipped == NO for a default view"); + check(approx(ps.frameTopLeft.origin.x, 50), + [NSString stringWithFormat:@"normalized x == 50 (got %.1f)", ps.frameTopLeft.origin.x]); + check(approx(ps.frameTopLeft.origin.y, winH - 60), + [NSString stringWithFormat:@"normalized yTop == %.1f (got %.1f)", winH - 60, ps.frameTopLeft.origin.y]); + + // 2b. Flipped container: isFlipped reported, normalized geometry still correct + FLEXFlippedView *flipped = [[FLEXFlippedView alloc] initWithFrame:NSMakeRect(0, 0, 400, 300)]; + [content addSubview:flipped]; + NSView *inFlipped = [[NSView alloc] initWithFrame:NSMakeRect(50, 40, 100, 20)]; + [flipped addSubview:inFlipped]; + FLEXAppKitViewSnapshot *fs = [FLEXAppKitWalker snapshotForView:flipped inWindow:window]; + check(fs.isFlipped == YES, @"isFlipped == YES for a flipped container"); + FLEXAppKitViewSnapshot *ifs = fs.children.firstObject; + check(ifs != nil && approx(ifs.frameTopLeft.origin.y, titlebar + 40), + [NSString stringWithFormat:@"flipped child yTop == %.1f (got %.1f)", + titlebar + 40, ifs ? ifs.frameTopLeft.origin.y : -1]); + + // 3. Font decomposition: raw weight trait AND nearest name, no lossy conversion + NSTextField *label = [NSTextField labelWithString:@"Hi"]; + label.font = [NSFont systemFontOfSize:13 weight:NSFontWeightSemibold]; + [content addSubview:label]; + FLEXAppKitViewSnapshot *ls = [FLEXAppKitWalker snapshotForView:label inWindow:window]; + check(ls.font != nil, @"label reports a font"); + check(ls.font && approx(ls.font.pointSize, 13), @"font pointSize == 13"); + check(ls.font && [ls.font.weightName isEqualToString:@"semibold"], + [NSString stringWithFormat:@"weightName == semibold (got %@)", ls.font.weightName]); + check(ls.font && approx(ls.font.weightTrait, NSFontWeightSemibold), + @"weightTrait ~ NSFontWeightSemibold (raw, not converted)"); + check(ls.font.postScriptName.length > 0, @"postScriptName present"); + check(ps.font == nil, @"plain NSView reports no font (null)"); + + // 4. Rooted traversal: NSApp.windows enumeration + NSArray *windows = [FLEXAppKitWalker snapshotApplicationWindows]; + FLEXAppKitWindowSnapshot *probeWindow = nil; + for (FLEXAppKitWindowSnapshot *w in windows) { + if ([w.title isEqualToString:@"ProbeWindow"]) { probeWindow = w; break; } + } + check(probeWindow != nil, @"NSApp.windows enumeration finds ProbeWindow as a root"); + check(probeWindow.className.length > 0 && [probeWindow.className containsString:@"Window"], + [NSString stringWithFormat:@"window real class looks like an NSWindow (got %@)", probeWindow.className]); + check(probeWindow.contentView != nil && probeWindow.contentView.children.count >= 1, + @"window root carries its contentView subtree"); + + // 5. swiftUIBoundary: a class whose name contains NSHostingView trips the flag + Class hostingStub = objc_allocateClassPair([NSView class], "NSHostingViewStub", 0); + objc_registerClassPair(hostingStub); + NSView *fakeHost = [[hostingStub alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + FLEXAppKitViewSnapshot *hs = [FLEXAppKitWalker snapshotForView:fakeHost inWindow:nil]; + check(hs.swiftUIBoundary == YES, @"swiftUIBoundary == YES at an NSHostingView-named class"); + check(ps.swiftUIBoundary == NO, @"swiftUIBoundary == NO for a plain view"); + + // 6. Layer sub-shape + NSColor decomposition (standalone, layer-backed) + NSView *backed = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 50, 50)]; + backed.wantsLayer = YES; + backed.layer.cornerRadius = 8; + backed.layer.masksToBounds = YES; + backed.layer.backgroundColor = [NSColor colorWithSRGBRed:1 green:0 blue:0 alpha:1].CGColor; + FLEXAppKitViewSnapshot *bs = [FLEXAppKitWalker snapshotForView:backed inWindow:nil]; + check(bs.layer != nil, @"layer-backed view captures a layer"); + check(bs.layer && approx(bs.layer.cornerRadius, 8), @"layer cornerRadius == 8"); + check(bs.layer && bs.layer.masksToBounds == YES, @"layer masksToBounds == YES"); + check(bs.layer.backgroundColor != nil, @"layer has a decomposed backgroundColor"); + check(bs.layer.backgroundColor && [bs.layer.backgroundColor.hex isEqualToString:@"#FF0000FF"], + [NSString stringWithFormat:@"bg color hex == #FF0000FF (got %@)", bs.layer.backgroundColor.hex]); + + // 6b. nil-layer: a standalone, non-wantsLayer view reports no layer (success) + NSView *unbacked = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + FLEXAppKitViewSnapshot *us = [FLEXAppKitWalker snapshotForView:unbacked inWindow:nil]; + check(us.layer == nil, @"unbacked standalone view reports no layer (nil, not error)"); + + // 7. NSVisualEffectView material / blendingMode + NSVisualEffectView *vev = [[NSVisualEffectView alloc] initWithFrame:NSMakeRect(0, 0, 50, 50)]; + vev.material = NSVisualEffectMaterialSidebar; + FLEXAppKitViewSnapshot *vs = [FLEXAppKitWalker snapshotForView:vev inWindow:nil]; + check([vs.material isEqualToString:@"sidebar"], + [NSString stringWithFormat:@"material == sidebar (got %@)", vs.material]); + check(vs.blendingMode.length > 0, + [NSString stringWithFormat:@"blendingMode present (got %@)", vs.blendingMode]); + + // 8. Node-schema completeness: superclasses / text / axRole / constraintsCount + check([cs.superclasses containsObject:@"NSView"] && [cs.superclasses.lastObject isEqualToString:@"NSObject"], + [NSString stringWithFormat:@"superclasses run up to NSObject (got %@)", cs.superclasses]); + check(ls.text != nil && [ls.text isEqualToString:@"Hi"], + [NSString stringWithFormat:@"text == 'Hi' for the label (got %@)", ls.text]); + check(ps.text == nil, @"plain NSView reports no text (null)"); + check(ls.axRole.length > 0, + [NSString stringWithFormat:@"label carries an axRole (got %@)", ls.axRole]); + + NSView *constrained = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 40, 40)]; + [content addSubview:constrained]; + [constrained addConstraint:[NSLayoutConstraint constraintWithItem:constrained + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:nil + attribute:NSLayoutAttributeNotAnAttribute + multiplier:1 + constant:120]]; + FLEXAppKitViewSnapshot *cns = [FLEXAppKitWalker snapshotForView:constrained inWindow:window]; + check(cns.constraintsCount == 1, + [NSString stringWithFormat:@"constraintsCount == 1 (got %ld)", (long)cns.constraintsCount]); + + // 9. Depth bound: truncated + childCount, children omitted past the bound + NSView *a = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 30, 30)]; + NSView *b = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 20, 20)]; + NSView *cc = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + [a addSubview:b]; + [b addSubview:cc]; + FLEXAppKitViewSnapshot *as = [FLEXAppKitWalker snapshotForView:a inWindow:nil maxDepth:1]; + check(as.truncated == NO && as.childCount == 1 && as.children.count == 1, + @"depth root: not truncated, childCount 1, one child present"); + FLEXAppKitViewSnapshot *deepB = as.children.firstObject; + check(deepB != nil && deepB.truncated == YES && deepB.childCount == 1 && deepB.children.count == 0, + @"depth bound: node truncated, childCount 1, children omitted"); + + // 10. Constraints extraction (FLEXConstraintNode) + FLEXConstraintNode *cn = [FLEXConstraintNode constraintsForView:constrained]; + FLEXConstraint *wc = cn.constraints.firstObject; + check(wc != nil && [wc.first.attribute isEqualToString:@"width"] + && [wc.relation isEqualToString:@"equal"] && approx(wc.constant, 120) + && [wc.second.kind isEqualToString:@"none"] && wc.first.isTarget, + @"width constraint serialized (width == 120, second none, first is target)"); + + NSView *cont = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 200, 50)]; + NSView *cA = [NSView new]; + NSView *cB = [NSView new]; + cA.translatesAutoresizingMaskIntoConstraints = NO; + cB.translatesAutoresizingMaskIntoConstraints = NO; + [cont addSubview:cA]; + [cont addSubview:cB]; + [cont addConstraint:[NSLayoutConstraint constraintWithItem:cA + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:cB + attribute:NSLayoutAttributeLeading + multiplier:1 + constant:8]]; + FLEXConstraintNode *cnA = [FLEXConstraintNode constraintsForView:cA]; + FLEXConstraint *bc = cnA.constraints.firstObject; + check(cnA.constraints.count == 1 && bc != nil && [bc.first.attribute isEqualToString:@"trailing"] + && bc.first.isTarget && [bc.second.kind isEqualToString:@"view"] && approx(bc.constant, 8), + @"sibling constraint found for the first item (ancestor-held, both directions)"); + FLEXConstraintNode *cnB = [FLEXConstraintNode constraintsForView:cB]; + check(cnB.constraints.count == 1, @"same constraint found for the SECOND item (reverse direction)"); + + // 11. Window nesting: a child window nests under its parent, not as a root + NSWindow *childWin = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 100, 100) + styleMask:NSWindowStyleMaskTitled + backing:NSBackingStoreBuffered + defer:NO]; + childWin.title = @"ChildProbeWindow"; + [window addChildWindow:childWin ordered:NSWindowAbove]; + NSArray *ws2 = [FLEXAppKitWalker snapshotApplicationWindows]; + BOOL childIsRoot = NO; + FLEXAppKitWindowSnapshot *parentRoot = nil; + for (FLEXAppKitWindowSnapshot *w in ws2) { + if ([w.title isEqualToString:@"ChildProbeWindow"]) { childIsRoot = YES; } + if ([w.title isEqualToString:@"ProbeWindow"]) { parentRoot = w; } + } + check(!childIsRoot, @"child window is NOT a top-level root"); + BOOL childNested = NO; + for (FLEXAppKitWindowSnapshot *c in parentRoot.childWindows) { + if ([c.title isEqualToString:@"ChildProbeWindow"]) { childNested = YES; } + } + check(parentRoot != nil && childNested, @"child window nested under its parent window"); + + // 12. Parallel CALayer sublayer tree + count + NSView *layerHost = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 30, 30)]; + layerHost.wantsLayer = YES; + [layerHost.layer addSublayer:[CALayer layer]]; + [layerHost.layer addSublayer:[CALayer layer]]; + FLEXAppKitViewSnapshot *lhs = [FLEXAppKitWalker snapshotForView:layerHost inWindow:nil]; + check(lhs.layer != nil && lhs.layer.sublayerCount == 2 && lhs.layer.sublayers.count == 2 && !lhs.layer.truncated, + [NSString stringWithFormat:@"parallel layer tree has 2 sublayers (got %ld)", + lhs.layer ? (long)lhs.layer.sublayerCount : -1]); + + // 13. hitTest at a point + NSWindow *hitWindow = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 200, 200) + styleMask:NSWindowStyleMaskTitled + backing:NSBackingStoreBuffered + defer:NO]; + FLEXProbeView *htv = [[FLEXProbeView alloc] initWithFrame:NSMakeRect(10, 10, 50, 50)]; + [hitWindow.contentView addSubview:htv]; + FLEXAppKitViewSnapshot *hitSnap = [FLEXAppKitWalker snapshotForHitTestAtPoint:NSMakePoint(20, 20) + inWindow:hitWindow]; + check(hitSnap != nil && [hitSnap.className isEqualToString:@"FLEXProbeView"], + [NSString stringWithFormat:@"hitTest at (20,20) hits FLEXProbeView (got %@)", hitSnap.className]); + + printf("\n%s (%d failure%s)\n", gFailures == 0 ? "ALL PASS" : "FAILURES", + gFailures, gFailures == 1 ? "" : "s"); + return gFailures == 0 ? 0 : 1; + } +} diff --git a/Package.swift b/Package.swift index 81a3203831..0ad264397c 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,8 @@ let package = Package( name: "FLEX", platforms: platforms, products: [ - .library(name: "FLEX", targets: ["FLEX"]) + .library(name: "FLEX", targets: ["FLEX"]), + .library(name: "FLEXAppKit", targets: ["FLEXAppKit"]), ], targets: [ .target( @@ -27,6 +28,7 @@ let package = Package( path: "Classes", exclude: [ "Info.plist", + "ViewHierarchy/AppKit", "Utility/APPLE_LICENSE", "Network/OSCache/LICENSE.md", "Network/PonyDebugger/LICENSE", @@ -41,7 +43,25 @@ let package = Package( .linkedLibrary("sqlite3"), .linkedLibrary("z"), ] - ) + ), + .target( + name: "FLEXAppKit", + path: "Classes/ViewHierarchy/AppKit", + publicHeadersPath: ".", + linkerSettings: [ + .linkedFramework("AppKit", .when(platforms: [.macOS])) + ] + ), + .target( + name: "FLEXAppKitProbe", + dependencies: ["FLEXAppKit"], + path: "DevProbe" + ), + .target( + name: "SampleAppKitDump", + dependencies: ["FLEXAppKit"], + path: "Samples/SampleAppKitDump" + ), ], // Required to compile FLEXSwiftInternal.mm cxxLanguageStandard: .gnucxx11 diff --git a/Samples/SampleAppKitDump/main.m b/Samples/SampleAppKitDump/main.m new file mode 100644 index 0000000000..30523a2694 --- /dev/null +++ b/Samples/SampleAppKitDump/main.m @@ -0,0 +1,101 @@ +// +// main.m — SampleAppKitDump +// +// A self-hosting AppKit sample: builds a representative window (sidebar +// NSVisualEffectView, known fonts, a layer-backed accent row, Auto Layout +// constraints) and prints its OWN runtime view tree as JSON, produced by the real +// FLEXAppKitWalker. No injection, no SIP changes — run with: +// swift run SampleAppKitDump +// Eyeball the JSON against the constructed window to verify the AppKit surface. +// + +#import +#import +@import FLEXAppKit; + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + + NSWindow *window = + [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 640, 420) + styleMask:(NSWindowStyleMaskTitled | NSWindowStyleMaskResizable) + backing:NSBackingStoreBuffered + defer:NO]; + window.title = @"Gourmand — Inbox"; + NSView *content = window.contentView; + + // Sidebar: a vibrancy material, the classic source-list look. + NSVisualEffectView *sidebar = [[NSVisualEffectView alloc] initWithFrame:NSMakeRect(0, 0, 200, 420)]; + sidebar.material = NSVisualEffectMaterialSidebar; + sidebar.blendingMode = NSVisualEffectBlendingModeBehindWindow; + sidebar.identifier = @"Sidebar"; + [content addSubview:sidebar]; + + // A layer-backed selection row: 6pt corner radius, accent fill. + NSView *selectionRow = [[NSView alloc] initWithFrame:NSMakeRect(8, 350, 184, 28)]; + selectionRow.wantsLayer = YES; + selectionRow.layer.cornerRadius = 6; + if (@available(macOS 10.14, *)) { + selectionRow.layer.backgroundColor = NSColor.controlAccentColor.CGColor; + } else { + selectionRow.layer.backgroundColor = NSColor.systemBlueColor.CGColor; + } + selectionRow.identifier = @"SelectionRow"; + [sidebar addSubview:selectionRow]; + + // A label with a known font. + NSTextField *rowLabel = [NSTextField labelWithString:@"Inbox"]; + rowLabel.font = [NSFont systemFontOfSize:13 weight:NSFontWeightSemibold]; + rowLabel.frame = NSMakeRect(16, 354, 160, 18); + rowLabel.identifier = @"InboxLabel"; + [sidebar addSubview:rowLabel]; + + // Detail pane with a constrained title. + NSView *detail = [[NSView alloc] initWithFrame:NSMakeRect(200, 0, 440, 420)]; + detail.identifier = @"Detail"; + [content addSubview:detail]; + + NSTextField *title = [NSTextField labelWithString:@"Welcome"]; + title.font = [NSFont systemFontOfSize:22 weight:NSFontWeightBold]; + title.translatesAutoresizingMaskIntoConstraints = NO; + title.identifier = @"Title"; + [detail addSubview:title]; + [detail addConstraint:[NSLayoutConstraint constraintWithItem:title + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:detail + attribute:NSLayoutAttributeLeading + multiplier:1 + constant:24]]; + [detail addConstraint:[NSLayoutConstraint constraintWithItem:title + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:detail + attribute:NSLayoutAttributeTop + multiplier:1 + constant:24]]; + + [window orderFront:nil]; + + // Walk + project to JSON via the real walker. + NSArray *windows = [FLEXAppKitWalker snapshotApplicationWindows]; + NSDictionary *out = @{ + @"windows": [FLEXAppKitJSON dictionariesForWindows:windows], + @"constraintsForTitle": [FLEXAppKitJSON dictionaryForConstraintNode: + [FLEXConstraintNode constraintsForView:title]], + }; + + NSError *error = nil; + NSData *json = [NSJSONSerialization dataWithJSONObject:out + options:NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys + error:&error]; + if (json == nil) { + fprintf(stderr, "JSON error: %s\n", error.localizedDescription.UTF8String); + return 1; + } + fwrite(json.bytes, 1, json.length, stdout); + printf("\n"); + return 0; + } +}