diff --git a/.gitignore b/.gitignore index 2622fa6b4..0ffb55b0f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ Example/ReferenceImages/**/*.png .docs gh-pages .swift_pd_guess -.claude/commands/guess_filename.md \ No newline at end of file +.claude/commands/guess_filename.md +/.augments \ No newline at end of file diff --git a/Sources/OpenSwiftUI/Util/OpenSwiftUIGlue.swift b/Sources/OpenSwiftUI/Util/OpenSwiftUIGlue.swift index 5dd9e91fd..f54eef06c 100644 --- a/Sources/OpenSwiftUI/Util/OpenSwiftUIGlue.swift +++ b/Sources/OpenSwiftUI/Util/OpenSwiftUIGlue.swift @@ -104,10 +104,6 @@ final public class OpenSwiftUIGlue2: CoreGlue2 { #endif } - override public func configureEmptyEnvironment(_ environment: inout EnvironmentValues) { - environment.configureForPlatform(traitCollection: nil) - } - override public final func configureDefaultEnvironment(_: inout EnvironmentValues) { #if os(iOS) || os(visionOS) #else @@ -115,6 +111,10 @@ final public class OpenSwiftUIGlue2: CoreGlue2 { #endif } + override public func configureEmptyEnvironment(_ environment: inout EnvironmentValues) { + environment.configureForPlatform(traitCollection: nil) + } + override public final func makeRootView(base: AnyView, rootFocusScope: Namespace.ID) -> AnyView { AnyView(base.safeAreaInsets(.zero, next: nil)) } diff --git a/Sources/OpenSwiftUICore/Test/TestApp.swift b/Sources/OpenSwiftUICore/Test/TestApp.swift index 74cd2e470..afdbc43cb 100644 --- a/Sources/OpenSwiftUICore/Test/TestApp.swift +++ b/Sources/OpenSwiftUICore/Test/TestApp.swift @@ -47,7 +47,7 @@ public struct _TestApp { package static let defaultEnvironment: EnvironmentValues = { var environment = EnvironmentValues() - CoreGlue2.shared.configureDefaultEnvironment(&environment) + CoreGlue2.shared.configureEmptyEnvironment(&environment) // TODO: Font: "HelveticaNeue" environment.displayScale = 2.0 environment.setTestSystemColorDefinition() diff --git a/Sources/OpenSwiftUICore/Util/CoreGlue.swift b/Sources/OpenSwiftUICore/Util/CoreGlue.swift index ceb25a896..75fac2204 100644 --- a/Sources/OpenSwiftUICore/Util/CoreGlue.swift +++ b/Sources/OpenSwiftUICore/Util/CoreGlue.swift @@ -226,11 +226,11 @@ open class CoreGlue2: NSObject { _openSwiftUIBaseClassAbstractMethod() } - open func configureEmptyEnvironment(_: inout EnvironmentValues) { + open func configureDefaultEnvironment(_: inout EnvironmentValues) { _openSwiftUIBaseClassAbstractMethod() } - open func configureDefaultEnvironment(_: inout EnvironmentValues) { + open func configureEmptyEnvironment(_: inout EnvironmentValues) { _openSwiftUIBaseClassAbstractMethod() } diff --git a/Sources/OpenSwiftUICore/Util/CustomRecursiveStringConvertible.swift b/Sources/OpenSwiftUICore/Util/CustomRecursiveStringConvertible.swift new file mode 100644 index 000000000..2592c849e --- /dev/null +++ b/Sources/OpenSwiftUICore/Util/CustomRecursiveStringConvertible.swift @@ -0,0 +1,280 @@ +// +// CustomRecursiveStringConvertible.swift +// OpenSwiftUICore +// +// Status: Complete +// Audited for 6.5.4 +// ID: 2DFA09903A864CB0F038E089ECDB7AF8 (SwiftUICore) + +import Foundation + +// MARK: - CustomRecursiveStringConvertible + +package protocol CustomRecursiveStringConvertible { + var descriptionName: String { get } + + var descriptionAttributes: [(name: String, value: String)] { get } + + var defaultDescriptionAttributes: Set { get } + + var descriptionChildren: [any CustomRecursiveStringConvertible] { get } + + var hideFromDescription: Bool { get } +} + +extension CustomRecursiveStringConvertible { + package var defaultDescriptionAttributes: Set { + DefaultDescriptionAttribute.all + } + + package var descriptionChildren: [any CustomRecursiveStringConvertible] { + [] + } + + package var hideFromDescription: Bool { + false + } +} + +extension CustomRecursiveStringConvertible { + package var descriptionName: String { + recursiveDescriptionName(Self.self) + } + + package var descriptionAttributes: [(name: String, value: String)] { + [] + } + + package var recursiveDescription: String { + _recursiveDescription(indent: 0, rounded: false) + } + + package var roundedRecursiveDescription: String { + _recursiveDescription(indent: 0, rounded: true) + } + + package func _recursiveDescription( + indent: Int, + rounded: Bool + ) -> String { + let indentString = repeatElement(" ", count: indent).joined() + var attributes = descriptionAttributes + if rounded { + attributes = attributes.roundedAttributes() + } + attributes.append(contentsOf: indent == 0 ? topLevelAttributes : []) + let sortedAttributes = attributes.sorted(by: \.name) + let attributeString = sortedAttributes.isEmpty ? "" : " " + sortedAttributes + .map { + let escapedName = $0.name + .components(separatedBy: .whitespacesAndNewlines) + .joined(separator: "_") + .escapeXML() + let escapedValue = $0.value.escapeXML() + return #"\#(escapedName)="\#(escapedValue)""# + } + .joined(separator: " ") + let escapedName = descriptionName + .components(separatedBy: .whitespacesAndNewlines) + .joined(separator: "_") + .escapeXML() + let mark = "\(indentString)<\(escapedName)\(attributeString)" + if descriptionChildren.isEmpty { + return "\(mark) />\n" + } else { + var result = "\(mark)>\n" + for child in descriptionChildren { + guard !child.hideFromDescription else { continue } + result.append(child._recursiveDescription(indent: indent &+ 1, rounded: rounded)) + } + result.append("\(indentString)") + result.append("\n") + return result + } + } + + package var topLevelAttributes: [(name: String, value: String)] { + guard _TestApp.isIntending(to: .includeStatusBar), + let isHidden = CoreGlue2.shared.isStatusBarHidden() + else { return [] } + return [(name: "statusBar", value: isHidden ? "hidden" : "visible")] + } +} + +// MARK: - BridgeStringConvertible + +package protocol BridgeStringConvertible { + var bridgeDescriptionChildren: [any CustomRecursiveStringConvertible] { get } +} + +extension BridgeStringConvertible { + package var bridgeDescriptionChildren: [any CustomRecursiveStringConvertible] { [] } +} + +// MARK: - recursiveDescriptionName + +package func recursiveDescriptionName(_ type: any Any.Type) -> String { + var name = "\(type)" + if name.first == "(" { + var substring = name.dropFirst() + if let spaceIndex = substring.firstIndex(of: " ") { + substring.removeSubrange(spaceIndex...) + } + name = String(substring) + } + if let angleIndex = name.firstIndex(of: "<") { + name = String(name[.. [(label: String, value: Double)]? { + guard let first, first == "(", + let last, last == ")" + else { return nil } + + func decomposeTuple() -> (labels: [String], values: [String]) { + let inner = dropFirst().dropLast() + let parts = inner.split(separator: ",", omittingEmptySubsequences: true) + var labels: [String] = [] + var values: [String] = [] + for part in parts { + if let colonIndex = part.firstIndex(of: ":") { + let label = String(part[.. String { + var result = "" + result.reserveCapacity(count) + for char in self { + switch char { + case "\"": result.append(""") + case "&": result.append("&") + case "'": result.append("'") + case "<": result.append("<") + case ">": result.append(">") + case "\n": result.append("\\n") + case "\r": result.append("\\r") + default: result.append(char) + } + } + return result + } +} + +// MARK: - Sequence.roundedAttributes [?] + +extension Sequence where Element == (name: String, value: String) { + package func roundedAttributes() -> [(name: String, value: String)] { + map { (name, value) in + if let doubleValue = Double(value) { + let rounded = round(doubleValue * 256.0) / 256.0 + return (name: name, value: rounded.description) + } else if let tupleValues = value.tupleOfDoubles() { + let roundedTuple = tupleValues.map { (label: $0.label, value: round($0.value * 256.0) / 256.0) } + if roundedTuple.count == 4, + name.range(of: "color", options: .caseInsensitive) != nil + { + let floats = roundedTuple.map { Float($0.value) } + if let colorName = colorNameForColorComponents(floats[0], floats[1], floats[2], floats[3]) { + return (name: name, value: colorName) + } + } + let parts: [String] = roundedTuple.map { item in + if item.label.isEmpty { + return "\(item.value)" + } else { + return "\(item.label): \(item.value)" + } + } + return (name: name, value: "(" + parts.joined(separator: ", ") + ")") + } else { + return (name, value) + } + } + } +} + +// MARK: - Color.Resolved.name + +extension Color.Resolved { + package var name: String? { + @inline(__always) + func quantize(_ value: Float) -> Float { + round(value * 256.0) / 256.0 + } + return colorNameForColorComponents( + quantize(linearRed), + quantize(linearGreen), + quantize(linearBlue), + quantize(opacity) + ) + } +} + +private func colorNameForColorComponents(_ r: Float, _ g: Float, _ b: Float, _ a: Float) -> String? { + if r == 0 && g == 0 && b == 0 { + if a == 0 { + return "clear" + } else if a == 1 { + return "black" + } + } + if r == 1 && g == 1 && b == 1 && a == 1 { + return "white" + } else if r == 8.0 / 256.0 && g == 8.0 / 256.0 && b == 8.0 / 256.0 && a == 1 { + return "gray" + } else if r == 1 && g == 0 && b == 0 && a == 1 { + return "red" + } else if r == 1 && g == 11.0 / 256.0 && b == 11.0 / 256.0 && a == 1 { + return "system-red" + } else if r == 1 && g == 15.0 / 256.0 && b == 11.0 / 256.0 && a == 1 { + return "system-red-dark" + } else if r == 0 && g == 1 && b == 0 && a == 1 { + return "green" + } else if r == 0 && g == 0 && b == 1 && a == 1 { + return "blue" + } else if r == 1 && g == 1 && b == 0 && a == 1 { + return "yellow" + } else if r == 55.0 / 256.0 && g == 0 && b == 55.0 / 256.0 && a == 1 { + return "purple" + } else if r == 1 && g == 55.0 / 256.0 && b == 0 && a == 1 { + return "orange" + } else if r == 0 && g == 1 && b == 1 && a == 1 { + return "teal" + } else if r == 55.0 / 256.0 && g == 55.0 / 256.0 && b == 1 && a == 1 { + return "indigo" + } else if r == 1 && g == 0 && b == 55.0 / 256.0 && a == 1 { + return "pink" + } else if r == 12.0 / 256.0 && g == 12.0 / 256.0 && b == 14.0 / 256.0 && a == 64.0 / 256.0 { + return "brown" + } else if r == 12.0 / 256.0 && g == 12.0 / 256.0 && b == 14.0 / 256.0 && a == 76.0 / 256.0 { + return "placeholder-text" + } else { + return nil + } +} diff --git a/Sources/OpenSwiftUICore/Util/DefaultDescriptionAttribute.swift b/Sources/OpenSwiftUICore/Util/DefaultDescriptionAttribute.swift new file mode 100644 index 000000000..b548d0f11 --- /dev/null +++ b/Sources/OpenSwiftUICore/Util/DefaultDescriptionAttribute.swift @@ -0,0 +1,77 @@ +// +// DefaultDescriptionAttribute.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +// MARK: - DefaultDescriptionAttribute + +package enum DefaultDescriptionAttribute: String, CaseIterable { + case rect + case origin + case startPoint + case endPoint + case transform + case clips + case cornerRadius + case continuousCorners + case opacity + case borderWidth + case borderColor + case backgroundColor + case compositingFilter + case disableUpdates + case shadowOpacity + case shadowRadius + case shadowColor + case shadowOffset + case shadowPath + case shadowPathIsBounds + case contentsCenter + case contentsScaling + case contentsMultiplyColor + case colorScheme + case filters + case gradientType + case gradientColors + case gradientLocations + case gradientInterpolations + + package static var all: Set { + var cases = Set(Self.allCases) + if _TestApp.isIntending(to: .ignoreGeometry) { + cases.subtract(Self.geometry) + } + if _TestApp.isIntending(to: .ignoreCornerRadius) { + cases.subtract(Self.relatedToCornerRadius) + } + if !_TestApp.isIntending(to: .includeContinuousCorners) { + cases.remove(.continuousCorners) + } + if !_TestApp.isIntending(to: .includeExtendedContents) { + cases.remove(.contentsMultiplyColor) + } + if !_TestApp.isIntending(to: .includeExtendedGradients) { + cases.remove(.gradientType) + cases.remove(.gradientColors) + cases.remove(.gradientLocations) + cases.remove(.gradientInterpolations) + } + if _TestApp.isIntending(to: .ignoreOpacity) { + cases.remove(.opacity) + } + if _TestApp.isIntending(to: .ignoreCompositingFilters) { + cases.subtract([.filters, .compositingFilter]) + } + return Set(cases) + } + + package static var geometry: Set { + [.rect, .origin, .startPoint, .endPoint, .transform] + } + + package static var relatedToCornerRadius: Set { + [.cornerRadius, .continuousCorners] + } +} diff --git a/Tests/OpenSwiftUICoreTests/Util/CustomRecursiveStringConvertibleTests.swift b/Tests/OpenSwiftUICoreTests/Util/CustomRecursiveStringConvertibleTests.swift new file mode 100644 index 000000000..07ec7b575 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Util/CustomRecursiveStringConvertibleTests.swift @@ -0,0 +1,313 @@ +// +// CustomRecursiveStringConvertibleTests.swift +// OpenSwiftUICoreTests + +@testable +#if OPENSWIFTUI_ENABLE_PRIVATE_IMPORTS +@_private(sourceFile: "CustomRecursiveStringConvertible.swift") +#endif +import OpenSwiftUICore +import Testing + +struct CustomRecursiveStringConvertibleTests { + + // MARK: - recursiveDescription Tests + + struct TestView: CustomRecursiveStringConvertible { + var name: String = "View" + var attributes: [(name: String, value: String)] = [] + var children: [any CustomRecursiveStringConvertible] = [] + var hidden: Bool = false + + var descriptionName: String { name } + var descriptionAttributes: [(name: String, value: String)] { attributes } + var descriptionChildren: [any CustomRecursiveStringConvertible] { children } + var hideFromDescription: Bool { hidden } + } + + @Test( + arguments: [ + // recursiveDescriptionBasic + ( + TestView(), + "\n" + ), + ( + TestView( + name: "SimpleView", + attributes: [(name: "title", value: "Hello")] + ), + #""# + "\n" + ), + // recursiveDescriptionSortsAttributes + ( + TestView( + name: "View", + attributes: [ + (name: "zIndex", value: "1"), + (name: "alpha", value: "0.5"), + (name: "beta", value: "test"), + ] + ), + #""# + "\n" + ), + // recursiveDescriptionEscapesXML + ( + TestView( + name: "My View", + attributes: [(name: "text", value: #""#)] + ), + #""# + "\n" + ), + // recursiveDescriptionWithChildren + ( + TestView( + name: "Parent", + children: [ + TestView(name: "Child"), + TestView(name: "Child"), + ] + ), + "\n \n \n\n" + ), + // recursiveDescriptionHidesChildren + ( + TestView( + name: "Parent", + children: [ + TestView(name: "Hidden", hidden: true), + TestView(name: "Visible"), + ] + ), + "\n \n\n" + ), + ] as [(TestView, String)] + ) + func recursiveDescriptionTests(view: TestView, expected: String) { + #expect(view.recursiveDescription == expected) + } + + @Test(arguments: [ + // Simple double rounding + (TestView(name: "View", attributes: [(name: "value", value: "1.23456789")]), #""# + "\n"), + // Tuple of doubles rounding + (TestView(name: "View", attributes: [(name: "pos", value: "(x: 10.123456, y: 20.987654)")]), #""# + "\n"), + // Color detection (red) + (TestView(name: "View", attributes: [(name: "color", value: "(1.0, 0.0, 0.0, 1.0)")]), #""# + "\n"), + // Integer values become doubles + (TestView(name: "View", attributes: [(name: "count", value: "42")]), #""# + "\n"), + // Non-numeric values unchanged + (TestView(name: "View", attributes: [(name: "title", value: "Hello")]), #""# + "\n"), + ] as [(TestView, String)]) + func roundedRecursiveDescriptionTests(view: TestView, expected: String) { + #expect(view.roundedRecursiveDescription == expected) + } + + // MARK: - topLevelAttributes Tests + + @Test + func topLevelAttributesWithoutIntent() { + _TestApp.setIntents([]) + #expect(TestView().topLevelAttributes.isEmpty) + } + + // MARK: - recursiveDescriptionName Tests + + private struct PrivateType {} + struct SimpleType {} + struct GenericType {} + + @Test(arguments: [ + (PrivateType.self, "PrivateType"), + (SimpleType.self, "SimpleType"), + (GenericType.self, "GenericType"), + (Int.self, "Int"), + (String.self, "String"), + (Array.self, "Array"), + (Dictionary.self, "Dictionary"), + ((Int, String).self, "Int,"), + ] as [(Any.Type, String)]) + func recursiveDescriptionNameTests(type: Any.Type, expected: String) { + #expect(recursiveDescriptionName(type) == expected) + } + + // MARK: - String.tupleOfDoubles Tests + + @Test(arguments: [ + // Valid tuples with labeled elements + ("(x: 1.0, y: 2.0)", [("x", 1.0), ("y", 2.0)]), + ("(width: 100, height: 200)", [("width", 100.0), ("height", 200.0)]), + ("(a: 0.5)", [("a", 0.5)]), + // With negative values + ("(x: -1.0, y: -2.5)", [("x", -1.0), ("y", -2.5)]), + // With scientific notation + ("(value: 1e10)", [("value", 1e10)]), + // Multiple elements + ("(a: 1, b: 2, c: 3)", [("a", 1.0), ("b", 2.0), ("c", 3.0)]), + // With whitespace variations + ("( x: 1.0 , y: 2.0 )", [("x", 1.0), ("y", 2.0)]), + ]) + func tupleOfDoublesValid(input: String, expected: [(String, Double)]) { + let result = input.tupleOfDoubles() + #expect(result != nil) + guard let result else { return } + #expect(result.count == expected.count) + for (index, element) in result.enumerated() { + #expect(element.label == expected[index].0) + #expect(element.value.isApproximatelyEqual(to: expected[index].1)) + } + } + + @Test(arguments: [ + // Missing opening parenthesis + "x: 1.0, y: 2.0)", + // Missing closing parenthesis + "(x: 1.0, y: 2.0", + // No parentheses + "x: 1.0, y: 2.0", + // Invalid double value + "(x: abc, y: 2.0)", + // Empty string + "", + // Just parentheses with no content - though this might parse to empty array + // Wrong bracket types + "[x: 1.0, y: 2.0]", + ]) + func tupleOfDoublesInvalid(input: String) { + let result = input.tupleOfDoubles() + #expect(result == nil) + } + + @Test + func tupleOfDoublesEmptyParens() { + // Empty parens should return empty array (not nil) + let result = "()".tupleOfDoubles() + #expect(result != nil) + #expect(result?.isEmpty == true) + } + + // MARK: - String.escapeXML Tests + + #if OPENSWIFTUI_ENABLE_PRIVATE_IMPORTS + @Test(arguments: [ + ("hello", "hello"), + (#""quote""#, ""quote""), + ("a & b", "a & b"), + ("it's", "it's"), + ("", "<tag>"), + ("line1\nline2", #"line1\nline2"#), + ("line1\rline2", #"line1\rline2"#), + (#"<"'&>"#, "<"'&>"), + ("", ""), + ] as [(String, String)]) + func escapeXMLTests(input: String, expected: String) { + #expect(input.escapeXML() == expected) + } + #endif + + // MARK: - Sequence.roundedAttributes Tests + + @Test + func roundedAttributesSimpleDouble() { + let attrs: [(name: String, value: String)] = [ + (name: "width", value: "100.123456789"), + (name: "height", value: "200.0"), + ] + let result = attrs.roundedAttributes() + #expect(result.count == 2) + // 100.123456789 * 256 = 25631.60493... → rounds to 25632 → 25632/256 = 100.125 + #expect(result[0].name == "width") + #expect(result[0].value == "100.125") + #expect(result[1].name == "height") + #expect(result[1].value == "200.0") + } + + @Test + func roundedAttributesTupleOfDoubles() { + let attrs: [(name: String, value: String)] = [ + (name: "position", value: "(x: 10.123456, y: 20.987654)"), + ] + let result = attrs.roundedAttributes() + #expect(result.count == 1) + #expect(result[0].name == "position") + // 10.123456 * 256 = 2591.60 → 2592 → 10.125 + // 20.987654 * 256 = 5372.84 → 5373 → 20.98828125 + #expect(result[0].value == "(x: 10.125, y: 20.98828125)") + } + + @Test + func roundedAttributesColorDetection() { + // Color with RGBA values that match "red" + let attrs: [(name: String, value: String)] = [ + (name: "foregroundColor", value: "(1.0, 0.0, 0.0, 1.0)"), + ] + let result = attrs.roundedAttributes() + #expect(result.count == 1) + #expect(result[0].name == "foregroundColor") + #expect(result[0].value == "red") + } + + @Test + func roundedAttributesColorNotMatched() { + // Color values that don't match any known color + let attrs: [(name: String, value: String)] = [ + (name: "someColor", value: "(0.5, 0.3, 0.7, 1.0)"), + ] + let result = attrs.roundedAttributes() + #expect(result.count == 1) + #expect(result[0].name == "someColor") + // Should fall back to tuple format with rounded values + #expect(result[0].value == "(0.5, 0.30078125, 0.69921875, 1.0)") + } + + @Test + func roundedAttributesNonNumeric() { + let attrs: [(name: String, value: String)] = [ + (name: "title", value: "Hello World"), + (name: "isEnabled", value: "true"), + ] + let result = attrs.roundedAttributes() + #expect(result.count == 2) + #expect(result[0].name == "title") + #expect(result[0].value == "Hello World") + #expect(result[1].name == "isEnabled") + #expect(result[1].value == "true") + } + + // MARK: - Color.Resolved.name Tests + + @Test(arguments: [ + // Basic colors + (Float(0), Float(0), Float(0), Float(0), "clear"), + (Float(0), Float(0), Float(0), Float(1), "black"), + (Float(1), Float(1), Float(1), Float(1), "white"), + (Float(8.0 / 256.0), Float(8.0 / 256.0), Float(8.0 / 256.0), Float(1), "gray"), + (Float(1), Float(0), Float(0), Float(1), "red"), + (Float(0), Float(1), Float(0), Float(1), "green"), + (Float(0), Float(0), Float(1), Float(1), "blue"), + (Float(1), Float(1), Float(0), Float(1), "yellow"), + (Float(0), Float(1), Float(1), Float(1), "teal"), + // System colors + (Float(1), Float(11.0 / 256.0), Float(11.0 / 256.0), Float(1), "system-red"), + (Float(1), Float(15.0 / 256.0), Float(11.0 / 256.0), Float(1), "system-red-dark"), + // Extended colors + (Float(55.0 / 256.0), Float(0), Float(55.0 / 256.0), Float(1), "purple"), + (Float(1), Float(55.0 / 256.0), Float(0), Float(1), "orange"), + (Float(55.0 / 256.0), Float(55.0 / 256.0), Float(1), Float(1), "indigo"), + (Float(1), Float(0), Float(55.0 / 256.0), Float(1), "pink"), + (Float(12.0 / 256.0), Float(12.0 / 256.0), Float(14.0 / 256.0), Float(64.0 / 256.0), "brown"), + (Float(12.0 / 256.0), Float(12.0 / 256.0), Float(14.0 / 256.0), Float(76.0 / 256.0), "placeholder-text"), + // Quantization: slight variations round to named color + (Float(0.999), Float(0.001), Float(0.001), Float(0.999), "red"), + ] as [(Float, Float, Float, Float, String)]) + func colorResolvedName(r: Float, g: Float, b: Float, a: Float, expected: String) { + let color = Color.Resolved(linearRed: r, linearGreen: g, linearBlue: b, opacity: a) + #expect(color.name == expected) + } + + @Test + func colorResolvedNameUnknown() { + let color = Color.Resolved(linearRed: 0.5, linearGreen: 0.3, linearBlue: 0.7, opacity: 1) + #expect(color.name == nil) + } +}