diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index 36d0d9d1..ea4c24c5 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -28,8 +28,8 @@ jobs: strategy: fail-fast: false matrix: - xcode: [ "16.3", "16.4" ] # "26.0" is broken with xcodebuild test - os: [ macos-15 ] + xcode: [ "26.1" ] + os: [ macos-26 ] runs-on: ${{ matrix.os }} env: @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=iOS Simulator,name=iPhone 16e" \ + xcrun xcodebuild build -workspace . -scheme Vexil -skipMacroValidation -destination "platform=iOS Simulator,name=iPhone 16e" \ | xcbeautify --renderer github-actions build-ios: diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index f6632342..62d30d53 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -28,8 +28,8 @@ jobs: strategy: fail-fast: false matrix: - xcode: [ "16.3", "16.4", "26.0" ] - os: [ macos-15 ] + xcode: [ "26.1" ] + os: [ macos-26 ] runs-on: ${{ matrix.os }} env: @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=macOS,name=My Mac" \ + xcrun xcodebuild test -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=macOS,name=My Mac" \ | xcbeautify --renderer github-actions build-macos: diff --git a/.github/workflows/tvos-tests.yml b/.github/workflows/tvos-tests.yml index f1be5ef1..ff9d635e 100644 --- a/.github/workflows/tvos-tests.yml +++ b/.github/workflows/tvos-tests.yml @@ -28,8 +28,8 @@ jobs: strategy: fail-fast: false matrix: - xcode: [ "16.3", "16.4" ] # "26.0" is broken with xcodebuild test - os: [ macos-15 ] + xcode: [ "26.1" ] + os: [ macos-26 ] runs-on: ${{ matrix.os }} env: @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=tvOS Simulator,name=Apple TV 4K (3rd generation)" \ + xcrun xcodebuild build -workspace . -scheme Vexil-Package -skipMacroValidation -destination "platform=tvOS Simulator,name=Apple TV 4K (3rd generation)" \ | xcbeautify --renderer github-actions build-tvos: diff --git a/.github/workflows/visionos-tests.yml b/.github/workflows/visionos-tests.yml index 86008ad7..51859479 100644 --- a/.github/workflows/visionos-tests.yml +++ b/.github/workflows/visionos-tests.yml @@ -28,8 +28,8 @@ jobs: strategy: fail-fast: false matrix: - xcode: [ "16.3", "16.4" ] # "26.0" is broken with xcodebuild test - os: [ macos-15 ] + xcode: [ "26.1" ] + os: [ macos-26 ] runs-on: ${{ matrix.os }} env: @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=visionOS Simulator,name=Apple Vision Pro,os=2.5" \ + xcrun xcodebuild build -workspace . -scheme Vexil-Package -skipMacroValidation -destination "generic/platform=visionOS Simulator" \ | xcbeautify --renderer github-actions build-visionos: diff --git a/.github/workflows/watchos-tests.yml b/.github/workflows/watchos-tests.yml index b0eebe04..f03a337f 100644 --- a/.github/workflows/watchos-tests.yml +++ b/.github/workflows/watchos-tests.yml @@ -28,8 +28,8 @@ jobs: strategy: fail-fast: false matrix: - xcode: [ "16.3", "16.4" ] # "26.0" is broken with xcodebuild test - os: [ macos-15 ] + xcode: [ "26.1" ] + os: [ macos-26 ] runs-on: ${{ matrix.os }} env: @@ -42,7 +42,7 @@ jobs: run: | set -o pipefail && \ NSUnbufferedIO=YES \ - xcrun xcodebuild test -workspace . -scheme Vexil -skipMacroValidation -destination "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm)" \ + xcrun xcodebuild build -workspace . -scheme Vexil -skipMacroValidation -destination "platform=watchOS Simulator,name=Apple Watch Ultra 3 (49mm)" \ | xcbeautify --renderer github-actions build-watchos: diff --git a/.gitignore b/.gitignore index ea02318b..76f02d49 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .build /build /.swiftpm +/Examples/.swiftpm /Packages /*.xcodeproj xcuserdata/ diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj new file mode 100644 index 00000000..5b3ab83b --- /dev/null +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -0,0 +1,499 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 436D05AB2EA194D30056498A /* Vexil in Frameworks */ = {isa = PBXBuildFile; productRef = 436D05AA2EA194D30056498A /* Vexil */; }; + 436D05AD2EA194D30056498A /* Vexillographer in Frameworks */ = {isa = PBXBuildFile; productRef = 436D05AC2EA194D30056498A /* Vexillographer */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 436D05732EA193620056498A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 436D055D2EA193610056498A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 436D05642EA193610056498A; + remoteInfo = Examples; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 436D05652EA193610056498A /* Examples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Examples.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 436D05722EA193620056498A /* ExamplesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExamplesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 436D05672EA193610056498A /* Examples */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Examples; + sourceTree = ""; + }; + 436D05752EA193620056498A /* ExamplesTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ExamplesTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 436D05622EA193610056498A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 436D05AD2EA194D30056498A /* Vexillographer in Frameworks */, + 436D05AB2EA194D30056498A /* Vexil in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 436D056F2EA193620056498A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 436D055C2EA193610056498A = { + isa = PBXGroup; + children = ( + 436D05672EA193610056498A /* Examples */, + 436D05752EA193620056498A /* ExamplesTests */, + 436D05662EA193610056498A /* Products */, + ); + sourceTree = ""; + }; + 436D05662EA193610056498A /* Products */ = { + isa = PBXGroup; + children = ( + 436D05652EA193610056498A /* Examples.app */, + 436D05722EA193620056498A /* ExamplesTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 436D05642EA193610056498A /* Examples */ = { + isa = PBXNativeTarget; + buildConfigurationList = 436D05862EA193620056498A /* Build configuration list for PBXNativeTarget "Examples" */; + buildPhases = ( + 436D05612EA193610056498A /* Sources */, + 436D05622EA193610056498A /* Frameworks */, + 436D05632EA193610056498A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 436D05672EA193610056498A /* Examples */, + ); + name = Examples; + packageProductDependencies = ( + 436D05AA2EA194D30056498A /* Vexil */, + 436D05AC2EA194D30056498A /* Vexillographer */, + ); + productName = Examples; + productReference = 436D05652EA193610056498A /* Examples.app */; + productType = "com.apple.product-type.application"; + }; + 436D05712EA193620056498A /* ExamplesTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 436D05892EA193620056498A /* Build configuration list for PBXNativeTarget "ExamplesTests" */; + buildPhases = ( + 436D056E2EA193620056498A /* Sources */, + 436D056F2EA193620056498A /* Frameworks */, + 436D05702EA193620056498A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 436D05742EA193620056498A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 436D05752EA193620056498A /* ExamplesTests */, + ); + name = ExamplesTests; + packageProductDependencies = ( + ); + productName = ExamplesTests; + productReference = 436D05722EA193620056498A /* ExamplesTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 436D055D2EA193610056498A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 436D05642EA193610056498A = { + CreatedOnToolsVersion = 26.0; + }; + 436D05712EA193620056498A = { + CreatedOnToolsVersion = 26.0; + TestTargetID = 436D05642EA193610056498A; + }; + }; + }; + buildConfigurationList = 436D05602EA193610056498A /* Build configuration list for PBXProject "Examples" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 436D055C2EA193610056498A; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 436D05A92EA194D30056498A /* XCLocalSwiftPackageReference "../" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 436D05662EA193610056498A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 436D05642EA193610056498A /* Examples */, + 436D05712EA193620056498A /* ExamplesTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 436D05632EA193610056498A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 436D05702EA193620056498A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 436D05612EA193610056498A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 436D056E2EA193620056498A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 436D05742EA193620056498A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 436D05642EA193610056498A /* Examples */; + targetProxy = 436D05732EA193620056498A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 436D05842EA193620056498A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 436D05852EA193620056498A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 436D05872EA193620056498A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unsignedapps.vexil.Examples; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 436D05882EA193620056498A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unsignedapps.vexil.Examples; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 436D058A2EA193620056498A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unsignedapps.vexil.ExamplesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Examples.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Examples"; + }; + name = Debug; + }; + 436D058B2EA193620056498A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unsignedapps.vexil.ExamplesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Examples.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Examples"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 436D05602EA193610056498A /* Build configuration list for PBXProject "Examples" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 436D05842EA193620056498A /* Debug */, + 436D05852EA193620056498A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 436D05862EA193620056498A /* Build configuration list for PBXNativeTarget "Examples" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 436D05872EA193620056498A /* Debug */, + 436D05882EA193620056498A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 436D05892EA193620056498A /* Build configuration list for PBXNativeTarget "ExamplesTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 436D058A2EA193620056498A /* Debug */, + 436D058B2EA193620056498A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 436D05A92EA194D30056498A /* XCLocalSwiftPackageReference "../" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 436D05AA2EA194D30056498A /* Vexil */ = { + isa = XCSwiftPackageProductDependency; + productName = Vexil; + }; + 436D05AC2EA194D30056498A /* Vexillographer */ = { + isa = XCSwiftPackageProductDependency; + productName = Vexillographer; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 436D055D2EA193610056498A /* Project object */; +} diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/Examples.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Examples/Examples.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..be939c45 --- /dev/null +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "7d18382a80812208b61dcb5af0b5caf1a54236a8ca18bf984fd71055ff6d89a2", + "pins" : [ + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/Examples/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/Examples/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Examples/Examples/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/Examples/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Examples/Assets.xcassets/Contents.json b/Examples/Examples/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Examples/Examples/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Examples/Dependencies.swift b/Examples/Examples/Dependencies.swift new file mode 100644 index 00000000..739b4ddb --- /dev/null +++ b/Examples/Examples/Dependencies.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Vexil + +struct Dependencies { + var flags = FlagPole( + hoist: FeatureFlags.self, + sources: FlagPole.defaultSources + [RemoteFlags.values] + ) + + @TaskLocal + static var current = Dependencies() +} + +enum RemoteFlags { + static let values = FlagValueDictionary() +} diff --git a/Examples/Examples/DoubleAndBooleanControlStyle.swift b/Examples/Examples/DoubleAndBooleanControlStyle.swift new file mode 100644 index 00000000..84d7c37b --- /dev/null +++ b/Examples/Examples/DoubleAndBooleanControlStyle.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexillographer + +struct DoubleAndBooleanControlStyle: FlagControlStyle { + + func makeBody(configuration: Configuration) -> some View { + VStack { + Toggle(configuration.name, isOn: configuration.$value.isEnabled) + Slider(value: configuration.$value.percent, in: 0 ... 1.0) { + Text("Percent \(configuration.value.percent)") + } minimumValueLabel: { + Text("0.0") + } maximumValueLabel: { + Text("1.0") + } + .disabled(!configuration.value.isEnabled) + } + } + +} + +extension FlagControlStyle where Self == DoubleAndBooleanControlStyle { + + static var doubleAndBoolean: Self { Self() } + +} diff --git a/Sources/Vexillographer/Utilities/AnyView.swift b/Examples/Examples/ExamplesApp.swift similarity index 79% rename from Sources/Vexillographer/Utilities/AnyView.swift rename to Examples/Examples/ExamplesApp.swift index 6820df46..d5f1bbee 100644 --- a/Sources/Vexillographer/Utilities/AnyView.swift +++ b/Examples/Examples/ExamplesApp.swift @@ -11,14 +11,13 @@ // //===----------------------------------------------------------------------===// -#if os(iOS) || os(macOS) - import SwiftUI -extension View { - func eraseToAnyView() -> AnyView { - AnyView(self) +@main +struct ExamplesApp: App { + var body: some Scene { + WindowGroup { + RootView() + } } } - -#endif diff --git a/Examples/Examples/FeatureFlags.swift b/Examples/Examples/FeatureFlags.swift new file mode 100644 index 00000000..dc9e48e0 --- /dev/null +++ b/Examples/Examples/FeatureFlags.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Vexil + +@FlagContainer +struct FeatureFlags { + + @Flag(description: "Whether to display the developer menu in the UI", display: .hidden) + var developerMenuEnabled = false + + @FlagGroup(description: "Builtin types", display: .section) + var builtinTypes: BuiltinTypes + + @FlagGroup("Custom flags") + var customFlags: CustomFlags + +} + +@FlagContainer +struct BuiltinTypes { + + @Flag("A boolean flag") + var boolean = true + + @Flag("An optional boolean flag") + var optionalBoolean: Bool? + + @Flag("A string flag") + var string = "Blob" + + @Flag("An optional string flag") + var optionalString: String? + + @Flag("An integer flag") + var integer = 42 + + @Flag("An optional integer flag") + var optionalInteger: Int? + + @Flag("A double flag") + var double = 1729.42 + + @Flag("An optional double flag") + var optionalDouble: Double? + + @Flag("An case iterable flag") + var caseIterable = Enum.foo + + @Flag("An optional case iterable flag") + var optionalCaseIterable: Enum? + + enum Enum: String, CaseIterable, FlagValue { + case foo + case bar + case baz + } + +} + +@FlagContainer +struct CustomFlags { + + @Flag("A boolean flag") + var boolean = true + + @Flag("A double flag") + var double = 1729.42 + + @Flag("A double and boolaen flag") + var doubleAndBoolean = DoubleAndBoolean() + + struct DoubleAndBoolean: Codable, Equatable, FlagValue { + var percent = 0.5 + var isEnabled = false + } + +} diff --git a/Examples/Examples/RootView.swift b/Examples/Examples/RootView.swift new file mode 100644 index 00000000..ce9c8b22 --- /dev/null +++ b/Examples/Examples/RootView.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil +import Vexillographer + +struct RootView: View { + + var body: some View { + NavigationView { + List { + FlagControl(Dependencies.current.flags.$developerMenuEnabled) { configuration in + Section { + FlagToggle(configuration: configuration) + } + if configuration.value { + NavigationLink("Developer Menu") { + Vexillographer() + } + } + } + } + } + .flagPole( + Dependencies.current.flags, + editableSource: Dependencies.current.flags._sources.first + ) + .flagControlStyle(.doubleAndBoolean) + + } + +} + +#Preview { + RootView() +} diff --git a/Sources/Vexillographer/Extensions/NSApplication+Sidebar.swift b/Examples/ExamplesTests/ExamplesTests.swift similarity index 66% rename from Sources/Vexillographer/Extensions/NSApplication+Sidebar.swift rename to Examples/ExamplesTests/ExamplesTests.swift index 4304cb22..e728fe6a 100644 --- a/Sources/Vexillographer/Extensions/NSApplication+Sidebar.swift +++ b/Examples/ExamplesTests/ExamplesTests.swift @@ -11,16 +11,14 @@ // //===----------------------------------------------------------------------===// -#if os(macOS) +@testable import Examples +import Testing -import AppKit +struct ExamplesTests { -extension NSApplication { - - func toggleKeyWindowSidebar() { - keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil) + @Test + func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. } } - -#endif diff --git a/Examples/Package.swift b/Examples/Package.swift new file mode 100644 index 00000000..104d5ac9 --- /dev/null +++ b/Examples/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version:6.1 + +import PackageDescription + +let package = Package( + name: "", + products: [], + dependencies: [], + targets: [] +) diff --git a/Package.swift b/Package.swift index 9f4527dc..bc83b295 100644 --- a/Package.swift +++ b/Package.swift @@ -15,9 +15,7 @@ let package = Package( ], products: [ - // Automatic .library(name: "Vexil", targets: [ "Vexil" ]), -// .library(name: "Vexillographer", targets: [ "Vexillographer" ]), ], dependencies: [ @@ -44,15 +42,6 @@ let package = Package( ] ), - // Vexillographer - -// .target( -// name: "Vexillographer", -// dependencies: [ -// .target(name: "Vexil"), -// ] -// ), - // Macros .macro( @@ -76,6 +65,25 @@ let package = Package( #if !os(Linux) +// MARK: - Vexillographer + +// Vexillographer is not supported on Linux + +package.products.append( + .library(name: "Vexillographer", targets: [ "Vexillographer" ]) +) + +package.targets.append( + .target( + name: "Vexillographer", + dependencies: [ + .target(name: "Vexil"), + ] + ) +) + +// MARK: - Macro Testing + // We can't disable macro validation using `swift test` so these are guaranteed to fail on Linux package.targets.append( .testTarget( diff --git a/Sources/Vexil/Group.swift b/Sources/Vexil/Group.swift index 375b2bb5..edb4ed1b 100644 --- a/Sources/Vexil/Group.swift +++ b/Sources/Vexil/Group.swift @@ -95,6 +95,12 @@ public macro FlagGroup( /// `NavigationLink`. Other options include `.section` to wrap it in a `Section` and `.hidden` /// to hide it from Vexillographer entirely. /// +@attached(accessor) +@attached(peer, names: prefixed(`$`)) +public macro FlagGroup( + _ description: StaticString +) = #externalMacro(module: "VexilMacros", type: "FlagGroupMacro") + @attached(accessor) @attached(peer, names: prefixed(`$`)) public macro FlagGroup( diff --git a/Sources/Vexillographer/Bindings/Binding.swift b/Sources/Vexillographer/Bindings/Binding.swift deleted file mode 100644 index 921ca43d..00000000 --- a/Sources/Vexillographer/Bindings/Binding.swift +++ /dev/null @@ -1,82 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -extension Binding { - @available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) - init(key: String, manager: FlagValueManager, defaultValue: FValue, transformer: Transformer.Type) where Transformer: BoxedFlagValueTransformer, FValue: FlagValue, Transformer.EditingValue == Value, FValue.BoxedValueType == Transformer.OriginalValue { - self.init( - get: { - let value: FValue.BoxedValueType? = manager.boxedValue(key: key, type: FValue.self) ?? defaultValue.unwrappedBoxedValue() - return transformer.toEditingValue(value) - }, - set: { newValue in - do { - let value = transformer.toOriginalValue(newValue) - try manager.setBoxedValue(value, type: FValue.self, key: key) - - } catch { - print("[Vexilographer] Could not set flag with key \"\(key)\" to \"\(newValue)\"") - } - } - ) - } - - @available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) - init(key: String, manager: FlagValueManager, defaultValue: Transformer.OriginalValue, transformer: Transformer.Type) where Transformer: FlagValueTransformer, Transformer.EditingValue == Value { - self.init( - get: { - let value: Transformer.OriginalValue = manager.flagValue(key: key) ?? defaultValue - return transformer.toEditingValue(value) - }, - set: { newValue in - do { - let value = transformer.toOriginalValue(newValue) - try manager.setFlagValue(value, key: key) - - } catch { - print("[Vexilographer] Could not set flag with key \"\(key)\" to \"\(newValue)\"") - } - } - ) - } -} - - -// MARK: - Flag Value Transformers - -/// Describes a type that can be used to transform Boxed Flag Values for editing -/// -protocol BoxedFlagValueTransformer { - associatedtype OriginalValue - associatedtype EditingValue - - static func toEditingValue(_ value: OriginalValue?) -> EditingValue - static func toOriginalValue(_ value: EditingValue) -> OriginalValue? -} - -/// Describes a type that can be used to transform Flag Values for editing -/// -protocol FlagValueTransformer { - associatedtype OriginalValue: FlagValue - associatedtype EditingValue: FlagValue - - static func toEditingValue(_ value: OriginalValue?) -> EditingValue - static func toOriginalValue(_ value: EditingValue) -> OriginalValue? -} - -#endif diff --git a/Sources/Vexillographer/Bindings/EditableBoxedFlagValues.swift b/Sources/Vexillographer/Bindings/EditableBoxedFlagValues.swift deleted file mode 100644 index c03897ee..00000000 --- a/Sources/Vexillographer/Bindings/EditableBoxedFlagValues.swift +++ /dev/null @@ -1,67 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import Vexil - -extension FlagValue { - - /// Casts a value to its BoxedValueType - /// - func unwrappedBoxedValue() -> BoxedValueType? { - let boxed = boxedFlagValue - - switch boxed { - case let .bool(value): return value as? BoxedValueType - case let .data(value): return value as? BoxedValueType - case let .double(value): return value as? BoxedValueType - case let .float(value): return value as? BoxedValueType - case let .integer(value): return value as? BoxedValueType - case let .string(value): return value as? BoxedValueType - case .none: return BoxedValueType?.none - // unsupported - case .array, .dictionary: return nil - } - } - - /// Initialises a FlagValue from its BoxedValueType - /// - init? (unwrapped value: BoxedValueType) { - - if BoxedValueType.self == Bool.self || BoxedValueType.self == Bool?.self, let wrapped = value as? Bool { - self.init(boxedFlagValue: .bool(wrapped)) - - } else if BoxedValueType.self == Data.self || BoxedValueType.self == Data?.self, let wrapped = value as? Data { - self.init(boxedFlagValue: .data(wrapped)) - - } else if BoxedValueType.self == Double.self || BoxedValueType.self == Double?.self, let wrapped = value as? Double { - self.init(boxedFlagValue: .double(wrapped)) - - } else if BoxedValueType.self == Float.self || BoxedValueType.self == Float?.self, let wrapped = value as? Float { - self.init(boxedFlagValue: .float(wrapped)) - - } else if BoxedValueType.self == Int.self || BoxedValueType.self == Int?.self, let wrapped = value as? Int { - self.init(boxedFlagValue: .integer(wrapped)) - - } else if BoxedValueType.self == String.self || BoxedValueType.self == String?.self, let wrapped = value as? String { - self.init(boxedFlagValue: .string(wrapped)) - - } else { - nil - } - } -} - -#endif diff --git a/Sources/Vexillographer/Bindings/LosslessStringTransformer.swift b/Sources/Vexillographer/Bindings/LosslessStringTransformer.swift deleted file mode 100644 index 2bae3826..00000000 --- a/Sources/Vexillographer/Bindings/LosslessStringTransformer.swift +++ /dev/null @@ -1,34 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import Vexil - -/// A simple transformer that converts a FlagValue into a string for editing with a TextField -/// -struct LosslessStringTransformer: BoxedFlagValueTransformer where Value: LosslessStringConvertible { - typealias OriginalValue = Value - typealias EditingValue = String - - static func toEditingValue(_ value: Value?) -> String { - value!.description - } - - static func toOriginalValue(_ value: String) -> Value? { - Value(value) - } -} - -#endif diff --git a/Sources/Vexillographer/Bindings/OptionalTransformer.swift b/Sources/Vexillographer/Bindings/OptionalTransformer.swift deleted file mode 100644 index 07e83f86..00000000 --- a/Sources/Vexillographer/Bindings/OptionalTransformer.swift +++ /dev/null @@ -1,44 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import Vexil - -struct OptionalTransformer: BoxedFlagValueTransformer - where Value: OptionalFlagValue, Default: OptionalDefaultValue, Underlying: BoxedFlagValueTransformer, - Underlying.OriginalValue == Value.WrappedFlagValue, Default == Underlying.EditingValue -{ - typealias OriginalValue = Value - typealias EditingValue = Underlying.EditingValue - - static func toEditingValue(_ value: OriginalValue?) -> EditingValue { - guard let wrapped = value?.wrapped else { - return Default.defaultValue - } - return Underlying.toEditingValue(wrapped) - } - - static func toOriginalValue(_ value: EditingValue) -> OriginalValue? { - Value(Underlying.toOriginalValue(value)) - } -} - -// MARK: - Default Values - -protocol OptionalDefaultValue { - static var defaultValue: Self { get } -} - -#endif diff --git a/Sources/Vexillographer/Bindings/PassthroughTransformer.swift b/Sources/Vexillographer/Bindings/PassthroughTransformer.swift deleted file mode 100644 index 37f506d0..00000000 --- a/Sources/Vexillographer/Bindings/PassthroughTransformer.swift +++ /dev/null @@ -1,47 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import Vexil - -/// A simple transformer that passes the value through as the same type -/// -struct BoxedPassthroughTransformer: BoxedFlagValueTransformer { - typealias OriginalValue = Value - typealias EditingValue = Value - - static func toEditingValue(_ value: OriginalValue?) -> Value { - value! - } - - static func toOriginalValue(_ value: Value) -> OriginalValue? { - value - } -} - -struct PassthroughTransformer: FlagValueTransformer where Value: FlagValue { - typealias OriginalValue = Value - typealias EditingValue = Value - - static func toEditingValue(_ value: OriginalValue?) -> Value { - value! - } - - static func toOriginalValue(_ value: Value) -> OriginalValue? { - value - } -} - -#endif diff --git a/Sources/Vexillographer/CopyButton.swift b/Sources/Vexillographer/CopyButton.swift deleted file mode 100644 index 1668d0cd..00000000 --- a/Sources/Vexillographer/CopyButton.swift +++ /dev/null @@ -1,40 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI - -struct CopyButton: View { - - private let action: () -> Void - - init(action: @escaping () -> Void) { - self.action = action - } - - var body: some View { -#if compiler(>=5.3.1) - if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { - return Button(action: self.action) { - Label("Copy", systemImage: "doc.on.doc") - }.eraseToAnyView() - } -#endif - return Button("Copy", action: action) - .eraseToAnyView() - } - -} - -#endif diff --git a/Sources/Vexillographer/DetailButton.swift b/Sources/Vexillographer/DetailButton.swift deleted file mode 100644 index 8fbea775..00000000 --- a/Sources/Vexillographer/DetailButton.swift +++ /dev/null @@ -1,93 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct DetailButton: View { - - // MARK: - Properties - - let hasChanges: Bool - - @Binding - var showDetail: Bool - - @State - private var size = CGSize.zero - - @State - private var isDraggingInside = false - - // MARK: - View - -#if os(iOS) - - var body: some View { - Image(systemName: hasChanges ? "info.circle.fill" : "info.circle") - .imageScale(.large) - .foregroundColor(.accentColor) - .opacity(isDraggingInside ? 0.3 : 1) - .animation(isDraggingInside ? .easeOut(duration: 0.15) : .easeIn(duration: 0.2), value: isDraggingInside) - .background( - GeometryReader { proxy in - Color.clear - .preference(key: SizePreferenceKey.self, value: proxy.size) - } - ) - .onPreferenceChange(SizePreferenceKey.self) { size in - self.size = size - } - .gesture(selectionGesture) - } - - private var selectionGesture: some Gesture { - DragGesture(minimumDistance: 0) - .onChanged { data in - isDraggingInside = CGRect(origin: .zero, size: size) - .insetBy(dx: -10, dy: -10) - .contains(data.location) - } - .onEnded { _ in - if isDraggingInside { - showDetail.toggle() - isDraggingInside = false - } - } - } - -#elseif os(macOS) - - var body: some View { - EmptyView() - } - -#endif - -} - -private struct SizePreferenceKey: PreferenceKey { - - typealias Value = CGSize - - static var defaultValue: Value = .zero - - static func reduce(value: inout Value, nextValue: () -> Value) { - value = nextValue() - } - -} - -#endif diff --git a/Sources/Vexillographer/Flag Value Controls/BooleanFlagControl.swift b/Sources/Vexillographer/Flag Value Controls/BooleanFlagControl.swift deleted file mode 100644 index 2a9f398b..00000000 --- a/Sources/Vexillographer/Flag Value Controls/BooleanFlagControl.swift +++ /dev/null @@ -1,122 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -// Boolean Flags -// -// Boolean flags are those those whose boxed type is `Bool`, or `Bool?` -// -// This includes `Bool` directly, but also `Optional` and -// `RawRepresentable where RawValue == Bool`. -// -// Plus any custom types that are boxed to a Bool. - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct BooleanFlagControl: View { - - // MARK: - Properties - - let label: String - @Binding - var value: Bool - - let hasChanges: Bool - let isEditable: Bool - @Binding - var showDetail: Bool - - - // MARK: - Views - - var body: some View { - HStack { - if isEditable { - Toggle(label, isOn: $value) - } else { - Text(label).font(.headline) - Spacer() - FlagDisplayValueView(value: value) - } - DetailButton(hasChanges: hasChanges, showDetail: $showDetail) - } - } -} - - -// MARK: - Boolean Flags - -/// Support for `UnfurledFlag` when `FlagValue.BoxedValueType == Bool` -/// -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol BooleanEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: BooleanEditableFlag where Value.BoxedValueType == Bool { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - BooleanFlagControl( - label: label, - value: Binding( - key: info.key, - manager: manager, - defaultValue: flag.defaultValue, - transformer: BoxedPassthroughTransformer.self - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .eraseToAnyView() - } -} - -// MARK: - Optional Boolean Flags - -/// Support for `UnfurledFlag` when `FlagValue.BoxedFlagValue == Bool?` -/// -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol OptionalBooleanEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: OptionalBooleanEditableFlag where Value: FlagValue, Value.BoxedValueType: OptionalFlagValue, Value.BoxedValueType.WrappedFlagValue == Bool { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - BooleanFlagControl( - label: label, - value: Binding( - key: flag.key, - manager: manager, - defaultValue: flag.defaultValue, - transformer: OptionalTransformer.self - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .eraseToAnyView() - } -} - -extension Bool: OptionalDefaultValue { - static var defaultValue: Bool { - false - } -} - -#endif diff --git a/Sources/Vexillographer/Flag Value Controls/CaseIterableFlagControl.swift b/Sources/Vexillographer/Flag Value Controls/CaseIterableFlagControl.swift deleted file mode 100644 index c84ec5f3..00000000 --- a/Sources/Vexillographer/Flag Value Controls/CaseIterableFlagControl.swift +++ /dev/null @@ -1,180 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -// Case Iterable Flags -// -// Case Iterable flags are those those whose flag value conforms to `CaseIterable` - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct CaseIterableFlagControl: View where Value: FlagValue, Value: CaseIterable, Value: Hashable, Value.AllCases: RandomAccessCollection { - - // MARK: - Properties - - let label: String - @Binding - var value: Value - - let hasChanges: Bool - let isEditable: Bool - @Binding - var showDetail: Bool - - // MARK: - View Body - - var content: some View { - HStack { - Text(label).font(.headline) - Spacer() - FlagDisplayValueView(value: value) - } - } - -#if os(iOS) - - var body: some View { - HStack { - if isEditable { - NavigationLink(destination: selector) { - content - } - } else { - content - } - DetailButton(hasChanges: hasChanges, showDetail: $showDetail) - } - } - - var selector: some View { - SelectorList(value: $value) - .navigationBarTitle(Text(label), displayMode: .inline) - } - -#elseif os(macOS) - - var body: some View { - Group { - if isEditable { - picker - } else { - content - } - } - } - - var picker: some View { - let picker = Picker( - selection: $value, - label: Text(label), - content: { - ForEach(Value.allCases, id: \.self) { value in - FlagDisplayValueView(value: value) - } - } - ) - -#if compiler(>=5.3.1) - - return picker - .pickerStyle(MenuPickerStyle()) - -#else - - return picker - -#endif - } - -#endif - - struct SelectorList: View { - @Binding - var value: Value - - @Environment(\.presentationMode) - private var presentationMode - - var body: some View { - Form { - ForEach(Value.allCases, id: \.self) { value in - Button( - action: { - self.value = value - presentationMode.wrappedValue.dismiss() - }, - label: { - HStack { - FlagDisplayValueView(value: value) - .foregroundColor(.primary) - Spacer() - - if value == self.value { - checkmark - } - } - } - ) - } - } - } - -#if os(macOS) - - var checkmark: some View { - Text("✓") - } - -#else - - var checkmark: some View { - Image(systemName: "checkmark") - } - -#endif - } -} - -// MARK: - Creating CaseIterableFlagControls - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol CaseIterableEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: CaseIterableEditableFlag - where Value: FlagValue, Value: CaseIterable, Value.AllCases: RandomAccessCollection, - Value: RawRepresentable, Value.RawValue: FlagValue, Value: Hashable -{ - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - CaseIterableFlagControl( - label: label, - value: Binding( - key: flag.key, - manager: manager, - defaultValue: flag.defaultValue, - transformer: PassthroughTransformer.self - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .eraseToAnyView() - } -} - -#endif diff --git a/Sources/Vexillographer/Flag Value Controls/OptionalCaseIterableFlagControl.swift b/Sources/Vexillographer/Flag Value Controls/OptionalCaseIterableFlagControl.swift deleted file mode 100644 index b02e139e..00000000 --- a/Sources/Vexillographer/Flag Value Controls/OptionalCaseIterableFlagControl.swift +++ /dev/null @@ -1,184 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -// Optional Case Iterable Flags -// -// For those whose flag value is optional and conform to `CaseIterable` - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct OptionalCaseIterableFlagControl: View - where Value: OptionalFlagValue, Value.WrappedFlagValue: CaseIterable, - Value.WrappedFlagValue: Hashable, Value.WrappedFlagValue.AllCases: RandomAccessCollection -{ - - // MARK: - Properties - - let label: String - @Binding - var value: Value - - let hasChanges: Bool - let isEditable: Bool - @Binding - var showDetail: Bool - - // MARK: - View Body - - var content: some View { - HStack { - Text(label).font(.headline) - Spacer() - FlagDisplayValueView(value: value.wrapped) - } - } - - var body: some View { - HStack { - if isEditable { - NavigationLink(destination: selector) { - content - } - } else { - content - } - DetailButton(hasChanges: hasChanges, showDetail: $showDetail) - } - } - -#if os(iOS) - - var selector: some View { - SelectorList(value: $value) - .navigationBarTitle(Text(label), displayMode: .inline) - } - -#else - - var selector: some View { - SelectorList(value: $value) - } - -#endif - - struct SelectorList: View { - @Binding - var value: Value - - @Environment(\.presentationMode) - private var presentationMode - - var body: some View { - Form { - Section { - Button( - action: { - valueSelected(nil) - }, - label: { - HStack { - Text("None") - .foregroundColor(.primary) - Spacer() - - if value.wrapped == nil { - checkmark - } - } - } - ) - } - - ForEach(Value.WrappedFlagValue.allCases, id: \.self) { value in - Button( - action: { - valueSelected(value) - }, - label: { - HStack { - FlagDisplayValueView(value: value) - .foregroundColor(.primary) - Spacer() - - if value == self.value.wrapped { - checkmark - } - } - } - ) - } - } - } - -#if os(macOS) - - var checkmark: some View { - Text("✓") - } - -#else - - var checkmark: some View { - Image(systemName: "checkmark") - } - -#endif - func valueSelected(_ value: Value.WrappedFlagValue?) { - self.value.wrapped = value - presentationMode.wrappedValue.dismiss() - } - } -} - -// MARK: - Creating CaseIterableFlagControls - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol OptionalCaseIterableEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: OptionalCaseIterableEditableFlag - where Value: OptionalFlagValue, Value.WrappedFlagValue: CaseIterable, - Value.WrappedFlagValue.AllCases: RandomAccessCollection, Value.WrappedFlagValue: RawRepresentable, - Value.WrappedFlagValue.RawValue: FlagValue, Value.WrappedFlagValue: Hashable -{ - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - let key = info.key - - return OptionalCaseIterableFlagControl( - label: label, - value: Binding( - get: { Value(manager.flagValue(key: key)) }, - set: { newValue in - do { - try manager.setFlagValue(newValue, key: key) - - } catch { - print("[Vexilographer] Could not set flag with key \"\(key)\" to \"\(newValue)\"") - } - } - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .eraseToAnyView() - } -} - -#endif diff --git a/Sources/Vexillographer/Flag Value Controls/StringFlagControl.swift b/Sources/Vexillographer/Flag Value Controls/StringFlagControl.swift deleted file mode 100644 index 4cf97e2d..00000000 --- a/Sources/Vexillographer/Flag Value Controls/StringFlagControl.swift +++ /dev/null @@ -1,165 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -// String Flag Values -// -// String flag values are ones whose flag value conforms to `LosslessStringConvertible` - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct StringFlagControl: View { - - // MARK: - Properties - - let label: String - @Binding - var value: String - - let hasChanges: Bool - let isEditable: Bool - @Binding - var showDetail: Bool - - - // MARK: - Views - - var body: some View { - HStack { - Text(label) - Spacer() - if isEditable { - TextField("", text: $value) - .multilineTextAlignment(.trailing) - } else { - FlagDisplayValueView(value: value) - } - DetailButton(hasChanges: hasChanges, showDetail: $showDetail) - } - } -} - - -// MARK: - Lossless String Convertible Flags - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol StringEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: StringEditableFlag where Value.BoxedValueType: LosslessStringConvertible { - - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - StringFlagControl( - label: label, - value: Binding( - key: flag.key, - manager: manager, - defaultValue: flag.defaultValue, - transformer: LosslessStringTransformer.self - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .flagValueKeyboard(type: Value.self) - .eraseToAnyView() - } - -} - - -// MARK: - Optional Lossless String Convertible Flags - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol OptionalStringEditableFlag { - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension UnfurledFlag: OptionalStringEditableFlag - where Value: FlagValue, Value.BoxedValueType: OptionalFlagValue, Value.BoxedValueType.WrappedFlagValue: LosslessStringConvertible -{ - - func control(label: String, manager: FlagValueManager, showDetail: Binding) -> AnyView { - StringFlagControl( - label: label, - value: Binding( - key: flag.key, - manager: manager, - defaultValue: flag.defaultValue, - transformer: OptionalTransformer>.self - ), - hasChanges: manager.hasValueInSource(flag: flag), - isEditable: manager.isEditable, - showDetail: showDetail - ) - .flagValueKeyboard(type: Value.self) - .eraseToAnyView() - } - -} - -extension String: OptionalDefaultValue { - var unwrapped: String? { - self - } - - static var defaultValue: String { - "" - } -} - -#if os(iOS) - -private extension View { - func flagValueKeyboard(type: Value.Type) -> some View where Value: FlagValue { - keyboardType(Value.keyboardType) - } -} - -private extension FlagValue { - - /// Provides a hint as to what keyboard type to use for a given FlagValue - /// - static var keyboardType: UIKeyboardType { - if Self.self == Double.self || Self.self == Float.self { - return .decimalPad - - } else if Self.self == Int.self || Self.self == Int8.self || Self.self == Int16.self - || Self.self == Int32.self || Self.self == Int64.self || Self.self == UInt.self - || Self.self == UInt8.self || Self.self == UInt16.self || Self.self == UInt32.self - || Self.self == UInt64.self - { - return .numberPad - } - - return .default - } -} - -#else - -private extension View { - func flagValueKeyboard(type: (some FlagValue).Type) -> some View { - self - } -} - -#endif - -#endif diff --git a/Sources/Vexillographer/FlagControl/FlagControl.swift b/Sources/Vexillographer/FlagControl/FlagControl.swift new file mode 100644 index 00000000..b814e7e4 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagControl.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +// Public way to create single custom controls +public struct FlagControl: View { + + private var wigwag: FlagWigwag + private var content: (FlagControlConfiguration) -> Content + + @State + private var cachedValue: Value? + @State + private var seed = 0 + + @Environment(\.flagPoleContext) + private var flagPoleContext + + public init( + _ wigwag: FlagWigwag, + @ViewBuilder content: @escaping (FlagControlConfiguration) -> Content + ) { + self.wigwag = wigwag + self.content = content + } + + public var body: some View { + content( + FlagControlConfiguration( + seed: seed, + name: wigwag.name, + description: wigwag.description, + keyPath: wigwag.keyPath, + isEditable: flagPoleContext.editableSource != nil, + hasValue: editableValue != nil, + defaultValue: wigwag.defaultValue, + value: Binding(get: getValue, set: setValue), + resetValue: resetValue + ) + ) + .task { + for await _ in wigwag.changes { + seed += 1 + cachedValue = resolvedValue + } + } + } + + private var editableValue: Value? { + flagPoleContext.editableSource?.flagValue(key: wigwag.key) + } + + private var nonEditableValue: Value { + let editableSourceID = flagPoleContext.editableSource?.flagValueSourceID + for source in flagPoleContext.sources where source.flagValueSourceID != editableSourceID { + if let value = source.flagValue(key: wigwag.key) as Value? { + return value + } + } + return wigwag.defaultValue + } + + private var resolvedValue: Value { + editableValue ?? nonEditableValue + } + + private func getValue() -> Value { + cachedValue ?? resolvedValue + } + + private func setValue(_ newValue: Value, transaction: Transaction) { + // TODO: logging + guard let editableSource = flagPoleContext.editableSource else { + print("Trying to set a value that isn't editable. This will be ignored.") + return + } + + do { + $cachedValue.transaction(transaction).wrappedValue = newValue + try editableSource.setFlagValue(newValue, key: wigwag.key) + } catch { + print("Error trying to set value.") + } + } + + private func resetValue() { + guard let editableSource = flagPoleContext.editableSource else { + print("Trying to set a value that isn't editable. This will be ignored.") + return + } + + do { + cachedValue = nonEditableValue + try editableSource.setFlagValue(nil as Value?, key: wigwag.key) + } catch { + print("Error trying to reset value.") + } + } + +} diff --git a/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift b/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift new file mode 100644 index 00000000..6872a835 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagControlConfiguration.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +// Binding to a flag value could be a property wrapper but maybe best +// not to blur the lines +public struct FlagControlConfiguration { + + private let seed: Int + public let name: String + public let description: String? + public let keyPath: FlagKeyPath + public let isEditable: Bool + public let hasValue: Bool + public let defaultValue: Value + @Binding + public var value: Value + private let _resetValue: () -> Void + + init( + seed: Int, + name: String, + description: String? = nil, + keyPath: FlagKeyPath, + isEditable: Bool, + hasValue: Bool, + defaultValue: Value, + value: Binding, + resetValue: @escaping () -> Void + ) { + self.seed = seed + self.name = name + self.description = description + self.keyPath = keyPath + self.isEditable = isEditable + self.hasValue = hasValue + self.defaultValue = defaultValue + _value = value + self._resetValue = resetValue + } + + public var key: String { + keyPath.key + } + + public func resetValue() { + _resetValue() + } + +} diff --git a/Sources/Vexillographer/FlagControl/FlagDetail.swift b/Sources/Vexillographer/FlagControl/FlagDetail.swift new file mode 100644 index 00000000..65bc39fc --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagDetail.swift @@ -0,0 +1,114 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +// Sheet with flag info +// - can reset value +// - can see source hierarchy +struct FlagDetailView: View { + + var configuration: FlagControlConfiguration + + @Environment(\.dismiss) + private var dismiss + @Environment(\.flagPoleContext) + private var flagPoleContext + + var body: some View { + List { + Section { + if let description = configuration.description { + Text(description) + } + RowContent("Key", value: configuration.keyPath.key) + } + if let editableSource = flagPoleContext.editableSource { + let editableValue = editableSource.flagValue(key: configuration.key) as Value? + Section("Current Source") { + FlagValueRow(editableSource.flagValueSourceName, value: editableValue) +#if os(macOS) + RowContent("Clear Current Source") { + Button("Clear", role: .destructive) { + configuration.resetValue() + } + .disabled(editableValue == nil) + } +#else + Button("Clear Current Source", role: .destructive) { + configuration.resetValue() + } + .disabled(editableValue == nil) +#endif + } + } + Section("Flagpole Source Hierarchy") { + ForEach(flagPoleContext.sources, id: \.flagValueSourceID) { source in + let isEditableSource = source.flagValueSourceID == flagPoleContext.editableSource?.flagValueSourceID + let sourceValue = source.flagValue(key: configuration.key) as Value? + FlagValueRow(source.flagValueSourceName, value: sourceValue) + .font(isEditableSource ? .headline : nil) + } + FlagValueRow("Default Value", value: configuration.defaultValue) + } + } + .navigationTitle(configuration.name) +#if os(macOS) + .padding(0) // FIXME: Views for mac +#elseif !os(tvOS) + .navigationBarTitleDisplayMode(.inline) +#endif + .toolbar { + ToolbarItem { + Button { + dismiss() + } label: { + Label("Close", systemImage: "xmark") + } + } + } + } +} + +struct FlagValueRow: View { + + private var label: String + private var value: Value? + + init(_ label: String, value: Value?) { + self.label = label + self.value = value + } + + var body: some View { + RowContent(label) { + // Clean this up + if let value { + if let value = value as? any OptionalProtocol { + if let wrapped = value.wrapped { + Text(String(describing: wrapped)) + } else { + Text("nil") + } + } else { + Text(String(describing: value)) + } + } else { + Text("not set") + .italic() + } + } + } + +} diff --git a/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift b/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift new file mode 100644 index 00000000..f2ce9065 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagPicker+Bool.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +// Convenience for optional bool +public extension FlagPicker where Value.BoxedValueType == Bool?, SelectionValue == Bool?, Content == DefaultFlagPickerContent { + init(configuration: FlagControlConfiguration) { + self.init(configuration: configuration, selection: \.asOptionalBool) { + DefaultFlagPickerContent(Array([nil, true, false])) + } + } +} + +private extension FlagValue where BoxedValueType == Bool? { + + var asOptionalBool: Bool? { + get { + Bool(boxedFlagValue: boxedFlagValue) + } + set { + let boxedFlagValue = newValue.map(BoxedFlagValue.bool) ?? .none + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } + +} + +protocol OptionalBooleanFlagPickerRepresentable { + @MainActor + func makeContent() -> any View +} + +extension FlagControlConfiguration: OptionalBooleanFlagPickerRepresentable where Value.BoxedValueType == Bool? { + func makeContent() -> any View { + FlagPicker(configuration: self) + } +} diff --git a/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift b/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift new file mode 100644 index 00000000..054977cc --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagPicker+CaseIterable.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +// Convenience for case iterable +public extension FlagPicker where Value: CaseIterable, SelectionValue == Value, Content == DefaultFlagPickerContent { + + init(configuration: FlagControlConfiguration) { + self.init(configuration: configuration) { + DefaultFlagPickerContent(Array(Value.allCases)) + } + } +} + +public extension FlagPicker { + + init( + configuration: FlagControlConfiguration + ) where Value == Wrapped?, SelectionValue == Wrapped?, Content == DefaultFlagPickerContent { + self.init(configuration: configuration, selection: \.wrapped) { + DefaultFlagPickerContent([nil as Wrapped?] + Array(Wrapped.allCases)) + } + } +} + +protocol CaseIterableFlagPickerRepresentable { + @MainActor + func makeContent() -> any View +} + +extension FlagControlConfiguration: CaseIterableFlagPickerRepresentable where Value: CaseIterable & Hashable { + func makeContent() -> any View { + FlagPicker(configuration: self) + } +} + +protocol OptionalCaseIterableFlagPickerRepresentable { + @MainActor + func makeContent() -> any View +} + +extension FlagControlConfiguration: OptionalCaseIterableFlagPickerRepresentable where Value: OptionalProtocol, Value.Wrapped: CaseIterable & Hashable { + func makeContent() -> any View { + FlagPicker(configuration: self, selection: \.wrapped) { + DefaultFlagPickerContent([nil as Value.Wrapped?] + Array(Value.Wrapped.allCases)) + } + } +} diff --git a/Sources/Vexillographer/FlagControl/FlagPicker.swift b/Sources/Vexillographer/FlagControl/FlagPicker.swift new file mode 100644 index 00000000..8f124199 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagPicker.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +// A picker +public struct FlagPicker: View { + + private var name: String + @Binding + private var value: Value + private var selection: WritableKeyPath + private var content: Content + + init( + configuration: FlagControlConfiguration, + selection: WritableKeyPath, + @ViewBuilder content: () -> Content + ) { + self.name = configuration.name + _value = configuration.$value + self.selection = selection + self.content = content() + } + + public var body: some View { + Picker(name, selection: $value[dynamicMember: selection]) { + content + } + } + +} + +public extension FlagPicker where SelectionValue == Value { + + init(configuration: FlagControlConfiguration, @ViewBuilder content: () -> Content) { + self.init(configuration: configuration, selection: \.asSelection, content: content) + } + +} + +private extension FlagValue { + + var asSelection: Self { + get { self } + set { self = newValue } + } + +} + + + + +public struct DefaultFlagPickerContent: View { + + private var options: [SelectionValue] + + init(_ options: [SelectionValue]) { + self.options = options + } + + public var body: some View { + ForEach(options, id: \.self) { option in + if let optional = option as? any OptionalProtocol { + if let wrapped = optional.wrapped { + Text(String(describing: wrapped)) + } else { + Section { + Text("None") + } + } + } else { + Text(String(describing: option)) + } + } + } + +} diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift b/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift new file mode 100644 index 00000000..d54e9481 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagTextField+FloatingPoint.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +// TextField convenience for floating point +extension FlagTextField where Value.BoxedValueType: BinaryFloatingPoint { + + init(configuration: FlagControlConfiguration) { + self = Self( + configuration: configuration, + formatted: \.asString, + editingFormat: { $0 } + ) +#if os(iOS) || os(tvOS) + .keyboardType(.decimalPad) +#endif + } + +} + +private extension FlagValue where BoxedValueType: BinaryFloatingPoint { + var asString: String { + get { + Double(boxedFlagValue: boxedFlagValue)?.description ?? "" + } + set { + let boxedFlagValue = newValue.isEmpty ? BoxedFlagValue.double(0) : .double(Double(newValue) ?? 0) + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } +} + +protocol FloatingPointTextFieldRepresentable { + @MainActor + func makeContent() -> any View +} + +extension FlagControlConfiguration: FloatingPointTextFieldRepresentable where Value.BoxedValueType: BinaryFloatingPoint { + func makeContent() -> any View { + FlagTextField(configuration: self) + } +} + +extension FlagTextField { + + init(configuration: FlagControlConfiguration) where Value == Wrapped?, Wrapped.BoxedValueType: BinaryFloatingPoint { + self = Self( + configuration: configuration, + formatted: \.asStringOrEmpty, + editingFormat: { $0 } + ) +#if os(iOS) || os(tvOS) + .keyboardType(.decimalPad) +#endif + + } + +} + +private extension FlagValue where BoxedValueType: OptionalProtocol, BoxedValueType.Wrapped: BinaryFloatingPoint { + var asStringOrEmpty: String { + get { + Double(boxedFlagValue: boxedFlagValue)?.description ?? "" + } + set { + let boxedFlagValue = newValue.isEmpty ? BoxedFlagValue.none : .double(Double(newValue) ?? 0) + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } +} + +protocol OptionalFloatingPointFlagTextFieldRepresentable { + @MainActor + func makeContent() -> any View +} + +extension FlagControlConfiguration: OptionalFloatingPointFlagTextFieldRepresentable where Value.BoxedValueType: OptionalProtocol, Value.BoxedValueType.Wrapped: BinaryFloatingPoint { + func makeContent() -> any View { + FlagTextField( + configuration: self, + formatted: \.asStringOrEmpty, + editingFormat: { $0 } + ) +#if os(iOS) || os(tvOS) + .keyboardType(.decimalPad) +#endif + } +} diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift b/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift new file mode 100644 index 00000000..2b77880c --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagTextField+Integer.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +// TextField convenience for integer +extension FlagTextField where Value.BoxedValueType: BinaryInteger { + + init(configuration: FlagControlConfiguration) { + self = Self( + configuration: configuration, + formatted: \.asString, + editingFormat: { $0.filter(\.isNumber) } + ) +#if os(iOS) || os(tvOS) + .keyboardType(.numberPad) +#endif + } + +} + +private extension FlagValue where BoxedValueType: BinaryInteger { + var asString: String { + get { + Int(boxedFlagValue: boxedFlagValue)?.description ?? "" + } + set { + let boxedFlagValue = newValue.isEmpty ? BoxedFlagValue.integer(0) : .integer(Int(newValue) ?? 0) + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } +} + +protocol IntegerFlagTextFieldRepresentable { + @MainActor + func makeContent() -> any View +} + +extension FlagControlConfiguration: IntegerFlagTextFieldRepresentable where Value.BoxedValueType: BinaryInteger { + func makeContent() -> any View { + FlagTextField(configuration: self) + } +} + +extension FlagTextField { + + init(configuration: FlagControlConfiguration) where Value == Wrapped?, Wrapped.BoxedValueType: BinaryInteger { + self = Self( + configuration: configuration, + formatted: \.asStringOrEmpty, + editingFormat: { $0.filter(\.isNumber) } + ) +#if os(iOS) || os(tvOS) + .keyboardType(.numberPad) +#endif + } + +} + +private extension FlagValue where BoxedValueType: OptionalProtocol, BoxedValueType.Wrapped: BinaryInteger { + var asStringOrEmpty: String { + get { + Int(boxedFlagValue: boxedFlagValue)?.description ?? "" + } + set { + let boxedFlagValue = newValue.isEmpty ? BoxedFlagValue.none : .integer(Int(newValue) ?? 0) + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } +} + +protocol OptionalIntegerFlagTextFieldRepresentable { + @MainActor + func makeContent() -> any View +} + +extension FlagControlConfiguration: OptionalIntegerFlagTextFieldRepresentable where Value.BoxedValueType: OptionalProtocol, Value.BoxedValueType.Wrapped: BinaryInteger { + func makeContent() -> any View { + FlagTextField( + configuration: self, + formatted: \.asStringOrEmpty, + editingFormat: { $0.filter(\.isNumber) } + ) +#if os(iOS) || os(tvOS) + .keyboardType(.numberPad) +#endif + } +} diff --git a/Sources/Vexillographer/FlagControl/FlagTextField+String.swift b/Sources/Vexillographer/FlagControl/FlagTextField+String.swift new file mode 100644 index 00000000..97d5b88e --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagTextField+String.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +// TextField convenience for string +extension FlagTextField where Value.BoxedValueType == String { + + init(configuration: FlagControlConfiguration) { + self.init(configuration: configuration, formatted: \.asString) + } + +} + +private extension FlagValue where BoxedValueType == String { + var asString: String { + get { + String(boxedFlagValue: boxedFlagValue) ?? "" + } + set { + self = Self(boxedFlagValue: .string(newValue)) ?? self + } + } +} + +protocol StringFlagTextFieldRepresentable { + @MainActor + func makeContent() -> any View +} + +extension FlagControlConfiguration: StringFlagTextFieldRepresentable where Value.BoxedValueType == String { + func makeContent() -> any View { + FlagTextField(configuration: self) + } +} + +extension FlagTextField { + + init(configuration: FlagControlConfiguration) where Value.BoxedValueType == String? { + self.init(configuration: configuration, formatted: \.asStringOrEmpty, placeholder: "nil") + } + +} + +private extension FlagValue where BoxedValueType == String? { + var asStringOrEmpty: String { + get { + String(boxedFlagValue: boxedFlagValue) ?? "" + } + set { + let boxedFlagValue = newValue.isEmpty ? BoxedFlagValue.none : .string(newValue) + self = Self(boxedFlagValue: boxedFlagValue) ?? self + } + } +} + +protocol OptionalStringFlagTextFieldRepresentable { + @MainActor + func makeContent() -> any View +} + +extension FlagControlConfiguration: OptionalStringFlagTextFieldRepresentable where Value.BoxedValueType == String? { + func makeContent() -> any View { + FlagTextField(configuration: self) + } +} diff --git a/Sources/Vexillographer/FlagControl/FlagTextField.swift b/Sources/Vexillographer/FlagControl/FlagTextField.swift new file mode 100644 index 00000000..4ac155a8 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagTextField.swift @@ -0,0 +1,104 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +// A text field +// - want to dismiss on scroll? +// - want to have confirm/cancel? +struct FlagTextField: View { + + private var name: String + @Binding + private var value: Value + private var placeholder: String + private var formatted: WritableKeyPath + private var format: (String) -> String + private var editingFormat: (String) -> String +#if os(iOS) || os(tvOS) + private var keyboardType = UIKeyboardType.default +#endif + + @State + private var cachedText: String? + + @FocusState + private var isFocused + + init( + configuration: FlagControlConfiguration, + formatted: WritableKeyPath, + placeholder: String = "", + format: @escaping (String) -> String = { $0 }, + editingFormat: @escaping (String) -> String = { $0 } + ) { + self.name = configuration.name + _value = configuration.$value + self.placeholder = placeholder + self.formatted = formatted + self.format = format + self.editingFormat = editingFormat + } + + var body: some View { + HStack { + Text(name) + .accessibilityHidden(true) + TextField(placeholder, text: text) + .multilineTextAlignment(.trailing) + .accessibilityLabel(name) + .submitLabel(.done) + .autocorrectionDisabled() + .textContentType(nil) +#if os(iOS) || os(tvOS) + .keyboardType(keyboardType) +#endif + } + .onChange(of: value.boxedFlagValue) { _ in + cachedText = nil + } + .onChange(of: cachedText) { newText in + guard let newText else { + return + } + cachedText = editingFormat(newText) + } + .onChange(of: isFocused) { isFocused in + guard isFocused == false, let cachedText else { + return + } + let newText = format(cachedText) + self.cachedText = newText + value[keyPath: formatted] = newText + } + .focused($isFocused) + } + + // Can this be computed key path? + var text: Binding { + Binding( + get: { cachedText ?? value[keyPath: formatted] }, + set: { cachedText = $0 } + ) + } + +#if os(iOS) || os(tvOS) + func keyboardType(_ type: UIKeyboardType) -> Self { + var copy = self + copy.keyboardType = type + return copy + } +#endif + +} diff --git a/Sources/Vexillographer/FlagControl/FlagToggle.swift b/Sources/Vexillographer/FlagControl/FlagToggle.swift new file mode 100644 index 00000000..e00c02b1 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/FlagToggle.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +// A toggle +public struct FlagToggle: View where Value.BoxedValueType == Bool { + + private var name: String + @Binding + private var value: Value + + public init(configuration: FlagControlConfiguration) { + self.name = configuration.name + _value = configuration.$value + } + + public var body: some View { + Toggle(name, isOn: $value.asBool) + } + +} + +private extension FlagValue where BoxedValueType == Bool { + + var asBool: Bool { + get { + Bool(boxedFlagValue: boxedFlagValue) ?? false + } + set { + self = Self(boxedFlagValue: .bool(newValue)) ?? self + } + } + +} + +protocol FlagToggleRepresentable { + @MainActor + func makeContent() -> any View +} + +extension FlagControlConfiguration: FlagToggleRepresentable where Value.BoxedValueType == Bool { + func makeContent() -> any View { + FlagToggle(configuration: self) + } +} diff --git a/Sources/Vexillographer/FlagControl/RowContent.swift b/Sources/Vexillographer/FlagControl/RowContent.swift new file mode 100644 index 00000000..730a4930 --- /dev/null +++ b/Sources/Vexillographer/FlagControl/RowContent.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI + +// UI helper +struct RowContent: View { + + var label: String + var content: Content + + init(_ label: String, @ViewBuilder content: () -> Content) { + self.label = label + self.content = content() + } + + init(_ label: String, value: some Any) where Content == Text { + self.label = label + self.content = Text(String(describing: value)) + } + + var body: some View { + HStack(spacing: 0) { + Text(label) + Spacer() + content + .foregroundStyle(.secondary) + } + } + +} diff --git a/Sources/Vexillographer/FlagDetailSection.swift b/Sources/Vexillographer/FlagDetailSection.swift deleted file mode 100644 index de53bb16..00000000 --- a/Sources/Vexillographer/FlagDetailSection.swift +++ /dev/null @@ -1,53 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI - -struct FlagDetailSection: View where Header: View, Content: View { - - private let header: Header - - private let content: Content - - init(header: Header, @ViewBuilder content: () -> Content) { - self.header = header - self.content = content() - } - -#if os(macOS) - - var body: some View { - GroupBox(label: header) { - VStack(alignment: .leading, spacing: 8) { - content - } - .padding(EdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)) - .frame(maxWidth: .infinity, alignment: .leading) - }.padding(.bottom, 8) - } - -#else - - var body: some View { - Section(header: header) { - content - } - } - -#endif - -} - -#endif diff --git a/Sources/Vexillographer/FlagDetailView.swift b/Sources/Vexillographer/FlagDetailView.swift deleted file mode 100644 index d5f0b662..00000000 --- a/Sources/Vexillographer/FlagDetailView.swift +++ /dev/null @@ -1,170 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct FlagDetailView: View where Value: FlagValue, RootGroup: FlagContainer { - - // MARK: - Properties - - let flag: UnfurledFlag - let isEditable: Bool - - @ObservedObject - var manager: FlagValueManager - - - // MARK: - Initialisation - - init(flag: UnfurledFlag, manager: FlagValueManager) { - self.flag = flag - self.manager = manager - self.isEditable = manager.isEditable - } - - - // MARK: - View Body - -#if os(iOS) - - var body: some View { - content - .navigationBarTitle(Text(flag.info.name), displayMode: .inline) - } - -#elseif os(macOS) - - var body: some View { - ScrollView { - content - } - .frame(minWidth: 300) - } - -#else - - var body: some View { - content - } - -#endif - - - var content: some View { - Form { - FlagDetailSection(header: Text("Flag Details")) { - flagKeyView - .contextMenu { - CopyButton(action: flag.info.key.copyToPasteboard) - } - - VStack(alignment: .leading) { - Text("Description:").font(.headline) - Text(flag.info.description) - } - .contextMenu { - CopyButton(action: flag.info.description.copyToPasteboard) - } - } - - if manager.source != nil { - FlagDetailSection(header: Text("Current Source")) { - HStack { - Text(manager.source!.flagValueSourceName) - .font(.headline) - Spacer() - description(source: manager.source!) - } - - Button(action: clearValue) { - Text("Clear Flag Value in Current Source") - } - .foregroundColor(.red) - .opacity(isCurrentSourceSet ? 1 : 0.3) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) - .disabled(isCurrentSourceSet == false) - .animation(.easeInOut, value: isCurrentSourceSet) - } - } - - FlagDetailSection(header: Text("FlagPole Source Hierarchy")) { - ForEach(manager.flagPole._sources, id: \.flagValueSourceName) { source in - HStack { - if (source as AnyObject) === (manager.source as AnyObject) { - Text(source.flagValueSourceName) - .font(.headline) - } else { - Text(source.flagValueSourceName) - } - Spacer() - description(source: source) - } - } - HStack { - Text("Default Value") - Spacer() - FlagDisplayValueView(value: flag.flag.defaultValue) - } - } - } - } - - func description(source: FlagValueSource) -> some View { - if let value = flagValue(source: source) { - FlagDisplayValueView(value: value).eraseToAnyView() - } else { - Text("not set").italic().eraseToAnyView() - } - } - - func flagValue(source: FlagValueSource) -> Value? { - source.flagValue(key: flag.flag.key) - } - - func clearValue() { - try? manager.source?.setFlagValue(Value?.none, key: flag.flag.key) // swiftlint:disable:this syntactic_sugar - } - - var isCurrentSourceSet: Bool { - guard let source = manager.source else { - return false - } - return flagValue(source: source) != nil - } - - private var flagKeyView: some View { -#if os(macOS) - - return VStack(alignment: .leading) { - Text("Key").font(.headline) - Text(flag.info.key) - } - -#else - - return HStack { - Text("Key").font(.headline) - Spacer() - Text(flag.info.key) - } - -#endif - } - -} - -#endif diff --git a/Sources/Vexillographer/FlagDisplayValueView.swift b/Sources/Vexillographer/FlagDisplayValueView.swift deleted file mode 100644 index 35d21621..00000000 --- a/Sources/Vexillographer/FlagDisplayValueView.swift +++ /dev/null @@ -1,72 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct FlagDisplayValueView: View where Value: FlagValue { - - // MARK: - Properties - - let value: Value - - var string: String? { - if let value = value as? OptionalFlagDisplayValue { - return value.flagDisplayValue - } - if let displayValue = value as? FlagDisplayValue { - return displayValue.flagDisplayValue - } - return String(describing: value) - } - - // MARK: - Body - - var body: some View { - Group { - if string != nil { - Text(string!) - .contextMenu { - CopyButton(action: string!.copyToPasteboard) - } - - } else { - Text("nil").foregroundColor(.red) - } - } - } - -} - -private protocol OptionalFlagDisplayValue { - var flagDisplayValue: String? { get } -} - -extension Optional: OptionalFlagDisplayValue where Wrapped: FlagValue { - var flagDisplayValue: String? { - guard let value = self else { - return nil - } - - if let displayValue = value as? FlagDisplayValue { - return displayValue.flagDisplayValue - } - - return String(describing: value) - } -} - -#endif diff --git a/Sources/Vexillographer/FlagGroupView.swift b/Sources/Vexillographer/FlagGroupView.swift deleted file mode 100644 index 53a2f880..00000000 --- a/Sources/Vexillographer/FlagGroupView.swift +++ /dev/null @@ -1,107 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagGroupView: View where Group: FlagContainer, Root: FlagContainer { - - // MARK: - Properties - - let group: UnfurledFlagGroup - @ObservedObject - var manager: FlagValueManager - - - // MARK: - Initialisation - - init(group: UnfurledFlagGroup, manager: FlagValueManager) { - self.group = group - self.manager = manager - } - - - // MARK: - View Body - -#if os(iOS) - - var body: some View { - Form { - Section { - description - } - .padding([.top, .bottom], 4) - flags - } - } - -#elseif os(macOS) && compiler(>=5.3.1) - - var body: some View { - ScrollView { - VStack(alignment: .leading) { - description - .padding(.bottom, 8) - Divider() - } - .padding() - - Form { - Section { - // Filter out all links. They won't work on the mac flag group view. - ForEach(group.allItems().filter { $0.isLink == false }, id: \.id) { item in - UnfurledFlagItemView(item: item) - } - } - } - .padding([.leading, .trailing, .bottom], 30) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) - } - .navigationTitle(group.info.name) - } - -#else - - var body: some View { - Form { - description - Section { - flags - } - } - } - -#endif - - var description: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Description").font(.headline) - Text(group.info.description) - } - .contextMenu { - CopyButton(action: group.info.description.copyToPasteboard) - } - } - - var flags: some View { - ForEach(group.allItems(), id: \.id) { item in - UnfurledFlagItemView(item: item) - } - } - -} - -#endif diff --git a/Sources/Vexillographer/FlagPole/FlagGroupItem.swift b/Sources/Vexillographer/FlagPole/FlagGroupItem.swift new file mode 100644 index 00000000..02722936 --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagGroupItem.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +struct FlagGroupItem: FlagPoleItemGroup { + + var group: FlagGroupWigwag + var items = [any FlagPoleItem]() + + init(_ group: FlagGroupWigwag) { + self.group = group + } + + var isHidden: Bool { + group.displayOption == .hidden || visibleItems.isEmpty + } + + var keyPath: FlagKeyPath { + group.keyPath + } + + var name: String { + group.name + } + + var visibleItems: [any FlagPoleItem] { + items.filter { $0.isHidden == false } + } + + func makeContent() -> any View { + switch group.displayOption { + case .navigation, nil: + NavigationLink(group.name) { + List { + if let description = group.description { + Section { + Text(description) + } + } + ForEach(visibleItems, id: \.keyPath, content: \.content) + } + } + + case .section: + Section(group.name) { + ForEach(visibleItems, id: \.keyPath, content: \.content) + } + + case .hidden: + EmptyView() + } + } + +} diff --git a/Sources/Vexillographer/FlagPole/FlagItem.swift b/Sources/Vexillographer/FlagPole/FlagItem.swift new file mode 100644 index 00000000..5a326a4f --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagItem.swift @@ -0,0 +1,141 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +struct FlagItem: FlagPoleItem { + + var flag: FlagWigwag + + init(_ flag: FlagWigwag) { + self.flag = flag + } + + var isHidden: Bool { + flag.displayOption == .hidden + } + + var keyPath: FlagKeyPath { + flag.keyPath + } + + var name: String { flag.name } + + func makeContent() -> any View { + FlagItemContent(wigwag: flag) + } + +} + +struct FlagItemContent: View { + + var wigwag: FlagWigwag + + @State + private var isShowingDetail = false + @FocusState + private var isFocused + + @Environment(\.flagPoleContext) + private var flagPoleContext + + var body: some View { + FlagControl(wigwag) { configuration in + HStack { + if let styledControl = flagPoleContext.styledControl(configuration: configuration) { + styledControl + } else if configuration.isEditable { + DefaultFlagControl(configuration: configuration) + } else { + FlagValueRow(configuration.name, value: configuration.value) + } + Button { + isFocused = false + isShowingDetail = true + } label: { + Label("Info", systemImage: "info.circle") + .imageScale(.large) + .labelStyle(.iconOnly) + .foregroundStyle(.tint) + .symbolVariant(configuration.hasValue ? .fill : .none) + } + .buttonStyle(.plain) + } + .focused($isFocused) +#if !os(tvOS) + .swipeActions(edge: .trailing) { + if configuration.hasValue { + Button { + configuration.resetValue() + } label: { + Label("Clear", systemImage: "trash.fill") + .imageScale(.large) + } + .tint(.red) + } + } +#endif + .sheet(isPresented: $isShowingDetail) { + NavigationView { + FlagDetailView(configuration: configuration) + } + } + } + } +} + +struct StyledFlagControl: View { + var configuration: FlagControlConfiguration + var style: any FlagControlStyle + + var body: some View { + AnyView(style.makeBody(configuration: configuration)) + } +} + +struct DefaultFlagControl: View { + var content: any View + + init(configuration: FlagControlConfiguration) { + switch configuration { + case let configuration as any FlagToggleRepresentable: + self.content = configuration.makeContent() + case let configuration as any OptionalBooleanFlagPickerRepresentable: + self.content = configuration.makeContent() + case let configuration as any CaseIterableFlagPickerRepresentable: + self.content = configuration.makeContent() + case let configuration as any OptionalCaseIterableFlagPickerRepresentable: + self.content = configuration.makeContent() + case let configuration as any IntegerFlagTextFieldRepresentable: + self.content = configuration.makeContent() + case let configuration as any OptionalIntegerFlagTextFieldRepresentable: + self.content = configuration.makeContent() + case let configuration as any FloatingPointTextFieldRepresentable: + self.content = configuration.makeContent() + case let configuration as any OptionalFloatingPointFlagTextFieldRepresentable: + self.content = configuration.makeContent() + case let configuration as any StringFlagTextFieldRepresentable: + self.content = configuration.makeContent() + case let configuration as any OptionalStringFlagTextFieldRepresentable: + self.content = configuration.makeContent() + default: + self.content = Text("Unimplemented \(configuration.name)").frame(maxWidth: .infinity) + } + } + + var body: some View { + AnyView(content) + } + +} diff --git a/Sources/Vexillographer/FlagPole/FlagPoleContext.swift b/Sources/Vexillographer/FlagPole/FlagPoleContext.swift new file mode 100644 index 00000000..15790f11 --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagPoleContext.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +struct FlagPoleContext { + + var items: [any FlagPoleItem] = [] + var editableSource: (any FlagValueSource)? + var sources: [any FlagValueSource] = [] + var keyPathByFlagKeyPath = [FlagKeyPath: AnyKeyPath]() + var styles = [AnyHashable: any FlagControlStyle]() + + func items(matching searchText: String) -> [any FlagPoleItem] { + items.flatMap { $0.items(matching: searchText) } + } + + @MainActor + func styledControl(configuration: FlagControlConfiguration) -> AnyView? { + if let keyPath = keyPathByFlagKeyPath[configuration.keyPath], let style = styles[keyPath] { + style.control(configuration: configuration) + } else if let style = styles[ObjectIdentifier(Value.self)] { + style.control(configuration: configuration) + } else { + nil + } + } + +} + +extension EnvironmentValues { + + @Entry + var flagPoleContext = FlagPoleContext() + +} diff --git a/Sources/Vexillographer/FlagPole/FlagPoleItem.swift b/Sources/Vexillographer/FlagPole/FlagPoleItem.swift new file mode 100644 index 00000000..f7b7effd --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagPoleItem.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +protocol FlagPoleItem { + + var keyPath: FlagKeyPath { get } + var name: String { get } + var isHidden: Bool { get } + @MainActor + func makeContent() -> any View + +} + +extension FlagPoleItem { + @MainActor + var content: AnyView { AnyView(makeContent()) } +} + +extension FlagPoleItem { + + func matches(searchText: String) -> Bool { + searchText.isEmpty || name.localizedStandardContains(searchText) || keyPath.key.localizedStandardContains(searchText) + } + + func items(matching searchText: String) -> [any FlagPoleItem] { + guard isHidden == false else { + return [] + } + if let group = self as? FlagPoleItemGroup { + return group.items.flatMap { $0.items(matching: searchText) } + } else if matches(searchText: searchText) { + return [self] + } else { + return [] + } + } + +} diff --git a/Sources/Vexillographer/FlagPole/FlagPoleItemGroup.swift b/Sources/Vexillographer/FlagPole/FlagPoleItemGroup.swift new file mode 100644 index 00000000..6fdf9868 --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagPoleItemGroup.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +protocol FlagPoleItemGroup: FlagPoleItem { + + var items: [any FlagPoleItem] { get set } + +} + + diff --git a/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift b/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift new file mode 100644 index 00000000..92588692 --- /dev/null +++ b/Sources/Vexillographer/FlagPole/FlagPoleVisitor.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +class FlagPoleVisitor: FlagVisitor { + + var lookup: any FlagLookup + var items = [any FlagPoleItem]() + var groupStack = [any FlagPoleItemGroup]() + var keyPathByFlagKeyPath = [FlagKeyPath: AnyKeyPath]() + + init(lookup: any FlagLookup) { + self.lookup = lookup + } + + func beginContainer(keyPath: FlagKeyPath, containerType: any FlagContainer.Type) { + let container = containerType.init(_flagKeyPath: keyPath, _flagLookup: lookup) + keyPathByFlagKeyPath.merge(container.keyPathByFlagKeyPath, uniquingKeysWith: { $1 }) + } + + func beginGroup(keyPath: FlagKeyPath, wigwag: () -> FlagGroupWigwag) { + groupStack.append(FlagGroupItem(wigwag())) + } + + func visitFlag( + keyPath: FlagKeyPath, + value: () -> Value?, + defaultValue: Value, + wigwag: () -> FlagWigwag + ) { + appendToGroupOrRoot(FlagItem(wigwag())) + } + + func endGroup(keyPath: FlagKeyPath) { + appendToGroupOrRoot(groupStack.removeLast()) + } + + private func appendToGroupOrRoot(_ newItem: any FlagPoleItem) { + if groupStack.last != nil { + groupStack[groupStack.count - 1].items.append(newItem) + } else { + items.append(newItem) + } + } + +} + +private extension FlagContainer { + + /// A map of type-erased key paths by flag key path. + var keyPathByFlagKeyPath: [FlagKeyPath: AnyKeyPath] { + Dictionary(uniqueKeysWithValues: _allFlagKeyPaths.map { ($0.value, $0.key) }) + } + +} diff --git a/Sources/Vexillographer/FlagSectionView.swift b/Sources/Vexillographer/FlagSectionView.swift deleted file mode 100644 index 1c1ff8f0..00000000 --- a/Sources/Vexillographer/FlagSectionView.swift +++ /dev/null @@ -1,77 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagSectionView: View where Group: FlagContainer, Root: FlagContainer { - - // MARK: - Properties - - let group: UnfurledFlagGroup - @ObservedObject - var manager: FlagValueManager - - - // MARK: - Initialisation - - init(group: UnfurledFlagGroup, manager: FlagValueManager) { - self.group = group - self.manager = manager - } - - - // MARK: - View Body - -#if os(macOS) - - var body: some View { - GroupBox( - label: Text(group.info.name), - content: { - VStack(alignment: .leading) { - Text(group.info.description) - Divider() - content - }.padding(4) - } - ) - .padding([.top, .bottom]) - } - -#else - - var body: some View { - Section( - header: Text(group.info.name), - footer: Text(group.info.description), - content: { - content - } - ) - } - -#endif - - private var content: some View { - ForEach(group.allItems(), id: \.id) { item in - UnfurledFlagItemView(item: item) - } - } - -} - -#endif diff --git a/Sources/Vexillographer/FlagValueManager.swift b/Sources/Vexillographer/FlagValueManager.swift deleted file mode 100644 index 90cdebbd..00000000 --- a/Sources/Vexillographer/FlagValueManager.swift +++ /dev/null @@ -1,113 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Combine -import Foundation -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -class FlagValueManager: ObservableObject where RootGroup: FlagContainer { - - // MARK: - Properties - - let flagPole: FlagPole - let source: FlagValueSource? - private var cancellables = Set() - - var isEditable: Bool { - source != nil - } - - - // MARK: - Initialisation - - init(flagPole: FlagPole, source: FlagValueSource?) { - self.flagPole = flagPole - self.source = source - - flagPole - .publisher - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - } - - - // MARK: - Flag Values - - func rawValue(key: String) -> Value? where Value: FlagValue { - source?.flagValue(key: key) - } - - func flagValue(key: String) -> Value? where Value: FlagValue { - let snapshot = flagPole.snapshot() - return snapshot.flagValue(key: key) - } - - func setFlagValue(_ value: (some FlagValue)?, key: String) throws { - guard let source else { - return - } - - let snapshot = flagPole.emptySnapshot() - try snapshot.setFlagValue(value, key: key) - try flagPole.save(snapshot: snapshot, to: source) - } - - func hasValueInSource(flag: Flag) -> Bool { - if let _: Value = source?.flagValue(key: flag.key) { - true - - } else { - false - } - } - - - // MARK: - Boxed Values - - func boxedValue(key: String, type: Value.Type) -> Value.BoxedValueType? where Value: FlagValue { - guard let value: Value = flagValue(key: key) else { - return nil - } - return value.unwrappedBoxedValue() - } - - func setBoxedValue(_ value: Value.BoxedValueType?, type: Value.Type, key: String) throws where Value: FlagValue { - let unboxed = value.flatMap(Value.init(unwrapped:)) - try setFlagValue(unboxed, key: key) - } - - - // MARK: - Displaying Flag Values - - func allItems() -> [UnfurledFlagItem] { - Mirror(reflecting: flagPole._rootGroup) - .children - .compactMap { child -> UnfurledFlagItem? in - guard let label = child.label, let unfurlable = child.value as? Unfurlable else { - return nil - } - let unfurled = unfurlable.unfurl(label: label, manager: self) - return unfurled - } - } - -} - -#endif diff --git a/Sources/Vexillographer/FlagView.swift b/Sources/Vexillographer/FlagView.swift deleted file mode 100644 index d60670fc..00000000 --- a/Sources/Vexillographer/FlagView.swift +++ /dev/null @@ -1,111 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagView: View where Value: FlagValue, RootGroup: FlagContainer { - - // MARK: - Properties - - let flag: UnfurledFlag - - @ObservedObject - var manager: FlagValueManager - - @State - private var showDetail = false - - // MARK: - Initialisation - - init(flag: UnfurledFlag, manager: FlagValueManager) { - self.flag = flag - self.manager = manager - } - - - // MARK: - View Body - - var body: some View { - content - .contextMenu { - Button("Show Details") { showDetail = true } - } - .sheet( - isPresented: $showDetail, - content: { - detailView - } - ) - } - - var content: some View { - - if let flag = flag as? BooleanEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - - } else if let flag = flag as? OptionalBooleanEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - - } else if let flag = flag as? CaseIterableEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - - } else if let flag = flag as? OptionalCaseIterableEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - - } else if let flag = flag as? StringEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - - } else if let flag = flag as? OptionalStringEditableFlag { - return flag.control(label: self.flag.info.flagValueSourceName, manager: manager, showDetail: $showDetail) - } - - return EmptyView().eraseToAnyView() - } - -#if os(iOS) - - var detailView: some View { - NavigationView { - FlagDetailView(flag: flag, manager: manager) - .navigationBarItems(trailing: detailDoneButton) - } - } - -#elseif os(macOS) - - var detailView: some View { - VStack { - FlagDetailView(flag: flag, manager: manager) - HStack { - Spacer() - detailDoneButton - } - } - .padding() - } - -#endif - - var detailDoneButton: some View { - Button("Close") { - showDetail = false - } - } - -} - -#endif diff --git a/Sources/Vexillographer/Unfurling/Unfurlable.swift b/Sources/Vexillographer/Unfurling/Unfurlable.swift deleted file mode 100644 index e9e1be2c..00000000 --- a/Sources/Vexillographer/Unfurling/Unfurlable.swift +++ /dev/null @@ -1,57 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import Vexil - -/// Describes a type that can "unfurl" itself. -/// -/// Basically this is used to provide the Flag and FlagGroups with a way to create a type-erased `UnfurledFlagItem` -/// that describes themelves. -/// -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol Unfurlable { - func unfurl(label: String, manager: FlagValueManager) -> UnfurledFlagItem? -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension Flag: Unfurlable where Value: FlagValue { - - /// Creates an `UnfurledFlag` from the receiver and returns it as a type-erased `UnfurledFlagItem` - /// - func unfurl(label: String, manager: FlagValueManager) -> UnfurledFlagItem? { - guard info.shouldDisplay == true else { - return nil - } - let unfurled = UnfurledFlag(name: info.flagValueSourceName ?? label.localizedDisplayName, flag: self, manager: manager) - return unfurled.isEditable ? unfurled : nil - } -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -extension FlagGroup: Unfurlable { - - /// Creates an `UnfurledFlagGroup` from the receiver and returns it as a type-erased `UnfurledFlagItem` - /// - func unfurl(label: String, manager: FlagValueManager) -> UnfurledFlagItem? { - guard info.shouldDisplay == true else { - return nil - } - let unfurled = UnfurledFlagGroup(name: info.flagValueSourceName ?? label.localizedDisplayName, group: self, manager: manager) - return unfurled.isEditable ? unfurled : nil - } -} - -#endif diff --git a/Sources/Vexillographer/Unfurling/UnfurledFlag.swift b/Sources/Vexillographer/Unfurling/UnfurledFlag.swift deleted file mode 100644 index ce57c603..00000000 --- a/Sources/Vexillographer/Unfurling/UnfurledFlag.swift +++ /dev/null @@ -1,69 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlag: UnfurledFlagItem, Identifiable where Value: FlagValue, RootGroup: FlagContainer { - - // MARK: - Properties - - let info: UnfurledFlagInfo - let flag: Flag - let hasChildren = false - - private let manager: FlagValueManager - - var id: UUID { - flag.id - } - - var isEditable: Bool { - self is BooleanEditableFlag - || self is CaseIterableEditableFlag - || self is StringEditableFlag - || self is OptionalBooleanEditableFlag - || self is OptionalCaseIterableEditableFlag - || self is OptionalStringEditableFlag - } - - var childLinks: [UnfurledFlagItem]? { - nil - } - - var isLink: Bool { - false - } - - // MARK: - Initialisation - - init(name: String, flag: Flag, manager: FlagValueManager) { - self.info = UnfurledFlagInfo(key: flag.key, info: flag.info, defaultName: name) - self.flag = flag - self.manager = manager - } - - - // MARK: - Unfurled Flag Item Conformance - - var unfurledView: AnyView { - AnyView(UnfurledFlagView(flag: self, manager: manager)) - } - -} - -#endif diff --git a/Sources/Vexillographer/Unfurling/UnfurledFlagGroup.swift b/Sources/Vexillographer/Unfurling/UnfurledFlagGroup.swift deleted file mode 100644 index c23b4e8d..00000000 --- a/Sources/Vexillographer/Unfurling/UnfurledFlagGroup.swift +++ /dev/null @@ -1,112 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagGroup: UnfurledFlagItem, Identifiable where Group: FlagContainer, Root: FlagContainer { - - // MARK: - Properties - - let info: UnfurledFlagInfo - let group: FlagGroup - let hasChildren = true - - private let manager: FlagValueManager - - var id: UUID { - group.id - } - - var isEditable: Bool { - allItems() - .isEmpty == false - } - - var isLink: Bool { - group.display == .navigation - } - - var childLinks: [UnfurledFlagItem]? { - let children = allItems().filter { $0.hasChildren == true && $0.isLink } - return children.isEmpty == false ? children : nil - } - - // MARK: - Initialisation - - init(name: String, group: FlagGroup, manager: FlagValueManager) { - self.info = UnfurledFlagInfo(key: "", info: group.info, defaultName: name) - self.group = group - self.manager = manager - } - - - // MARK: - Unfurled Flag Item Conformance - - func allItems() -> [UnfurledFlagItem] { - Mirror(reflecting: group.wrappedValue) - .children - .compactMap { child -> UnfurledFlagItem? in - guard let label = child.label, let unfurlable = child.value as? Unfurlable else { - return nil - } - guard let unfurled = unfurlable.unfurl(label: label, manager: manager) else { - return nil - } - return unfurled.isEditable ? unfurled : nil - } - } - - var unfurledView: AnyView { - switch group.display { - case .navigation: - unfurledNavigationLink - - case .section: - UnfurledFlagSectionView(group: self, manager: manager) - .eraseToAnyView() - } - } - - private var unfurledNavigationLink: AnyView { - var destination = UnfurledFlagGroupView(group: self, manager: manager).eraseToAnyView() - -#if os(iOS) - - destination = destination - .navigationBarTitle(Text(info.flagValueSourceName), displayMode: .inline) - .eraseToAnyView() - -#elseif compiler(>=5.3.1) - - destination = destination - .navigationTitle(info.flagValueSourceName) - .eraseToAnyView() - -#endif - - return NavigationLink(destination: destination) { - HStack { - Text(info.flagValueSourceName) - .font(.headline) - } - }.eraseToAnyView() - } - -} - -#endif diff --git a/Sources/Vexillographer/Unfurling/UnfurledFlagInfo.swift b/Sources/Vexillographer/Unfurling/UnfurledFlagInfo.swift deleted file mode 100644 index 447d57e2..00000000 --- a/Sources/Vexillographer/Unfurling/UnfurledFlagInfo.swift +++ /dev/null @@ -1,42 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagInfo { - - // MARK: - Properties - - /// The flag's key - let key: String - - /// The name of the unfurled flag or flag group - let name: String - - /// A brief description of the unfurled flag or flag group - let description: String - - - // MARK: - Initialisation - - init(key: String, info: FlagInfo, defaultName: String) { - self.key = key - self.name = info.flagValueSourceName ?? defaultName - self.description = info.description - } -} - -#endif diff --git a/Sources/Vexillographer/Unfurling/UnfurledFlagItem.swift b/Sources/Vexillographer/Unfurling/UnfurledFlagItem.swift deleted file mode 100644 index 4f5cdd67..00000000 --- a/Sources/Vexillographer/Unfurling/UnfurledFlagItem.swift +++ /dev/null @@ -1,40 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation -import SwiftUI -import Vexil - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -protocol UnfurledFlagItem { - var id: UUID { get } - var info: UnfurledFlagInfo { get } - var hasChildren: Bool { get } - var childLinks: [UnfurledFlagItem]? { get } - var unfurledView: AnyView { get } - var isEditable: Bool { get } - var isLink: Bool { get } -} - -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -struct UnfurledFlagItemView: View { - var item: UnfurledFlagItem - - var body: some View { - item.unfurledView.id(item.id) - } -} - -#endif diff --git a/Sources/Vexillographer/Utilities/DisplayName.swift b/Sources/Vexillographer/Utilities/DisplayName.swift deleted file mode 100644 index c6cb5b70..00000000 --- a/Sources/Vexillographer/Utilities/DisplayName.swift +++ /dev/null @@ -1,100 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import Foundation - -extension String { - var localizedDisplayName: String { - displayName(with: Locale.autoupdatingCurrent) - } - - var displayName: String { - self.displayName(with: nil) - } - - func displayName(with locale: Locale?) -> String { - let uppercased = CharacterSet.uppercaseLetters - return (hasPrefix("_") ? String(dropFirst()) : self) - .separatedAtWordBoundaries - .map { CharacterSet(charactersIn: $0).isStrictSubset(of: uppercased) ? $0 : $0.capitalized(with: locale) } - .joined(separator: " ") - } - - /// Separates a string at word boundaries, eg. `oneTwoThree` becomes `one Two Three` - /// - /// Capital characters are determined by testing membership in `CharacterSet.uppercaseLetters` - /// and `CharacterSet.lowercaseLetters` (Unicode General Categories Lu and Lt). - /// The conversion to lower case uses `Locale.system`, also known as the ICU "root" locale. This means - /// the result is consistent regardless of the current user's locale and language preferences. - /// - /// Adapted from JSONEncoder's `toSnakeCase()` - /// - var separatedAtWordBoundaries: [String] { - guard !isEmpty else { - return [] - } - - let string = self - - var words: [Range] = [] - // The general idea of this algorithm is to split words on transition from lower to upper case, then on - // transition of >1 upper case characters to lowercase - // - // myProperty -> my_property - // myURLProperty -> my_url_property - // - // We assume, per Swift naming conventions, that the first character of the key is lowercase. - var wordStart = string.startIndex - var searchRange = string.index(after: wordStart) ..< string.endIndex - - let uppercase = CharacterSet.uppercaseLetters.union(CharacterSet.decimalDigits) - - // Find next uppercase character - while let upperCaseRange = string.rangeOfCharacter(from: uppercase, options: [], range: searchRange) { - let untilUpperCase = wordStart ..< upperCaseRange.lowerBound - words.append(untilUpperCase) - - // Find next lowercase character - searchRange = upperCaseRange.lowerBound ..< searchRange.upperBound - guard let lowerCaseRange = string.rangeOfCharacter(from: CharacterSet.lowercaseLetters, options: [], range: searchRange) else { - // There are no more lower case letters. Just end here. - wordStart = searchRange.lowerBound - break - } - - // Is the next lowercase letter more than 1 after the uppercase? If so, we encountered a group of uppercase - // letters that we should treat as its own word - let nextCharacterAfterCapital = string.index(after: upperCaseRange.lowerBound) - if lowerCaseRange.lowerBound == nextCharacterAfterCapital { - // The next character after capital is a lower case character and therefore not a word boundary. - // Continue searching for the next upper case for the boundary. - wordStart = upperCaseRange.lowerBound - } else { - // There was a range of >1 capital letters. Turn those into a word, stopping at the capital before the lower case character. - let beforeLowerIndex = string.index(before: lowerCaseRange.lowerBound) - words.append(upperCaseRange.lowerBound ..< beforeLowerIndex) - - // Next word starts at the capital before the lowercase we just found - wordStart = beforeLowerIndex - } - searchRange = lowerCaseRange.upperBound ..< searchRange.upperBound - } - words.append(wordStart ..< searchRange.upperBound) - - return words.map { string[$0].lowercased() } - } -} - -#endif diff --git a/Sources/Vexillographer/Utilities/OptionalFlagValues.swift b/Sources/Vexillographer/Utilities/OptionalFlagValues.swift deleted file mode 100644 index ace14a5b..00000000 --- a/Sources/Vexillographer/Utilities/OptionalFlagValues.swift +++ /dev/null @@ -1,44 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Vexil open source project -// -// Copyright (c) 2025 Unsigned Apps and the open source contributors. -// Licensed under the MIT license -// -// See LICENSE for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -#if os(iOS) || os(macOS) - -import SwiftUI -import Vexil - -protocol OptionalFlagValue { - associatedtype WrappedFlagValue: FlagValue - - var wrapped: WrappedFlagValue? { get set } - - init(_ wrapped: WrappedFlagValue?) -} - -extension Optional: OptionalFlagValue where Wrapped: FlagValue { - typealias WrappedFlagValue = Wrapped - - var wrapped: Wrapped? { - get { - self - } - set { - self = newValue - } - } - - init(_ wrapped: Wrapped?) { - self = wrapped - } -} - -#endif diff --git a/Sources/Vexillographer/Utilities/Pasteboard.swift b/Sources/Vexillographer/Utilities/OptionalProtocol.swift similarity index 60% rename from Sources/Vexillographer/Utilities/Pasteboard.swift rename to Sources/Vexillographer/Utilities/OptionalProtocol.swift index 540889ce..b8838e82 100644 --- a/Sources/Vexillographer/Utilities/Pasteboard.swift +++ b/Sources/Vexillographer/Utilities/OptionalProtocol.swift @@ -11,24 +11,15 @@ // //===----------------------------------------------------------------------===// -#if os(iOS) - -import UIKit - -extension String { - func copyToPasteboard() { - UIPasteboard.general.string = self - } +// Is this still needed +protocol OptionalProtocol { + associatedtype Wrapped + var wrapped: Wrapped? { get set } } -#elseif os(macOS) - -import Cocoa - -extension String { - func copyToPasteboard() { - NSPasteboard.general.setString(self, forType: .string) +extension Optional: OptionalProtocol { + var wrapped: Wrapped? { + get { self } + set { self = newValue } } } - -#endif diff --git a/Sources/Vexillographer/Vexillographer.swift b/Sources/Vexillographer/Vexillographer.swift index f247bfc2..78432715 100644 --- a/Sources/Vexillographer/Vexillographer.swift +++ b/Sources/Vexillographer/Vexillographer.swift @@ -11,84 +11,45 @@ // //===----------------------------------------------------------------------===// -#if os(iOS) || os(macOS) - import SwiftUI import Vexil -#if os(macOS) && compiler(>=5.3.1) - -/// A SwiftUI View that allows you to easily edit the flag -/// structure in a provided FlagValueSource. -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -public struct Vexillographer: View where RootGroup: FlagContainer { +public struct Vexillographer: View { - // MARK: - Properties - - @ObservedObject - var manager: FlagValueManager + @State + private var searchText = "" - // MARK: - Initialisation + public init() {} - /// Initialises a new `Vexillographer` instance with the provided FlagPole and source - /// - /// - Parameters; - /// - flagPole: A `FlagPole` instance manages the flag and source hierarchy we want to display - /// - source: An optional `FlagValueSource` for editing the flag values in. If `nil` the flag values are displayed read-only - /// - public init(flagPole: FlagPole, source: FlagValueSource?) { - self._manager = ObservedObject(wrappedValue: FlagValueManager(flagPole: flagPole, source: source)) + public var body: some View { + FlagList(searchText: searchText) + // Want to make this opt in? + .searchable(text: $searchText) } - // MARK: - Body +} - public var body: some View { - List(manager.allItems(), id: \.id, children: \.childLinks) { item in - UnfurledFlagItemView(item: item) - } - .listStyle(SidebarListStyle()) - .toolbar { - ToolbarItem(placement: .navigation) { - Button(action: NSApp.toggleKeyWindowSidebar) { - Image(systemName: "sidebar.left") - } +private struct FlagList: View { + + var searchText: String + @Environment(\.flagPoleContext) + private var flagPoleContext + @Environment(\.isSearching) + private var isSearching + + var body: some View { + List { + if isSearching { + let searchResult = flagPoleContext.items(matching: searchText) + ForEach(searchResult, id: \.keyPath, content: \.content) + } else { + let visibleItems = flagPoleContext.items.filter { $0.isHidden == false } + ForEach(visibleItems, id: \.keyPath, content: \.content) } } - } -} - -#else - -/// A SwiftUI View that allows you to easily edit the flag -/// structure in a provided FlagValueSource. -@available(OSX 11.0, iOS 13.0, watchOS 7.0, tvOS 13.0, *) -public struct Vexillographer: View where RootGroup: FlagContainer { - - // MARK: - Properties - - @State - var manager: FlagValueManager - - // MARK: - Initialisation - - /// Initialises a new `Vexillographer` instance with the provided FlagPole and source - /// - /// - Parameters; - /// - flagPole: A `FlagPole` instance manages the flag and source hierarchy we want to display - /// - source: An optional `FlagValueSource` for editing the flag values in. If `nil` the flag values are displayed read-only - /// - public init(flagPole: FlagPole, source: FlagValueSource?) { - self._manager = State(wrappedValue: FlagValueManager(flagPole: flagPole, source: source)) +#if os(iOS) + .listStyle(.insetGrouped) +#endif } - public var body: some View { - ForEach(manager.allItems(), id: \.id) { item in - UnfurledFlagItemView(item: item) - } - .environmentObject(manager) - } } - -#endif - -#endif diff --git a/Sources/Vexillographer/View+FlagControlStyle.swift b/Sources/Vexillographer/View+FlagControlStyle.swift new file mode 100644 index 00000000..3fade9c6 --- /dev/null +++ b/Sources/Vexillographer/View+FlagControlStyle.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +public protocol FlagControlStyle: DynamicProperty { + + associatedtype Value: FlagValue + associatedtype Body: View + + typealias Configuration = FlagControlConfiguration + + @ViewBuilder @MainActor + func makeBody(configuration: Configuration) -> Body + +} + +public extension View { + + func flagControlStyle(_ style: Style) -> some View { + modifier(FlagControlStyleModifier(style: style, key: ObjectIdentifier(Style.Value.self))) + } + + func flagControlStyle(_ style: Style, for keyPath: KeyPath) -> some View { + modifier(FlagControlStyleModifier(style: style, key: keyPath)) + } + +} + +private struct FlagControlStyleModifier: ViewModifier { + + var style: Style + var key: AnyHashable + + func body(content: Content) -> some View { + content + .transformEnvironment(\.flagPoleContext) { + $0.styles[key] = style + } + } + +} + +extension FlagControlStyle { + + @MainActor + func control(configuration: Configuration) -> AnyView? { + (configuration as? Configuration).map { AnyView(StyledFlagControl(configuration: $0, style: self)) } + } + +} diff --git a/Sources/Vexillographer/View+FlagPole.swift b/Sources/Vexillographer/View+FlagPole.swift new file mode 100644 index 00000000..68b206a5 --- /dev/null +++ b/Sources/Vexillographer/View+FlagPole.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Vexil open source project +// +// Copyright (c) 2025 Unsigned Apps and the open source contributors. +// Licensed under the MIT license +// +// See LICENSE for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import SwiftUI +import Vexil + +public extension View { + + func flagPole( + _ flagPole: FlagPole, + editableSource: (any FlagValueSource)? = nil + ) -> some View { + modifier(FlagPoleModifier(flagPole: flagPole, editableSource: editableSource)) + } + +} + +private struct FlagPoleModifier: ViewModifier { + var flagPole: FlagPole + var editableSource: (any FlagValueSource)? + + func body(content: Content) -> some View { + // TODO: Can cache this. + let visitor = FlagPoleVisitor(lookup: flagPole) + flagPole.walk(visitor: visitor) + return content + .transformEnvironment(\.flagPoleContext) { + $0.items = visitor.items + $0.keyPathByFlagKeyPath = visitor.keyPathByFlagKeyPath + $0.editableSource = editableSource + $0.sources = flagPole._sources + } + } +}