diff --git a/Package.swift b/Package.swift index c31e4eec1..c791383c9 100644 --- a/Package.swift +++ b/Package.swift @@ -392,6 +392,14 @@ extension Target { var swiftSettings = swiftSettings ?? [] swiftSettings.append(.define("OPENRENDERBOX_RENDERBOX")) self.swiftSettings = swiftSettings + + var cSettings = cSettings ?? [] + cSettings.append(.define("OPENRENDERBOX_RENDERBOX")) + self.cSettings = cSettings + + var cxxSettings = cxxSettings ?? [] + cxxSettings.append(.define("OPENRENDERBOX_RENDERBOX")) + self.cxxSettings = cxxSettings } func addCoreUISettings() { diff --git a/Sources/OpenSwiftUICore/Shape/Path.swift b/Sources/OpenSwiftUICore/Shape/Path.swift index 5e695a045..552b1ecef 100644 --- a/Sources/OpenSwiftUICore/Shape/Path.swift +++ b/Sources/OpenSwiftUICore/Shape/Path.swift @@ -12,11 +12,6 @@ package import OpenRenderBoxShims import OpenSwiftUI_SPI public import OpenCoreGraphicsShims -#if canImport(CoreGraphics) -@_silgen_name("__CGPathParseString") -private func __CGPathParseString(_ path: CGMutablePath, _ utf8CString: UnsafePointer) -> Bool -#endif - // MARK: - Path /// The outline of a 2D shape. @@ -280,7 +275,7 @@ public struct Path: Equatable, LosslessStringConvertible, @unchecked Sendable { guard let str = nsString.utf8String else { return nil } - guard __CGPathParseString(mutablePath, str) else { + guard _CGPathParseString(mutablePath, str) else { return nil } storage = .path(PathBox(mutablePath)) diff --git a/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/CGPath+OpenSwiftUI.h b/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/CGPath+OpenSwiftUI.h new file mode 100644 index 000000000..ab602bda2 --- /dev/null +++ b/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/CGPath+OpenSwiftUI.h @@ -0,0 +1,73 @@ +// +// CGPath+OpenSwiftUI.h +// OpenSwiftUI_SPI + +#ifndef CGPath_OpenSwiftUI_h +#define CGPath_OpenSwiftUI_h + +#include "OpenSwiftUIBase.h" + +#if OPENSWIFTUI_TARGET_OS_DARWIN + +#include +#include + +OPENSWIFTUI_ASSUME_NONNULL_BEGIN + +/// Parses a path string and appends the path elements to a mutable path. +/// +/// The string format uses space-separated numbers followed by command characters: +/// +/// | Command | Parameters | Description | +/// |---------|------------|-------------| +/// | `m` | x y | Move to point | +/// | `l` | x y | Line to point | +/// | `c` | cp1x cp1y cp2x cp2y x y | Cubic Bézier curve | +/// | `q` | cpx cpy x y | Quadratic Bézier curve | +/// | `t` | x y | Smooth quadratic curve (reflects previous control point) | +/// | `v` | cp2x cp2y x y | Smooth cubic curve (uses last point as cp1) | +/// | `y` | cp1x cp1y x y | Shorthand cubic (cp2 equals endpoint) | +/// | `h` | (none) | Close subpath | +/// | `re` | x y width height | Rectangle | +/// +/// Whitespace characters (space, tab, newline, carriage return) are skipped. +/// Numbers can be integers, decimals, or special values like `Inf`. +/// +/// - Parameters: +/// - path: The mutable path to append elements to. +/// - utf8CString: The path string to parse. +/// - Returns: `YES` if parsing succeeded, `NO` if the string is malformed. +BOOL _CGPathParseString(CGMutablePathRef path, const char *utf8CString); + +/// Creates a string description of a path with optional coordinate rounding. +/// +/// - Parameters: +/// - path: The path to describe. +/// - step: The rounding step for coordinates. When non-zero, coordinates +/// are rounded to the nearest multiple of this value. Pass 0 for no rounding. +/// - Returns: A string representation of the path using SVG-like commands +/// (m for move, l for line, h for close). +NSString * _CGPathCopyDescription(CGPathRef path, CGFloat step); + +/// Creates a rounded rectangle path with the specified corner radii. +/// +/// The corner radii are automatically clamped to fit within the rectangle: +/// - Negative values are treated as 0 +/// - Values exceeding half the width or height are reduced accordingly +/// +/// - Parameters: +/// - rect: The rectangle to create the path from. +/// - cornerWidth: The horizontal radius of the rounded corners. +/// - cornerHeight: The vertical radius of the rounded corners. +/// - useRB: If `YES`, uses RenderBox for path creation (when available). +/// If `NO`, uses CoreGraphics directly. +/// - Returns: A new path representing the rounded rectangle. Returns a plain +/// rectangle path if either corner dimension is 0 or if the rect is empty. +CF_RETURNS_RETAINED +CGPathRef _CGPathCreateRoundedRect(CGRect rect, CGFloat cornerWidth, CGFloat cornerHeight, BOOL useRB); + +OPENSWIFTUI_ASSUME_NONNULL_END + +#endif + +#endif /* CGPath_OpenSwiftUI_h */ diff --git a/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/CGPath+OpenSwiftUI.m b/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/CGPath+OpenSwiftUI.m new file mode 100644 index 000000000..797051594 --- /dev/null +++ b/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/CGPath+OpenSwiftUI.m @@ -0,0 +1,269 @@ +// +// CGPath+OpenSwiftUI.m +// OpenSwiftUI_SPI + +#import "CGPath+OpenSwiftUI.h" + +#if OPENSWIFTUI_TARGET_OS_DARWIN + +#import +#import +#import + +#if OPENRENDERBOX_RENDERBOX +@import RenderBox; +#else +@import OpenRenderBox; +#endif + +BOOL _CGPathParseString(CGMutablePathRef path, const char *utf8CString) { + double numbers[6]; + int numCount = 0; + CGFloat currentX = 0.0, currentY = 0.0; + CGFloat lastControlX = 0.0, lastControlY = 0.0; + const char *ptr = utf8CString; + do { + while (*ptr <= 0x1f) { + switch (*ptr) { + case 0: return true; + case 9: case 10: case 12: case 13: ptr++; break; + default: return false; + } + } + unsigned char c = (unsigned char)*ptr; + BOOL isNumberStart = NO; + switch (c) { + case ' ': break; + case '+': case '-': case '.': case '0' ... '9': + case 'E': case 'P': case 'X': case 'e': case 'p': case 'x': + isNumberStart = YES; + break; + case 'I': + if (ptr[1] == 'n' && ptr[2] == 'f') { isNumberStart = YES; } + break; + case 'm': + if (numCount != 2) return NO; + CGPathMoveToPoint(path, NULL, + numbers[0], numbers[1]); + currentX = lastControlX = numbers[0]; + currentY = lastControlY = numbers[1]; + numCount = 0; + break; + case 'l': + if (numCount != 2) return NO; + CGPathAddLineToPoint(path, NULL, + numbers[0], numbers[1]); + currentX = lastControlX = numbers[0]; + currentY = lastControlY = numbers[1]; + numCount = 0; + break; + case 'c': + if (numCount != 6) return NO; + CGPathAddCurveToPoint(path, NULL, + numbers[0], numbers[1], + numbers[2], numbers[3], + numbers[4], numbers[5]); + lastControlX = numbers[2]; + lastControlY = numbers[3]; + currentX = numbers[4]; + currentY = numbers[5]; + numCount = 0; + break; + case 'q': + if (numCount != 4) return NO; + CGPathAddQuadCurveToPoint(path, NULL, + numbers[0], numbers[1], + numbers[2], numbers[3]); + lastControlX = numbers[0]; + lastControlY = numbers[1]; + currentX = numbers[2]; + currentY = numbers[3]; + numCount = 0; + break; + case 't': + if (numCount != 2) return NO; + CGFloat reflectedX = currentX * 2.0 - lastControlX; + CGFloat reflectedY = currentY * 2.0 - lastControlY; + CGPathAddQuadCurveToPoint(path, NULL, + reflectedX, reflectedY, + numbers[0], numbers[1]); + lastControlX = reflectedX; + lastControlY = reflectedY; + currentX = numbers[0]; + currentY = numbers[1]; + numCount = 0; + break; + case 'v': + if (numCount != 4) return NO; + CGPathAddCurveToPoint(path, NULL, + currentX, currentY, + numbers[0], numbers[1], + numbers[2], numbers[3]); + lastControlX = numbers[0]; + lastControlY = numbers[1]; + currentX = numbers[2]; + currentY = numbers[3]; + numCount = 0; + break; + case 'y': + if (numCount != 4) return NO; + CGPathAddCurveToPoint(path, NULL, + numbers[0], numbers[1], + numbers[2], numbers[3], + numbers[2], numbers[3]); + lastControlX = numbers[2]; + lastControlY = numbers[3]; + numCount = 0; + break; + case 'h': + if (numCount != 0) return NO; + CGPathCloseSubpath(path); + lastControlX = 0.0; + lastControlY = 0.0; + numCount = 0; + break; + case 'r': + if (ptr[1] != 'e') return NO; + if (numCount != 4) return NO; + CGPathAddRect(path, NULL, + CGRectMake(numbers[0], numbers[1], + numbers[2], numbers[3])); + ptr++; + numCount = 0; + break; + default: + return false; + } + if (isNumberStart) { + if (numCount == 6) return false; + char *endPtr; + numbers[numCount++] = strtod_l(ptr, &endPtr, NULL); + ptr = endPtr; + } else { + ptr++; + } + } while (1); +} + +typedef struct PathInfo { + CFMutableStringRef description; + CGFloat step; + CGFloat inverseStep; +} PathInfo; + +#define APPEND_COORD(coord) do { \ + CGFloat value = (coord); \ + if (path_info->step != 0.0) { \ + value = path_info->step * round(value * path_info->inverseStep); \ + } \ + char buffer[64]; \ + snprintf_l(buffer, 64, NULL, "%g ", value); \ + CFStringAppendCString(path_info->description, buffer, kCFStringEncodingUTF8); \ +} while (0) + +#define APPEND_POINTS(count) do { \ + for (int i = 0; i < (count); i++) { \ + APPEND_COORD(element->points[i].x); \ + APPEND_COORD(element->points[i].y); \ + } \ +} while (0) + +void copy_path_iter(void * __nullable info, const CGPathElement * element) { + PathInfo *path_info = (PathInfo *)info; + if (path_info->description != NULL) { + CFStringAppend(path_info->description, CFSTR(" ")); + } + UniChar ch; + switch (element->type) { + case kCGPathElementMoveToPoint: + APPEND_POINTS(1); + ch = 'm'; + break; + case kCGPathElementAddLineToPoint: + APPEND_POINTS(1); + ch = 'l'; + break; + case kCGPathElementAddQuadCurveToPoint: + APPEND_POINTS(2); + ch = 'q'; + break; + case kCGPathElementAddCurveToPoint: + APPEND_POINTS(3); + ch = 'c'; + break; + case kCGPathElementCloseSubpath: + ch = 'h'; + break; + default: + return; + } + CFStringAppendCharacters(path_info->description, &ch, 1); +} + +#undef APPEND_COORD +#undef APPEND_POINTS + +NSString * _CGPathCopyDescription(CGPathRef path, CGFloat step) { + PathInfo info = { + CFStringCreateMutable(kCFAllocatorDefault, 0), + step, + 1.0 / step + }; + CGPathApply(path, &info, ©_path_iter); + return (__bridge_transfer NSString *)(info.description); +} + +CGPathRef _CGPathCreateRoundedRect(CGRect rect, CGFloat cornerWidth, CGFloat cornerHeight, BOOL useRB) { + // Clamp corner dimensions to be non-negative + if (cornerWidth < 0.0) { + cornerWidth = 0.0; + } + if (cornerHeight < 0.0) { + cornerHeight = 0.0; + } + + // If either corner dimension is 0, or rect is empty, return a plain rectangle + if (cornerWidth == 0.0 || cornerHeight == 0.0 || CGRectIsEmpty(rect)) { + return CGPathCreateWithRect(rect, NULL); + } + + if (useRB) { + #if OPENRENDERBOX_RENDERBOX + // RBPath rbPath = RBPathMakeRoundedRect(NULL, rect, cornerWidth, cornerHeight, YES); + // CGPathRef cgPath = RBPathCopyCGPath(rbPath); + // RBPathRelease(rbPath); + // return cgPath; + #else + // ORBPath rbPath = ORBPathMakeRoundedRect(NULL, rect, cornerWidth, cornerHeight, YES); + // CGPathRef cgPath = ORBPathCopyCGPath(rbPath); + // ORBPathRelease(rbPath); + // return cgPath; + #endif + } + + // Use CoreGraphics path creation + CGFloat width = CGRectGetWidth(rect); + CGFloat height = CGRectGetHeight(rect); + + // Clamp cornerWidth to at most half the width + if (cornerWidth * 2.0 > width) { + cornerWidth = nextafter(width * 0.5, 0.0); + } + + // Clamp cornerHeight to at most half the height + if (cornerHeight * 2.0 > height) { + cornerHeight = nextafter(height * 0.5, 0.0); + } + + // Final validation + if (cornerWidth < 0.0 || cornerWidth * 2.0 > width) { + return CGPathCreateWithRect(rect, NULL); + } + if (cornerHeight < 0.0 || cornerHeight * 2.0 > height) { + return CGPathCreateWithRect(rect, NULL); + } + + return CGPathCreateWithRoundedRect(rect, cornerWidth, cornerHeight, NULL); +} + +#endif diff --git a/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/__CGPathParseString.h b/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/__CGPathParseString.h deleted file mode 100644 index 0aa5e572b..000000000 --- a/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/__CGPathParseString.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// __CGPathParseString.h -// OpenSwiftUI_SPI - -#ifndef __CGPathParseString_h -#define __CGPathParseString_h - -#include "OpenSwiftUIBase.h" - -#if OPENSWIFTUI_TARGET_OS_DARWIN - -#include -#include - -OPENSWIFTUI_ASSUME_NONNULL_BEGIN - -OPENSWIFTUI_EXPORT -BOOL __CGPathParseString(CGMutablePathRef path, const char *utf8CString); - -OPENSWIFTUI_ASSUME_NONNULL_END - -#endif - -#endif /* __CGPathParseString_h */ diff --git a/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/__CGPathParseString.m b/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/__CGPathParseString.m deleted file mode 100644 index f3858f2ae..000000000 --- a/Sources/OpenSwiftUI_SPI/Overlay/CoreGraphics/__CGPathParseString.m +++ /dev/null @@ -1,12 +0,0 @@ -// -// __CGPathParseString.m -// OpenSwiftUI_SPI - -#import "__CGPathParseString.h" - -#if OPENSWIFTUI_TARGET_OS_DARWIN -BOOL __CGPathParseString(CGMutablePathRef path, const char *utf8CString) { - // TODO - return NO; -} -#endif diff --git a/Tests/OpenSwiftUI_SPITests/Overlay/CoreGraphics/CGPath+OpenSwiftUITests.swift b/Tests/OpenSwiftUI_SPITests/Overlay/CoreGraphics/CGPath+OpenSwiftUITests.swift new file mode 100644 index 000000000..79b58b22c --- /dev/null +++ b/Tests/OpenSwiftUI_SPITests/Overlay/CoreGraphics/CGPath+OpenSwiftUITests.swift @@ -0,0 +1,133 @@ +// +// CGPath+OpenSwiftUITests.swift +// OpenSwiftUI_SPITests + +import OpenSwiftUI_SPI +import Testing + +#if canImport(Darwin) +import CoreGraphics + +@Suite +struct CGPath_OpenSwiftUITests { + // MARK: - _CGPathParseString + + struct ParseString { + @Test(arguments: [ + // m - move to + ("100 0 m 200 100 l h", true, " 100 0 m 200 100 l h"), + // l - line to (multiple) + ("0 0 m 100 0 l 100 100 l 0 100 l h", true, " 0 0 m 100 0 l 100 100 l 0 100 l h"), + // q - quad curve + ("0 0 m 50 0 100 100 q", true, " 0 0 m 50 0 100 100 q"), + // c - cubic curve + ("0 0 m 25 0 75 100 100 100 c", true, " 0 0 m 25 0 75 100 100 100 c"), + // v - smooth cubic (cp1 = current point) + ("0 0 m 50 50 100 100 v", true, " 0 0 m 0 0 50 50 100 100 c"), + // y - shorthand cubic (cp2 = endpoint) + ("0 0 m 25 0 100 100 y", true, " 0 0 m 25 0 100 100 100 100 c"), + // re - rectangle + ("10 20 30 40 re", true, " 10 20 m 40 20 l 40 60 l 10 60 l h"), + // Negative numbers + ("-10 -20 m 30 40 l", true, " -10 -20 m 30 40 l"), + // Decimal numbers + ("0.5 1.5 m 2.5 3.5 l", true, " 0.5 1.5 m 2.5 3.5 l"), + // Whitespace variants (tab, newline) + ("0\t0\nm\n100\t100\tl", true, " 0 0 m 100 100 l"), + // Invalid: unknown command + ("0 0 z", false, ""), + // Invalid: wrong number of parameters + ("0 m", false, ""), + // Invalid: too many numbers + ("1 2 3 4 5 6 7 m", false, ""), + ]) + func parseString(input: String, expectedResult: Bool, expectedString: String) { + let path = CGMutablePath() + let result = _CGPathParseString(path, input) + #expect(result == expectedResult) + if expectedResult { + let description = _CGPathCopyDescription(path, 0) + #expect(description == expectedString) + } + } + } + + // MARK: - _CGPathCopyDescription + + struct CopyDescription { + @Test(arguments: [ + (0.0, " 100 0 m 189.5 189.5 l 0 189.5 l h"), + (1.0, " 100 0 m 190 190 l 0 190 l h"), + (2.0, " 100 0 m 190 190 l 0 190 l h"), + (3.0, " 99 0 m 189 189 l 0 189 l h"), + (4.0, " 100 0 m 188 188 l 0 188 l h"), + ]) + func copyDescription(step: Double, expected: String) { + let path = CGMutablePath() + path.move(to: CGPoint(x: 100, y: 0)) + path.addLine(to: CGPoint(x: 189.5, y: 189.5)) + path.addLine(to: CGPoint(x: 0, y: 189.5)) + path.closeSubpath() + let description = _CGPathCopyDescription(path, step) + #expect(description == expected) + } + + @Test + func copyDescriptionWithQuadCurve() { + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: 0)) + path.addQuadCurve(to: CGPoint(x: 100, y: 100), control: CGPoint(x: 50, y: 0)) + let description = _CGPathCopyDescription(path, 1) + #expect(description == " 0 0 m 50 0 100 100 q") + } + + @Test + func copyDescriptionWithCubicCurve() { + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: 0)) + path.addCurve(to: CGPoint(x: 100, y: 100), control1: CGPoint(x: 25, y: 0), control2: CGPoint(x: 75, y: 100)) + let description = _CGPathCopyDescription(path, 1) + #expect(description == " 0 0 m 25 0 75 100 100 100 c") + } + } + + // MARK: - _CGPathCreateRoundedRect + + struct CreateRoundedRect { + @Test + func zeroCornerWidth() { + let rect = CGRect(x: 0, y: 0, width: 100, height: 50) + let path = _CGPathCreateRoundedRect(rect, 0, 10, false) + // Should return a plain rectangle + #expect(_CGPathCopyDescription(path, 0) == " 0 0 m 100 0 l 100 50 l 0 50 l h") + } + + @Test + func zeroCornerHeight() { + let rect = CGRect(x: 0, y: 0, width: 100, height: 50) + let path = _CGPathCreateRoundedRect(rect, 10, 0, false) + // Should return a plain rectangle + #expect(_CGPathCopyDescription(path, 0) == " 0 0 m 100 0 l 100 50 l 0 50 l h") + } + + @Test + func negativeCornerValues() { + let rect = CGRect(x: 0, y: 0, width: 100, height: 50) + let path = _CGPathCreateRoundedRect(rect, -5, -5, false) + // Negative values are clamped to 0, so should return a plain rectangle + #expect(_CGPathCopyDescription(path, 0) == " 0 0 m 100 0 l 100 50 l 0 50 l h") + } + + @Test + func emptyRect() { + // A rect with zero width or height is considered empty by CGRectIsEmpty + let rect = CGRect(x: 0, y: 0, width: 0, height: 50) + let path = _CGPathCreateRoundedRect(rect, 10, 10, false) + // Empty rect returns a plain rectangle path + #expect(_CGPathCopyDescription(path, 0) == " 0 0 m 0 0 l 0 50 l 0 50 l h") + } + } +} + +#endif +