From 1d8e4020b289913a60204d50ae18df0c232d402b Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Tue, 10 Sep 2024 20:49:12 -0400 Subject: [PATCH] v1.0.0-alpha.1 --- .github/workflows/RadiantKit.yml | 141 ++++++ .github/workflows/codeql.yml | 78 ++++ .gitignore | 146 ++++++ .periphery.yml | 1 + .spi.yml | 5 + .swift-format | 70 +++ LICENSE | 21 + Mintfile | 2 + Package.resolved | 33 ++ Package.swift | 86 ++++ README.md | 18 + Scripts/gh-md-toc | 421 ++++++++++++++++++ Scripts/header.sh | 98 ++++ Scripts/lint.sh | 50 +++ .../Actions/OpenFileURLAction.swift | 65 +++ .../Actions/OpenWindowWithAction.swift | 48 ++ .../Actions/OpenWindowWithValueAction.swift | 55 +++ .../RadiantDocs/AllowedOpenFileTypesKey.swift | 61 +++ Sources/RadiantDocs/AppKit/NewFilePanel.swift | 67 +++ .../RadiantDocs/AppKit/OpenAnyFilePanel.swift | 68 +++ .../RadiantDocs/AppKit/OpenFilePanel.swift | 65 +++ .../RadiantDocs/CodablePackageDocument.swift | 79 ++++ .../Primitives/CodablePackage.swift | 44 ++ .../RadiantDocs/Primitives/DocumentFile.swift | 45 ++ Sources/RadiantDocs/Primitives/FileType.swift | 73 +++ .../Primitives/FileTypeSpecification.swift | 32 ++ .../InitializableFileTypeSpecification.swift | 35 ++ .../Primitives/InitializablePackage.swift | 51 +++ Sources/RadiantDocs/UTType.swift | 69 +++ .../AppKit/NSWindowAdaptorModifier.swift | 82 ++++ .../AppKit/View+NSWindowDelegate.swift | 69 +++ Sources/RadiantKit/Binding.swift | 47 ++ Sources/RadiantKit/Color.swift | 59 +++ .../AppStorage/AppStorage+AppStored.swift | 101 +++++ .../AppStorage+DefaultWrapped.swift | 93 ++++ .../AppStorage+ExpressibleByNilLiteral.swift | 79 ++++ .../PropertyWrappers/AppStored.swift | 49 ++ .../PropertyWrappers/DefaultWrapped.swift | 32 ++ .../RadiantKit/PropertyWrappers/KeyType.swift | 35 ++ .../RadiantKit/TransformedValueObject.swift | 85 ++++ .../ViewExtensions/DefaultableViewValue.swift | 32 ++ .../ViewExtensions/IdentifiableView.swift | 49 ++ .../IdentifiableViewBuilder.swift | 49 ++ .../ViewExtensions/SingleWindowView.swift | 62 +++ .../ViewExtensions/View+GeometryProxy.swift | 42 ++ .../ViewExtensions/View+Hidden.swift | 46 ++ Sources/RadiantKit/Views/Button.swift | 41 ++ .../Views/GuidedLabeledContent.swift | 69 +++ .../GuidedLabeledContentDescriptionView.swift | 84 ++++ .../Views/PreferredLayoutView.swift | 100 +++++ .../RadiantKit/Views/SliderStepperView.swift | 72 +++ .../RadiantKit/Views/ValueTextBubble.swift | 90 ++++ .../RadiantKit/Views/VerticalLabelStyle.swift | 69 +++ Sources/RadiantKit/Views/Video.swift | 53 +++ Sources/RadiantPaging/CancelPageAction.swift | 46 ++ Sources/RadiantPaging/ContainerView.swift | 100 +++++ Sources/RadiantPaging/DismissParameters.swift | 48 ++ Sources/RadiantPaging/NextPageAction.swift | 57 +++ .../PageNavigationAvailability.swift | 61 +++ Sources/RadiantPaging/PageView.swift | 138 ++++++ .../RadiantPaging/PreviousPageAction.swift | 46 ++ Sources/RadiantProgress/CopyOperation.swift | 139 ++++++ Sources/RadiantProgress/CopyPaths.swift | 35 ++ .../RadiantProgress/DownloadOperation.swift | 87 ++++ Sources/RadiantProgress/DownloadUpdate.swift | 33 ++ Sources/RadiantProgress/Downloader.swift | 44 ++ .../FileOperationProgress.swift | 50 +++ .../ObservableDownloader.swift | 234 ++++++++++ .../RadiantProgress/PreviewOperation.swift | 52 +++ .../RadiantProgress/ProgressOperation.swift | 55 +++ .../ProgressOperationProperties.swift | 66 +++ .../ProgressOperationView.swift | 75 ++++ Sources/RadiantProgress/SetupPublishers.swift | 96 ++++ Tests/RadiantKitTests/RadiantKitTests.swift | 16 + codecov.yml | 2 + macros.json | 7 + project.yml | 13 + 77 files changed, 5116 insertions(+) create mode 100644 .github/workflows/RadiantKit.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .gitignore create mode 100644 .periphery.yml create mode 100644 .spi.yml create mode 100644 .swift-format create mode 100644 LICENSE create mode 100644 Mintfile create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100755 Scripts/gh-md-toc create mode 100755 Scripts/header.sh create mode 100755 Scripts/lint.sh create mode 100644 Sources/RadiantDocs/Actions/OpenFileURLAction.swift create mode 100644 Sources/RadiantDocs/Actions/OpenWindowWithAction.swift create mode 100644 Sources/RadiantDocs/Actions/OpenWindowWithValueAction.swift create mode 100644 Sources/RadiantDocs/AllowedOpenFileTypesKey.swift create mode 100644 Sources/RadiantDocs/AppKit/NewFilePanel.swift create mode 100644 Sources/RadiantDocs/AppKit/OpenAnyFilePanel.swift create mode 100644 Sources/RadiantDocs/AppKit/OpenFilePanel.swift create mode 100644 Sources/RadiantDocs/CodablePackageDocument.swift create mode 100644 Sources/RadiantDocs/Primitives/CodablePackage.swift create mode 100644 Sources/RadiantDocs/Primitives/DocumentFile.swift create mode 100644 Sources/RadiantDocs/Primitives/FileType.swift create mode 100644 Sources/RadiantDocs/Primitives/FileTypeSpecification.swift create mode 100644 Sources/RadiantDocs/Primitives/InitializableFileTypeSpecification.swift create mode 100644 Sources/RadiantDocs/Primitives/InitializablePackage.swift create mode 100644 Sources/RadiantDocs/UTType.swift create mode 100644 Sources/RadiantKit/AppKit/NSWindowAdaptorModifier.swift create mode 100644 Sources/RadiantKit/AppKit/View+NSWindowDelegate.swift create mode 100644 Sources/RadiantKit/Binding.swift create mode 100644 Sources/RadiantKit/Color.swift create mode 100644 Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+AppStored.swift create mode 100644 Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+DefaultWrapped.swift create mode 100644 Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+ExpressibleByNilLiteral.swift create mode 100644 Sources/RadiantKit/PropertyWrappers/AppStored.swift create mode 100644 Sources/RadiantKit/PropertyWrappers/DefaultWrapped.swift create mode 100644 Sources/RadiantKit/PropertyWrappers/KeyType.swift create mode 100644 Sources/RadiantKit/TransformedValueObject.swift create mode 100644 Sources/RadiantKit/ViewExtensions/DefaultableViewValue.swift create mode 100644 Sources/RadiantKit/ViewExtensions/IdentifiableView.swift create mode 100644 Sources/RadiantKit/ViewExtensions/IdentifiableViewBuilder.swift create mode 100644 Sources/RadiantKit/ViewExtensions/SingleWindowView.swift create mode 100644 Sources/RadiantKit/ViewExtensions/View+GeometryProxy.swift create mode 100644 Sources/RadiantKit/ViewExtensions/View+Hidden.swift create mode 100644 Sources/RadiantKit/Views/Button.swift create mode 100644 Sources/RadiantKit/Views/GuidedLabeledContent.swift create mode 100644 Sources/RadiantKit/Views/GuidedLabeledContentDescriptionView.swift create mode 100644 Sources/RadiantKit/Views/PreferredLayoutView.swift create mode 100644 Sources/RadiantKit/Views/SliderStepperView.swift create mode 100644 Sources/RadiantKit/Views/ValueTextBubble.swift create mode 100644 Sources/RadiantKit/Views/VerticalLabelStyle.swift create mode 100644 Sources/RadiantKit/Views/Video.swift create mode 100644 Sources/RadiantPaging/CancelPageAction.swift create mode 100644 Sources/RadiantPaging/ContainerView.swift create mode 100644 Sources/RadiantPaging/DismissParameters.swift create mode 100644 Sources/RadiantPaging/NextPageAction.swift create mode 100644 Sources/RadiantPaging/PageNavigationAvailability.swift create mode 100644 Sources/RadiantPaging/PageView.swift create mode 100644 Sources/RadiantPaging/PreviousPageAction.swift create mode 100644 Sources/RadiantProgress/CopyOperation.swift create mode 100644 Sources/RadiantProgress/CopyPaths.swift create mode 100644 Sources/RadiantProgress/DownloadOperation.swift create mode 100644 Sources/RadiantProgress/DownloadUpdate.swift create mode 100644 Sources/RadiantProgress/Downloader.swift create mode 100644 Sources/RadiantProgress/FileOperationProgress.swift create mode 100644 Sources/RadiantProgress/ObservableDownloader.swift create mode 100644 Sources/RadiantProgress/PreviewOperation.swift create mode 100644 Sources/RadiantProgress/ProgressOperation.swift create mode 100644 Sources/RadiantProgress/ProgressOperationProperties.swift create mode 100644 Sources/RadiantProgress/ProgressOperationView.swift create mode 100644 Sources/RadiantProgress/SetupPublishers.swift create mode 100644 Tests/RadiantKitTests/RadiantKitTests.swift create mode 100644 codecov.yml create mode 100644 macros.json create mode 100644 project.yml diff --git a/.github/workflows/RadiantKit.yml b/.github/workflows/RadiantKit.yml new file mode 100644 index 0000000..57e43e1 --- /dev/null +++ b/.github/workflows/RadiantKit.yml @@ -0,0 +1,141 @@ +name: RadiantKit +on: + push: + branches-ignore: + - '*WIP' +env: + PACKAGE_NAME: RadiantKit +jobs: + build-ubuntu: + name: Build on Ubuntu + env: + SWIFT_VER: 6.0 + if: "!contains(github.event.head_commit.message, 'ci skip')" + runs-on: ubuntu-latest + container: + image: swiftlang/swift:nightly-6.0-jammy + steps: + - uses: actions/checkout@v4 + - name: Cache swift package modules + id: cache-spm-linux + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.swift-version }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.swift-version }}- + ${{ runner.os }}-${{ env.cache-name }}- + - name: Test + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + flags: swift-${{ matrix.swift-version }},ubuntu + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-macos: + name: Build on macOS + runs-on: ${{ matrix.os }} + if: "!contains(github.event.head_commit.message, 'ci skip')" + strategy: + matrix: + include: + - xcode: "/Applications/Xcode_16.1.app" + os: macos-14 + iOSVersion: "18.1" + watchOSVersion: "11.0" + watchName: "Apple Watch Series 9 (41mm)" + iPhoneName: "iPhone 15" + steps: + - uses: actions/checkout@v4 + - name: Cache swift package modules + id: cache-spm-macos + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ matrix.os }}-build-${{ env.cache-name }}-${{ matrix.xcode }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ matrix.os }}-build-${{ env.cache-name }}-${{ matrix.xcode }}- + - name: Cache mint + if: startsWith(matrix.xcode,'/Applications/Xcode_16.1') + id: cache-mint + uses: actions/cache@v4 + env: + cache-name: cache-mint + with: + path: .mint + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Mintfile') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Set Xcode Name + run: echo "XCODE_NAME=$(basename -- ${{ matrix.xcode }} | sed 's/\.[^.]*$//' | cut -d'_' -f2)" >> $GITHUB_ENV + - name: Setup Xcode + run: sudo xcode-select -s ${{ matrix.xcode }}/Contents/Developer || (sudo ls -1 /Applications | grep "Xcode") + - name: Enable Swift Testing + run: | + mkdir -p ~/Library/org.swift.swiftpm/security/ + cp macros.json ~/Library/org.swift.swiftpm/security/ + - name: Install mint + if: startsWith(matrix.xcode,'/Applications/Xcode_16.1') + run: | + brew update + brew install mint + - name: Build + run: swift build + - name: Run Swift Package tests + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-spm + with: + fail-on-empty-output: true + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + files: ${{ join(fromJSON(steps.coverage-files-spm.outputs.files), ',') }} + token: ${{ secrets.CODECOV_TOKEN }} + flags: macOS,${{ env.XCODE_NAME }},${{ matrix.runs-on }} + - name: Clean up spm build directory + run: rm -rf .build + - name: Lint + run: ./scripts/lint.sh + if: startsWith(matrix.xcode,'/Applications/Xcode_16.1') + - name: Run iOS target tests + run: xcodebuild test -scheme ${{ env.PACKAGE_NAME }}-Package -destination 'platform=iOS Simulator,name=${{ matrix.iPhoneName }},OS=${{ matrix.iOSVersion }}' -enableCodeCoverage YES build test + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-iOS + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files-iOS.outputs.files), ',') }} + flags: iOS,iOS${{ matrix.iOSVersion }},macOS,${{ env.XCODE_NAME }} + - name: Run watchOS target tests + run: xcodebuild test -scheme ${{ env.PACKAGE_NAME }}-Package -destination 'platform=watchOS Simulator,name=${{ matrix.watchName }},OS=${{ matrix.watchOSVersion }}' -enableCodeCoverage YES build test + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-watchOS + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files-watchOS.outputs.files), ',') }} + flags: watchOS,watchOS${{ matrix.watchOSVersion }},macOS,${{ env.XCODE_NAME }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..92b69d6 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,78 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches-ignore: + - '*WIP' + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: '20 11 * * 3' + +jobs: + analyze: + if: false + name: Analyze + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-13') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'swift' ] + # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] + # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Xcode + run: sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - run: | + echo "Run, Build Application using script" + swift build + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..424ea0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,146 @@ +# Created by https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=swift,swiftpm,swiftpackagemanager,xcode,macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +*.xcodeproj +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm + +.build/ + +# CocoaPods +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# Pods/ +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +### SwiftPackageManager ### +# Packages +xcuserdata +*.xcodeproj + + +### SwiftPM ### + + +### Xcode ### + +## Xcode 8 and earlier + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings +.mint +# End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos + +test_output.log +.docc-build \ No newline at end of file diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..85b884a --- /dev/null +++ b/.periphery.yml @@ -0,0 +1 @@ +retain_public: true diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..d6334bf --- /dev/null +++ b/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +builder: + configs: + - documentation_targets: [RadiantKit] + swift_version: 6.0 diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..4f562bf --- /dev/null +++ b/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "fileprivate" + }, + "indentation" : { + "spaces" : 2 + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : true, + "lineBreakAroundMultilineExpressionChainComponents" : true, + "lineBreakBeforeControlFlowKeywords" : true, + "lineBreakBeforeEachArgument" : true, + "lineBreakBeforeEachGenericRequirement" : true, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : false, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : true, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : true, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : true, + "ValidateDocumentationComments" : true + }, + "spacesAroundRangeFormationOperators" : false, + "tabWidth" : 2, + "version" : 1 +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..575c376 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 BrightDigit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Mintfile b/Mintfile new file mode 100644 index 0000000..7060932 --- /dev/null +++ b/Mintfile @@ -0,0 +1,2 @@ +apple/swift-format@4b62459 +peripheryapp/periphery@2.20.0 \ No newline at end of file diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..d4ee4e8 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "0cd2dcc1e220681712709df5bd794ab7f349d718bc63840353fae6e361bc50c4", + "pins" : [ + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "515f79b522918f83483068d99c68daeb5116342d", + "version" : "600.0.0-prerelease-2024-09-04" + } + }, + { + "identity" : "swift-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-testing.git", + "state" : { + "revision" : "c55848b2aa4b29a4df542b235dfdd792a6fbe341", + "version" : "0.12.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..01e26a4 --- /dev/null +++ b/Package.swift @@ -0,0 +1,86 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let swiftSettings: [SwiftSetting] = [ + SwiftSetting.enableExperimentalFeature("AccessLevelOnImport"), + SwiftSetting.enableExperimentalFeature("BitwiseCopyable"), + SwiftSetting.enableExperimentalFeature("GlobalActorIsolatedTypesUsability"), + SwiftSetting.enableExperimentalFeature("IsolatedAny"), + SwiftSetting.enableExperimentalFeature("MoveOnlyPartialConsumption"), + SwiftSetting.enableExperimentalFeature("NestedProtocols"), + SwiftSetting.enableExperimentalFeature("NoncopyableGenerics"), + SwiftSetting.enableExperimentalFeature("RegionBasedIsolation"), + SwiftSetting.enableExperimentalFeature("TransferringArgsAndResults"), + SwiftSetting.enableExperimentalFeature("VariadicGenerics"), + + SwiftSetting.enableUpcomingFeature("FullTypedThrows"), + SwiftSetting.enableUpcomingFeature("InternalImportsByDefault"), + + // SwiftSetting.unsafeFlags([ + // "-Xfrontend", + // "-warn-long-function-bodies=100" + // ]), + // SwiftSetting.unsafeFlags([ + // "-Xfrontend", + // "-warn-long-expression-type-checking=100" + // ]) +] + +let package = Package( + name: "RadiantKit", + platforms: [.iOS(.v17), .macCatalyst(.v17), .macOS(.v14), .tvOS(.v17), .visionOS(.v1), .watchOS(.v10)], + products: [ + .library( + name: "RadiantKit", + targets: ["RadiantKit"] + ), + .library( + name: "RadiantDocs", + targets: ["RadiantDocs"] + ), + .library( + name: "RadiantPaging", + targets: ["RadiantPaging"] + ), + .library( + name: "RadiantProgress", + targets: ["RadiantProgress"] + ) + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-testing.git", from: "0.12.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + ], + targets: [ + .target( + name: "RadiantKit", + swiftSettings: swiftSettings + ), + .target( + name: "RadiantDocs", + dependencies: ["RadiantKit"], + swiftSettings: swiftSettings + ), + .target( + name: "RadiantPaging", + dependencies: ["RadiantKit"], + swiftSettings: swiftSettings + ), + .target( + name: "RadiantProgress", + dependencies: [ + .product(name: "Logging", package: "swift-log", condition: .when(platforms: [.linux])) + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "RadiantKitTests", + dependencies: [ + "RadiantKit", + .product(name: "Testing", package: "swift-testing") + ] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e754176 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# RadiantKit + +[![](https://img.shields.io/badge/docc-read_documentation-blue)](https://swiftpackageindex.com/brightdigit/RadiantKit/documentation) +[![SwiftPM](https://img.shields.io/badge/SPM-Linux%20%7C%20iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS-success?logo=swift)](https://swift.org) +[![Twitter](https://img.shields.io/badge/twitter-@brightdigit-blue.svg?style=flat)](http://twitter.com/brightdigit) +![GitHub](https://img.shields.io/github/license/brightdigit/RadiantKit) +![GitHub issues](https://img.shields.io/github/issues/brightdigit/RadiantKit) +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/brightdigit/RadiantKit/RadiantKit.yml?label=actions&logo=github&?branch=main) + +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FRadiantKit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/brightdigit/RadiantKit) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FRadiantKit%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/brightdigit/RadiantKit) + +[![Codecov](https://img.shields.io/codecov/c/github/brightdigit/RadiantKit)](https://codecov.io/gh/brightdigit/RadiantKit) +[![CodeFactor Grade](https://img.shields.io/codefactor/grade/github/brightdigit/RadiantKit)](https://www.codefactor.io/repository/github/brightdigit/RadiantKit) +[![codebeat badge](https://codebeat.co/badges/ba6d29a8-1d14-4c0c-9993-5cad98664300)](https://codebeat.co/projects/github-com-brightdigit-RadiantKit-main) +[![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/brightdigit/RadiantKit)](https://codeclimate.com/github/brightdigit/RadiantKit) +[![Code Climate technical debt](https://img.shields.io/codeclimate/tech-debt/brightdigit/RadiantKit?label=debt)](https://codeclimate.com/github/brightdigit/RadiantKit) +[![Code Climate issues](https://img.shields.io/codeclimate/issues/brightdigit/RadiantKit)](https://codeclimate.com/github/brightdigit/RadiantKit) diff --git a/Scripts/gh-md-toc b/Scripts/gh-md-toc new file mode 100755 index 0000000..03b5ddd --- /dev/null +++ b/Scripts/gh-md-toc @@ -0,0 +1,421 @@ +#!/usr/bin/env bash + +# +# Steps: +# +# 1. Download corresponding html file for some README.md: +# curl -s $1 +# +# 2. Discard rows where no substring 'user-content-' (github's markup): +# awk '/user-content-/ { ... +# +# 3.1 Get last number in each row like ' ... sitemap.js.*<\/h/)+2, RLENGTH-5) +# +# 5. Find anchor and insert it inside "(...)": +# substr($0, match($0, "href=\"[^\"]+?\" ")+6, RLENGTH-8) +# + +gh_toc_version="0.10.0" + +gh_user_agent="gh-md-toc v$gh_toc_version" + +# +# Download rendered into html README.md by its url. +# +# +gh_toc_load() { + local gh_url=$1 + + if type curl &>/dev/null; then + curl --user-agent "$gh_user_agent" -s "$gh_url" + elif type wget &>/dev/null; then + wget --user-agent="$gh_user_agent" -qO- "$gh_url" + else + echo "Please, install 'curl' or 'wget' and try again." + exit 1 + fi +} + +# +# Converts local md file into html by GitHub +# +# -> curl -X POST --data '{"text": "Hello world github/linguist#1 **cool**, and #1!"}' https://api.github.com/markdown +#

Hello world github/linguist#1 cool, and #1!

'" +gh_toc_md2html() { + local gh_file_md=$1 + local skip_header=$2 + + URL=https://api.github.com/markdown/raw + + if [ -n "$GH_TOC_TOKEN" ]; then + TOKEN=$GH_TOC_TOKEN + else + TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" + if [ -f "$TOKEN_FILE" ]; then + TOKEN="$(cat "$TOKEN_FILE")" + fi + fi + if [ -n "${TOKEN}" ]; then + AUTHORIZATION="Authorization: token ${TOKEN}" + fi + + local gh_tmp_file_md=$gh_file_md + if [ "$skip_header" = "yes" ]; then + if grep -Fxq "" "$gh_src"; then + # cut everything before the toc + gh_tmp_file_md=$gh_file_md~~ + sed '1,//d' "$gh_file_md" > "$gh_tmp_file_md" + fi + fi + + # echo $URL 1>&2 + OUTPUT=$(curl -s \ + --user-agent "$gh_user_agent" \ + --data-binary @"$gh_tmp_file_md" \ + -H "Content-Type:text/plain" \ + -H "$AUTHORIZATION" \ + "$URL") + + rm -f "${gh_file_md}~~" + + if [ "$?" != "0" ]; then + echo "XXNetworkErrorXX" + fi + if [ "$(echo "${OUTPUT}" | awk '/API rate limit exceeded/')" != "" ]; then + echo "XXRateLimitXX" + else + echo "${OUTPUT}" + fi +} + + +# +# Is passed string url +# +gh_is_url() { + case $1 in + https* | http*) + echo "yes";; + *) + echo "no";; + esac +} + +# +# TOC generator +# +gh_toc(){ + local gh_src=$1 + local gh_src_copy=$1 + local gh_ttl_docs=$2 + local need_replace=$3 + local no_backup=$4 + local no_footer=$5 + local indent=$6 + local skip_header=$7 + + if [ "$gh_src" = "" ]; then + echo "Please, enter URL or local path for a README.md" + exit 1 + fi + + + # Show "TOC" string only if working with one document + if [ "$gh_ttl_docs" = "1" ]; then + + echo "Table of Contents" + echo "=================" + echo "" + gh_src_copy="" + + fi + + if [ "$(gh_is_url "$gh_src")" == "yes" ]; then + gh_toc_load "$gh_src" | gh_toc_grab "$gh_src_copy" "$indent" + if [ "${PIPESTATUS[0]}" != "0" ]; then + echo "Could not load remote document." + echo "Please check your url or network connectivity" + exit 1 + fi + if [ "$need_replace" = "yes" ]; then + echo + echo "!! '$gh_src' is not a local file" + echo "!! Can't insert the TOC into it." + echo + fi + else + local rawhtml + rawhtml=$(gh_toc_md2html "$gh_src" "$skip_header") + if [ "$rawhtml" == "XXNetworkErrorXX" ]; then + echo "Parsing local markdown file requires access to github API" + echo "Please make sure curl is installed and check your network connectivity" + exit 1 + fi + if [ "$rawhtml" == "XXRateLimitXX" ]; then + echo "Parsing local markdown file requires access to github API" + echo "Error: You exceeded the hourly limit. See: https://developer.github.com/v3/#rate-limiting" + TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" + echo "or place GitHub auth token here: ${TOKEN_FILE}" + exit 1 + fi + local toc + toc=`echo "$rawhtml" | gh_toc_grab "$gh_src_copy" "$indent"` + echo "$toc" + if [ "$need_replace" = "yes" ]; then + if grep -Fxq "" "$gh_src" && grep -Fxq "" "$gh_src"; then + echo "Found markers" + else + echo "You don't have or in your file...exiting" + exit 1 + fi + local ts="<\!--ts-->" + local te="<\!--te-->" + local dt + dt=$(date +'%F_%H%M%S') + local ext=".orig.${dt}" + local toc_path="${gh_src}.toc.${dt}" + local toc_createdby="" + local toc_footer + toc_footer="" + # http://fahdshariff.blogspot.ru/2012/12/sed-mutli-line-replacement-between-two.html + # clear old TOC + sed -i"${ext}" "/${ts}/,/${te}/{//!d;}" "$gh_src" + # create toc file + echo "${toc}" > "${toc_path}" + if [ "${no_footer}" != "yes" ]; then + echo -e "\n${toc_createdby}\n${toc_footer}\n" >> "$toc_path" + fi + + # insert toc file + if ! sed --version > /dev/null 2>&1; then + sed -i "" "/${ts}/r ${toc_path}" "$gh_src" + else + sed -i "/${ts}/r ${toc_path}" "$gh_src" + fi + echo + if [ "${no_backup}" = "yes" ]; then + rm "$toc_path" "$gh_src$ext" + fi + echo "!! TOC was added into: '$gh_src'" + if [ -z "${no_backup}" ]; then + echo "!! Origin version of the file: '${gh_src}${ext}'" + echo "!! TOC added into a separate file: '${toc_path}'" + fi + echo + fi + fi +} + +# +# Grabber of the TOC from rendered html +# +# $1 - a source url of document. +# It's need if TOC is generated for multiple documents. +# $2 - number of spaces used to indent. +# +gh_toc_grab() { + + href_regex="/href=\"[^\"]+?\"/" + common_awk_script=' + modified_href = "" + split(href, chars, "") + for (i=1;i <= length(href); i++) { + c = chars[i] + res = "" + if (c == "+") { + res = " " + } else { + if (c == "%") { + res = "\\x" + } else { + res = c "" + } + } + modified_href = modified_href res + } + print sprintf("%*s", (level-1)*'"$2"', "") "* [" text "](" gh_url modified_href ")" + ' + if [ "`uname -s`" == "OS/390" ]; then + grepcmd="pcregrep -o" + echoargs="" + awkscript='{ + level = substr($0, 3, 1) + text = substr($0, match($0, /<\/span><\/a>[^<]*<\/h/)+11, RLENGTH-14) + href = substr($0, match($0, '$href_regex')+6, RLENGTH-7) + '"$common_awk_script"' + }' + else + grepcmd="grep -Eo" + echoargs="-e" + awkscript='{ + level = substr($0, 3, 1) + text = substr($0, match($0, /">.*<\/h/)+2, RLENGTH-5) + href = substr($0, match($0, '$href_regex')+6, RLENGTH-7) + '"$common_awk_script"' + }' + fi + + # if closed is on the new line, then move it on the prev line + # for example: + # was: The command foo1 + # + # became: The command foo1 + sed -e ':a' -e 'N' -e '$!ba' -e 's/\n<\/h/<\/h/g' | + + # Sometimes a line can start with . Fix that. + sed -e ':a' -e 'N' -e '$!ba' -e 's/\n//g' | sed 's/<\/code>//g' | + + # remove g-emoji + sed 's/]*[^<]*<\/g-emoji> //g' | + + # now all rows are like: + #

title

.. + # format result line + # * $0 - whole string + # * last element of each row: "/dev/null; then + $tool --version | head -n 1 + else + echo "not installed" + fi + done +} + +show_help() { + local app_name + app_name=$(basename "$0") + echo "GitHub TOC generator ($app_name): $gh_toc_version" + echo "" + echo "Usage:" + echo " $app_name [options] src [src] Create TOC for a README file (url or local path)" + echo " $app_name - Create TOC for markdown from STDIN" + echo " $app_name --help Show help" + echo " $app_name --version Show version" + echo "" + echo "Options:" + echo " --indent Set indent size. Default: 3." + echo " --insert Insert new TOC into original file. For local files only. Default: false." + echo " See https://github.com/ekalinin/github-markdown-toc/issues/41 for details." + echo " --no-backup Remove backup file. Set --insert as well. Default: false." + echo " --hide-footer Do not write date & author of the last TOC update. Set --insert as well. Default: false." + echo " --skip-header Hide entry of the topmost headlines. Default: false." + echo " See https://github.com/ekalinin/github-markdown-toc/issues/125 for details." + echo "" +} + +# +# Options handlers +# +gh_toc_app() { + local need_replace="no" + local indent=3 + + if [ "$1" = '--help' ] || [ $# -eq 0 ] ; then + show_help + return + fi + + if [ "$1" = '--version' ]; then + show_version + return + fi + + if [ "$1" = '--indent' ]; then + indent="$2" + shift 2 + fi + + if [ "$1" = "-" ]; then + if [ -z "$TMPDIR" ]; then + TMPDIR="/tmp" + elif [ -n "$TMPDIR" ] && [ ! -d "$TMPDIR" ]; then + mkdir -p "$TMPDIR" + fi + local gh_tmp_md + if [ "`uname -s`" == "OS/390" ]; then + local timestamp + timestamp=$(date +%m%d%Y%H%M%S) + gh_tmp_md="$TMPDIR/tmp.$timestamp" + else + gh_tmp_md=$(mktemp "$TMPDIR/tmp.XXXXXX") + fi + while read -r input; do + echo "$input" >> "$gh_tmp_md" + done + gh_toc_md2html "$gh_tmp_md" | gh_toc_grab "" "$indent" + return + fi + + if [ "$1" = '--insert' ]; then + need_replace="yes" + shift + fi + + if [ "$1" = '--no-backup' ]; then + need_replace="yes" + no_backup="yes" + shift + fi + + if [ "$1" = '--hide-footer' ]; then + need_replace="yes" + no_footer="yes" + shift + fi + + if [ "$1" = '--skip-header' ]; then + skip_header="yes" + shift + fi + + + for md in "$@" + do + echo "" + gh_toc "$md" "$#" "$need_replace" "$no_backup" "$no_footer" "$indent" "$skip_header" + done + + echo "" + echo "" +} + +# +# Entry point +# +gh_toc_app "$@" \ No newline at end of file diff --git a/Scripts/header.sh b/Scripts/header.sh new file mode 100755 index 0000000..4ed7446 --- /dev/null +++ b/Scripts/header.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +# Function to print usage +usage() { + echo "Usage: $0 -d directory -c creator -o company -p package [-y year]" + echo " -d directory Directory to read from (including subdirectories)" + echo " -c creator Name of the creator" + echo " -o company Name of the company with the copyright" + echo " -p package Package or library name" + echo " -y year Copyright year (optional, defaults to current year)" + exit 1 +} + +# Get the current year if not provided +current_year=$(date +"%Y") + +# Default values +year="$current_year" + +# Parse arguments +while getopts ":d:c:o:p:y:" opt; do + case $opt in + d) directory="$OPTARG" ;; + c) creator="$OPTARG" ;; + o) company="$OPTARG" ;; + p) package="$OPTARG" ;; + y) year="$OPTARG" ;; + *) usage ;; + esac +done + +# Check for mandatory arguments +if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then + usage +fi + +# Define the header template +header_template="// +// %s +// %s +// +// Created by %s. +// Copyright © %s %s. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +//" + +# Loop through each Swift file in the specified directory and subdirectories +find "$directory" -type f -name "*.swift" | while read -r file; do + # Check if the first line is the swift-format-ignore indicator + first_line=$(head -n 1 "$file") + if [[ "$first_line" == "// swift-format-ignore-file" ]]; then + echo "Skipping $file due to swift-format-ignore directive." + continue + fi + + # Create the header with the current filename + filename=$(basename "$file") + header=$(printf "$header_template" "$filename" "$package" "$creator" "$year" "$company") + + # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" + awk ' + BEGIN { skip = 1 } + { + if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) { + next + } + skip = 0 + print + }' "$file" > temp_file + + # Add the header to the cleaned file + (echo "$header"; echo; cat temp_file) > "$file" + + # Remove the temporary file + rm temp_file +done + +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." \ No newline at end of file diff --git a/Scripts/lint.sh b/Scripts/lint.sh new file mode 100755 index 0000000..7f34454 --- /dev/null +++ b/Scripts/lint.sh @@ -0,0 +1,50 @@ +#!/bin/sh + +if [ "$ACTION" == "install" ]; then + if [ -n "$SRCROOT" ]; then + exit + fi +fi + +export MINT_PATH="$PWD/.mint" +MINT_ARGS="-n -m Mintfile --silent" +MINT_RUN="/opt/homebrew/bin/mint run $MINT_ARGS" + +if [ -z "$SRCROOT" ] || [ -n "$CHILD_PACKAGE" ]; then + SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + PACKAGE_DIR="${SCRIPT_DIR}/.." + PERIPHERY_OPTIONS="" +else + PACKAGE_DIR="${SRCROOT}" + PERIPHERY_OPTIONS="" +fi + + +if [ "$LINT_MODE" == "NONE" ]; then + exit +elif [ "$LINT_MODE" == "STRICT" ]; then + SWIFTFORMAT_OPTIONS="--strict" +else + SWIFTFORMAT_OPTIONS="" +fi + +/opt/homebrew/bin/mint bootstrap + +echo "LINT Mode is $LINT_MODE" + +if [ "$LINT_MODE" == "INSTALL" ]; then + exit +fi + +if [ -z "$CI" ]; then + $MINT_RUN swift-format format --recursive --parallel --in-place $PACKAGE_DIR/Sources +else + set -e +fi + +$PACKAGE_DIR/scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "RadiantKit" +$MINT_RUN swift-format lint --recursive --parallel $SWIFTFORMAT_OPTIONS $PACKAGE_DIR/Sources + +pushd $PACKAGE_DIR +$MINT_RUN periphery scan $PERIPHERY_OPTIONS --disable-update-check +popd \ No newline at end of file diff --git a/Sources/RadiantDocs/Actions/OpenFileURLAction.swift b/Sources/RadiantDocs/Actions/OpenFileURLAction.swift new file mode 100644 index 0000000..0f90f25 --- /dev/null +++ b/Sources/RadiantDocs/Actions/OpenFileURLAction.swift @@ -0,0 +1,65 @@ +// +// OpenFileURLAction.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + #if os(macOS) || os(iOS) || os(visionOS) + public import Foundation + + public import SwiftUI + + fileprivate struct OpenFileURLKey: EnvironmentKey, Sendable { + typealias Value = OpenFileURLAction + + static let defaultValue: OpenFileURLAction = .default + } + + public typealias OpenWindowURLAction = OpenWindowWithValueAction + + public typealias OpenFileURLAction = OpenWindowURLAction + + extension EnvironmentValues { + public var openFileURL: OpenFileURLAction { + get { self[OpenFileURLKey.self] } + set { self[OpenFileURLKey.self] = newValue } + } + } + + extension Scene { + public func openFileURL( + _ closure: @escaping @Sendable @MainActor (URL, OpenWindowAction) -> Void + ) -> some Scene { self.environment(\.openFileURL, .init(closure: closure)) } + } + + @available(*, deprecated, message: "Use on Scene only.") extension View { + public func openFileURL( + _ closure: @Sendable @escaping @MainActor (URL, OpenWindowAction) -> Void + ) -> some View { self.environment(\.openFileURL, .init(closure: closure)) } + } + #endif +#endif diff --git a/Sources/RadiantDocs/Actions/OpenWindowWithAction.swift b/Sources/RadiantDocs/Actions/OpenWindowWithAction.swift new file mode 100644 index 0000000..331e3c5 --- /dev/null +++ b/Sources/RadiantDocs/Actions/OpenWindowWithAction.swift @@ -0,0 +1,48 @@ +// +// OpenWindowWithAction.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + #if os(macOS) || os(iOS) || os(visionOS) + import Foundation + + public import SwiftUI + + public typealias OpenWindowWithAction = OpenWindowWithValueAction + + @MainActor extension OpenWindowWithAction { + public init(closure: @escaping @Sendable @MainActor (OpenWindowAction) -> Void) { + self.init { _, action in closure(action) } + } + + @MainActor public func callAsFunction(with openWidow: OpenWindowAction) { + closure((), openWidow) + } + } + #endif +#endif diff --git a/Sources/RadiantDocs/Actions/OpenWindowWithValueAction.swift b/Sources/RadiantDocs/Actions/OpenWindowWithValueAction.swift new file mode 100644 index 0000000..32f3f6d --- /dev/null +++ b/Sources/RadiantDocs/Actions/OpenWindowWithValueAction.swift @@ -0,0 +1,55 @@ +// +// OpenWindowWithValueAction.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + #if os(macOS) || os(iOS) || os(visionOS) + import Foundation + + public import SwiftUI + + public struct OpenWindowWithValueAction: Sendable { + public static var `default`: Self { + .init { value, action in defaultClosure(value, with: action) } + } + + let closure: @Sendable @MainActor (ValueType, OpenWindowAction) -> Void + public init(closure: @escaping @MainActor @Sendable (ValueType, OpenWindowAction) -> Void) { + self.closure = closure + } + + private static func defaultClosure(_: ValueType, with _: OpenWindowAction) { + assertionFailure() + } + + @MainActor public func callAsFunction(_ value: ValueType, with openWidow: OpenWindowAction) { + closure(value, openWidow) + } + } + #endif +#endif diff --git a/Sources/RadiantDocs/AllowedOpenFileTypesKey.swift b/Sources/RadiantDocs/AllowedOpenFileTypesKey.swift new file mode 100644 index 0000000..4d6477f --- /dev/null +++ b/Sources/RadiantDocs/AllowedOpenFileTypesKey.swift @@ -0,0 +1,61 @@ +// +// AllowedOpenFileTypesKey.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import Foundation + + public import SwiftUI + + import UniformTypeIdentifiers + + fileprivate struct AllowedOpenFileTypesKey: EnvironmentKey { + typealias Value = [FileType] + static let defaultValue = [FileType]() + } + + extension EnvironmentValues { + public var allowedOpenFileTypes: [FileType] { + get { self[AllowedOpenFileTypesKey.self] } + set { self[AllowedOpenFileTypesKey.self] = newValue } + } + } + + @available(*, deprecated, message: "Use on Scene only.") extension View { + public func allowedOpenFileTypes(_ fileTypes: [FileType]) -> some View { + self.environment(\.allowedOpenFileTypes, fileTypes) + } + } + + extension Scene { + public func allowedOpenFileTypes(_ fileTypes: [FileType]) -> some Scene { + self.environment(\.allowedOpenFileTypes, fileTypes) + } + } + +#endif diff --git a/Sources/RadiantDocs/AppKit/NewFilePanel.swift b/Sources/RadiantDocs/AppKit/NewFilePanel.swift new file mode 100644 index 0000000..bbb0436 --- /dev/null +++ b/Sources/RadiantDocs/AppKit/NewFilePanel.swift @@ -0,0 +1,67 @@ +// +// NewFilePanel.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(AppKit) && canImport(SwiftUI) + import AppKit + + import Foundation + + public import SwiftUI + + import UniformTypeIdentifiers + + public struct NewFilePanel: Sendable { + public init() {} + + public init(_: FileType.Type) {} + + @MainActor public func callAsFunction(with openWindow: OpenWindowAction) { + let openPanel = NSSavePanel() + openPanel.allowedContentTypes = [UTType(fileType: FileType.fileType)] + openPanel.isExtensionHidden = true + openPanel.begin { response in + guard let fileURL = openPanel.url, response == .OK else { return } + let value: FileType.WindowValueType + do { value = try FileType.createAt(fileURL) } + catch { + openPanel.presentError(error) + return + } + openWindow(value: value) + } + } + } + + extension OpenWindowAction { + @MainActor public func callAsFunction( + newFileOf valueType: (some InitializableFileTypeSpecification).Type + ) { NewFilePanel(valueType)(with: self) } + } + +#endif diff --git a/Sources/RadiantDocs/AppKit/OpenAnyFilePanel.swift b/Sources/RadiantDocs/AppKit/OpenAnyFilePanel.swift new file mode 100644 index 0000000..3c4ebcd --- /dev/null +++ b/Sources/RadiantDocs/AppKit/OpenAnyFilePanel.swift @@ -0,0 +1,68 @@ +// +// OpenAnyFilePanel.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(AppKit) && canImport(SwiftUI) + import AppKit + + import Foundation + + public import SwiftUI + + import UniformTypeIdentifiers + + public struct OpenAnyFilePanel { + let fileTypes: [FileType] + + internal init(fileTypes: [FileType]) { + assert(!fileTypes.isEmpty) + self.fileTypes = fileTypes + } + + @MainActor public func callAsFunction( + with openFileURL: OpenFileURLAction, + using openWindow: OpenWindowAction + ) { + let openPanel = NSOpenPanel() + openPanel.allowedContentTypes = fileTypes.map(UTType.init(fileType:)) + openPanel.isExtensionHidden = true + openPanel.begin { response in + guard let fileURL = openPanel.url, response == .OK else { return } + openFileURL(fileURL, with: openWindow) + } + } + } + + extension OpenFileURLAction { + @MainActor public func callAsFunction( + ofFileTypes fileTypes: [FileType], + using openWindow: OpenWindowAction + ) { OpenAnyFilePanel(fileTypes: fileTypes).callAsFunction(with: self, using: openWindow) } + } + +#endif diff --git a/Sources/RadiantDocs/AppKit/OpenFilePanel.swift b/Sources/RadiantDocs/AppKit/OpenFilePanel.swift new file mode 100644 index 0000000..4a2b0db --- /dev/null +++ b/Sources/RadiantDocs/AppKit/OpenFilePanel.swift @@ -0,0 +1,65 @@ +// +// OpenFilePanel.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(AppKit) && canImport(SwiftUI) + import AppKit + + import Foundation + + public import SwiftUI + + import UniformTypeIdentifiers + + public struct OpenFilePanel: Sendable { + public init() {} + + public init(_: FileType.Type) {} + + @MainActor public func callAsFunction(with openWindow: OpenWindowAction) { + let openPanel = NSOpenPanel() + openPanel.allowedContentTypes = [UTType(fileType: FileType.fileType)] + openPanel.isExtensionHidden = true + openPanel.begin { response in + guard let fileURL = openPanel.url, response == .OK else { + #warning("logging-note: should we log something here?") + return + } + let libraryFile = DocumentFile(url: fileURL) + openWindow(value: libraryFile) + } + } + } + + extension OpenWindowAction { + @MainActor public func callAsFunction(_ valueType: (some FileTypeSpecification).Type) { + OpenFilePanel(valueType)(with: self) + } + } + +#endif diff --git a/Sources/RadiantDocs/CodablePackageDocument.swift b/Sources/RadiantDocs/CodablePackageDocument.swift new file mode 100644 index 0000000..5337c04 --- /dev/null +++ b/Sources/RadiantDocs/CodablePackageDocument.swift @@ -0,0 +1,79 @@ +// +// CodablePackageDocument.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + #if os(macOS) || os(iOS) || os(visionOS) + public import Foundation + + public import SwiftUI + + public import UniformTypeIdentifiers + + public struct CodablePackageDocument: FileDocument { + internal enum ReadError: Error { case missingConfigurationAtKey(String) } + + public static var readableContentTypes: [UTType] { T.readableContentTypes.map(UTType.init) } + + let configuration: T + + public init(configuration: T) { self.configuration = configuration } + + public init(configuration: ReadConfiguration) throws { + let regularFileContents = configuration.file.fileWrappers?[T.configurationFileWrapperKey]? + .regularFileContents + guard let configJSONWrapperData = regularFileContents else { + throw ReadError.missingConfigurationAtKey(T.configurationFileWrapperKey) + } + + let configuration = try T.decoder.decode(T.self, from: configJSONWrapperData) + self.init(configuration: configuration) + } + + public func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let rootFileWrapper = + configuration.existingFile ?? FileWrapper(directoryWithFileWrappers: [:]) + + if let oldConfigJSONWrapper = rootFileWrapper.fileWrappers?[T.configurationFileWrapperKey] { + rootFileWrapper.removeFileWrapper(oldConfigJSONWrapper) + } + + let newConfigJSONData = try T.encoder.encode(self.configuration) + let newConfigJSONWrapper = FileWrapper(regularFileWithContents: newConfigJSONData) + newConfigJSONWrapper.preferredFilename = T.configurationFileWrapperKey + rootFileWrapper.addFileWrapper(newConfigJSONWrapper) + + return rootFileWrapper + } + } + + extension CodablePackageDocument where T: InitializablePackage { + public init() { self.init(configuration: .init()) } + } + #endif +#endif diff --git a/Sources/RadiantDocs/Primitives/CodablePackage.swift b/Sources/RadiantDocs/Primitives/CodablePackage.swift new file mode 100644 index 0000000..bf8a19e --- /dev/null +++ b/Sources/RadiantDocs/Primitives/CodablePackage.swift @@ -0,0 +1,44 @@ +// +// CodablePackage.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +public protocol CodablePackage: Sendable, Codable { + static var decoder: JSONDecoder { get } + static var encoder: JSONEncoder { get } + static var configurationFileWrapperKey: String { get } + static var readableContentTypes: [FileType] { get } +} + +extension CodablePackage { + public init(contentsOf url: URL) throws { + let data = try Data(contentsOf: url.appendingPathComponent(Self.configurationFileWrapperKey)) + self = try Self.decoder.decode(Self.self, from: data) + } +} diff --git a/Sources/RadiantDocs/Primitives/DocumentFile.swift b/Sources/RadiantDocs/Primitives/DocumentFile.swift new file mode 100644 index 0000000..7b56688 --- /dev/null +++ b/Sources/RadiantDocs/Primitives/DocumentFile.swift @@ -0,0 +1,45 @@ +// +// DocumentFile.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +public struct DocumentFile: Codable, Hashable { + public let url: URL + + public init(url: URL) { self.url = url } + + public func hash(into hasher: inout Hasher) { hasher.combine(url) } +} + +extension DocumentFile { + public static func documentFile(from url: URL) -> Self? { + guard url.pathExtension == FileType.fileType.fileExtension else { return nil } + return Self(url: url) + } +} diff --git a/Sources/RadiantDocs/Primitives/FileType.swift b/Sources/RadiantDocs/Primitives/FileType.swift new file mode 100644 index 0000000..491c746 --- /dev/null +++ b/Sources/RadiantDocs/Primitives/FileType.swift @@ -0,0 +1,73 @@ +// +// FileType.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +public struct FileType: Hashable, ExpressibleByStringLiteral, Sendable { + public typealias StringLiteralType = String + + public let utIdentifier: String + public let isOwned: Bool + public let fileExtension: String? + + public init(utIdentifier: String, isOwned: Bool, fileExtension: String? = nil) { + self.utIdentifier = utIdentifier + self.isOwned = isOwned + self.fileExtension = fileExtension + } + + public init(stringLiteral utIdentifier: String) { + self.init(utIdentifier: utIdentifier, isOwned: false) + } + + public static func exportedAs(_ utIdentifier: String, _ fileExtension: String) -> FileType { + .init(utIdentifier: utIdentifier, isOwned: true, fileExtension: fileExtension) + } +} +// +//extension FileType { +// public static let ipswFileExtension = "ipsw" +// public static let iTunesIPSW: FileType = "com.apple.itunes.ipsw" +// public static let iPhoneIPSW: FileType = "com.apple.iphone.ipsw" +// +// public static let virtualMachineFileExtension = "bshvm" +// public static let restoreImageLibraryFileExtension = "bshrilib" +// +// public static let virtualMachine: FileType = .exportedAs( +// "com.brightdigit.bushel-vm", +// virtualMachineFileExtension +// ) +// +// public static let restoreImageLibrary: FileType = .exportedAs( +// "com.brightdigit.bushel-rilib", +// restoreImageLibraryFileExtension +// ) +// +// public static let ipswTypes = [iTunesIPSW, iPhoneIPSW] +//} diff --git a/Sources/RadiantDocs/Primitives/FileTypeSpecification.swift b/Sources/RadiantDocs/Primitives/FileTypeSpecification.swift new file mode 100644 index 0000000..da08d6a --- /dev/null +++ b/Sources/RadiantDocs/Primitives/FileTypeSpecification.swift @@ -0,0 +1,32 @@ +// +// FileTypeSpecification.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +public protocol FileTypeSpecification: Sendable { static var fileType: FileType { get } } diff --git a/Sources/RadiantDocs/Primitives/InitializableFileTypeSpecification.swift b/Sources/RadiantDocs/Primitives/InitializableFileTypeSpecification.swift new file mode 100644 index 0000000..7948842 --- /dev/null +++ b/Sources/RadiantDocs/Primitives/InitializableFileTypeSpecification.swift @@ -0,0 +1,35 @@ +// +// InitializableFileTypeSpecification.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +public protocol InitializableFileTypeSpecification: FileTypeSpecification { + associatedtype WindowValueType: Codable & Hashable + static func createAt(_ url: URL) throws -> WindowValueType +} diff --git a/Sources/RadiantDocs/Primitives/InitializablePackage.swift b/Sources/RadiantDocs/Primitives/InitializablePackage.swift new file mode 100644 index 0000000..75da2d9 --- /dev/null +++ b/Sources/RadiantDocs/Primitives/InitializablePackage.swift @@ -0,0 +1,51 @@ +// +// InitializablePackage.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +#if canImport(FoundationNetworking) + public import FoundationNetworking +#endif + +public protocol InitializablePackage: CodablePackage { init() } + +extension InitializablePackage { + #warning("logging-note: let's log what is going on here") + #warning("Might want to add parameters for creating data and creating directory.") + @discardableResult public static func createAt(_ fileURL: URL, using encoder: JSONEncoder) throws + -> Self + { + let library = self.init() + try FileManager.default.createDirectory(at: fileURL, withIntermediateDirectories: false) + let metadataJSONPath = fileURL.appendingPathComponent(self.configurationFileWrapperKey) + let data = try encoder.encode(library) + try data.write(to: metadataJSONPath) + return library + } +} diff --git a/Sources/RadiantDocs/UTType.swift b/Sources/RadiantDocs/UTType.swift new file mode 100644 index 0000000..1adc982 --- /dev/null +++ b/Sources/RadiantDocs/UTType.swift @@ -0,0 +1,69 @@ +// +// UTType.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(UniformTypeIdentifiers) + public import UniformTypeIdentifiers + + extension UTType { + public init(fileType: FileType) { + if fileType.isOwned { + self.init(exportedAs: fileType.utIdentifier) + } + else { + // swift-format-ignore: NeverForceUnwrap + self.init(fileType.utIdentifier)! + } + } + + public static func allowedContentTypes(for fileType: FileType) -> [UTType] { + var types = [UTType]() + + if fileType.isOwned { types.append(.init(exportedAs: fileType.utIdentifier)) } + + if let fileExtensionType = fileType.fileExtension.flatMap({ UTType(filenameExtension: $0) }) { + types.append(fileExtensionType) + } + + if let utIdentified = UTType(fileType.utIdentifier) { types.append(utIdentified) } + + return types + } + + public static func allowedContentTypes(for fileTypes: FileType...) -> [UTType] { + fileTypes.flatMap(allowedContentTypes(for:)) + } + } + + extension FileType { + @Sendable public init?(url: URL) { + guard let utType = UTType(filenameExtension: url.pathExtension) else { return nil } + self.init(stringLiteral: utType.identifier) + } + } +#endif diff --git a/Sources/RadiantKit/AppKit/NSWindowAdaptorModifier.swift b/Sources/RadiantKit/AppKit/NSWindowAdaptorModifier.swift new file mode 100644 index 0000000..7a40d2b --- /dev/null +++ b/Sources/RadiantKit/AppKit/NSWindowAdaptorModifier.swift @@ -0,0 +1,82 @@ +// +// NSWindowAdaptorModifier.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +#if canImport(SwiftUI) + public import SwiftUI + + #if canImport(AppKit) + import AppKit + + fileprivate struct NSWindowAdaptorHostingView: NSViewRepresentable { + #warning( + """ + Issue 100 + We need a way to specific when the callback is called and whether it should be. + """ + ) + private var callback: (NSWindow?) -> Void + + fileprivate init(callback: @escaping (NSWindow?) -> Void) { self.callback = callback } + + fileprivate func makeNSView(context _: Self.Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { [weak view] in self.callback(view?.window) } + view.setFrameSize(.zero) + view.isHidden = true + view.frame = CGRect.zero + return view + } + + fileprivate func updateNSView(_: NSView, context _: Context) {} + } + + fileprivate struct NSWindowAdaptorModifier: ViewModifier { + private var callback: (NSWindow?) -> Void + + fileprivate init(callback: @escaping (NSWindow?) -> Void) { self.callback = callback } + + fileprivate func body(content: Content) -> some View { + content.overlay(NSWindowAdaptorHostingView(callback: callback).frame(width: 0, height: 0)) + } + } + + extension View { + public func nsWindowAdaptor(_ callback: @escaping (NSWindow?) -> Void) -> some View { + self.modifier(NSWindowAdaptorModifier(callback: callback)) + } + } + #else + extension View { + public func nsWindowAdaptor(_: @escaping (Any?) -> Void) -> some View { self } + } + #endif + +#endif diff --git a/Sources/RadiantKit/AppKit/View+NSWindowDelegate.swift b/Sources/RadiantKit/AppKit/View+NSWindowDelegate.swift new file mode 100644 index 0000000..9cd7b3a --- /dev/null +++ b/Sources/RadiantKit/AppKit/View+NSWindowDelegate.swift @@ -0,0 +1,69 @@ +// +// View+NSWindowDelegate.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(AppKit) && canImport(SwiftUI) + import AppKit + + public import SwiftUI + + fileprivate struct NSWindowDelegateAdaptorModifier: ViewModifier { + @Binding var binding: (any NSWindowDelegate)? + let delegate: any NSWindowDelegate + + init( + binding: Binding<(any NSWindowDelegate)?>, + delegate: @autoclosure () -> any NSWindowDelegate + ) { + self._binding = binding + self.delegate = binding.wrappedValue ?? delegate() + + #warning( + "Issue 100 - We can't set binding here - Modifying state during view update, this will cause undefined behavior." + ) + self.binding = self.delegate + } + + func body(content: Content) -> some View { + content.nsWindowAdaptor { window in + assert(!self.delegate.isEqual(window?.delegate)) + assert(window != nil) + window?.delegate = delegate + } + } + } + + extension View { + public func nsWindowDelegateAdaptor( + _ binding: Binding<(any NSWindowDelegate)?>, + _ delegate: @autoclosure () -> any NSWindowDelegate + ) -> some View { + self.modifier(NSWindowDelegateAdaptorModifier(binding: binding, delegate: delegate())) + } + } +#endif diff --git a/Sources/RadiantKit/Binding.swift b/Sources/RadiantKit/Binding.swift new file mode 100644 index 0000000..4834b7b --- /dev/null +++ b/Sources/RadiantKit/Binding.swift @@ -0,0 +1,47 @@ +// +// Binding.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import Foundation + + public import SwiftUI + + extension Binding { + @MainActor public func map( + to get: @escaping @Sendable (Value) -> T, + from set: @escaping @Sendable (T) -> Value + ) -> Binding { + .init { + get(self.wrappedValue) + } set: { + self.wrappedValue = set($0) + } + } + } +#endif diff --git a/Sources/RadiantKit/Color.swift b/Sources/RadiantKit/Color.swift new file mode 100644 index 0000000..235355d --- /dev/null +++ b/Sources/RadiantKit/Color.swift @@ -0,0 +1,59 @@ +// +// Color.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import SwiftUI + + extension Color { + public init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let alpha: UInt64 + let red: UInt64 + let green: UInt64 + let blue: UInt64 + switch hex.count { case 3: // RGB (12-bit) + (alpha, red, green, blue) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (alpha, red, green, blue) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (alpha, red, green, blue) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: (alpha, red, green, blue) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(red) / 255, + green: Double(green) / 255, + blue: Double(blue) / 255, + opacity: Double(alpha) / 255 + ) + } + } +#endif diff --git a/Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+AppStored.swift b/Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+AppStored.swift new file mode 100644 index 0000000..3c31a63 --- /dev/null +++ b/Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+AppStored.swift @@ -0,0 +1,101 @@ +// +// AppStorage+AppStored.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + + public import Foundation + + public import SwiftUI + + extension AppStorage { + public init( + wrappedValue: Value, + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Bool, Value == Bool { + self.init(wrappedValue: wrappedValue, type.key, store: store) + } + + public init( + wrappedValue: Value, + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Int, Value == Int { + self.init(wrappedValue: wrappedValue, type.key, store: store) + } + + public init( + wrappedValue: Value, + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Double, Value == Double { + self.init(wrappedValue: wrappedValue, type.key, store: store) + } + + public init( + wrappedValue: Value, + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == String, Value == String { + self.init(wrappedValue: wrappedValue, type.key, store: store) + } + + public init( + wrappedValue: Value, + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == URL, Value == URL { + self.init(wrappedValue: wrappedValue, type.key, store: store) + } + + public init( + wrappedValue: Value, + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Data, Value == Data { + self.init(wrappedValue: wrappedValue, type.key, store: store) + } + + public init( + wrappedValue: Value, + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Value, Value: RawRepresentable, Value.RawValue == Int { + self.init(wrappedValue: wrappedValue, type.key, store: store) + } + + public init( + wrappedValue: Value, + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Value, Value: RawRepresentable, Value.RawValue == String { + self.init(wrappedValue: wrappedValue, type.key, store: store) + } + } +#endif diff --git a/Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+DefaultWrapped.swift b/Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+DefaultWrapped.swift new file mode 100644 index 0000000..23c5ab2 --- /dev/null +++ b/Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+DefaultWrapped.swift @@ -0,0 +1,93 @@ +// +// AppStorage+DefaultWrapped.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + + public import Foundation + + public import SwiftUI + + extension AppStorage { + public init( + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Bool, Value == Bool { + self.init(wrappedValue: AppStoredType.default, type.key, store: store) + } + + public init( + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Int, Value == Int { + self.init(wrappedValue: AppStoredType.default, type.key, store: store) + } + + public init( + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Double, Value == Double { + self.init(wrappedValue: AppStoredType.default, type.key, store: store) + } + + public init( + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == String, Value == String { + self.init(wrappedValue: AppStoredType.default, type.key, store: store) + } + + public init( + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == URL, Value == URL { + self.init(wrappedValue: AppStoredType.default, type.key, store: store) + } + + public init( + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Data, Value == Data { + self.init(wrappedValue: AppStoredType.default, type.key, store: store) + } + + public init( + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Value, Value: RawRepresentable, Value.RawValue == Int { + self.init(wrappedValue: AppStoredType.default, type.key, store: store) + } + + public init( + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) where AppStoredType.Value == Value, Value: RawRepresentable, Value.RawValue == String { + self.init(wrappedValue: AppStoredType.default, type.key, store: store) + } + } +#endif diff --git a/Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+ExpressibleByNilLiteral.swift b/Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+ExpressibleByNilLiteral.swift new file mode 100644 index 0000000..0c42266 --- /dev/null +++ b/Sources/RadiantKit/PropertyWrappers/AppStorage/AppStorage+ExpressibleByNilLiteral.swift @@ -0,0 +1,79 @@ +// +// AppStorage+ExpressibleByNilLiteral.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + + public import Foundation + + public import SwiftUI + + extension AppStorage where Value: ExpressibleByNilLiteral { + public init(for type: AppStoredType.Type, store: UserDefaults? = nil) + where AppStoredType.Value == Value, Value == Bool? { self.init(type.key, store: store) } + + public init(for type: AppStoredType.Type, store: UserDefaults? = nil) + where AppStoredType.Value == Value, Value == Int? { self.init(type.key, store: store) } + + public init(for type: AppStoredType.Type, store: UserDefaults? = nil) + where AppStoredType.Value == Value, Value == Double? { self.init(type.key, store: store) } + + public init(for type: AppStoredType.Type, store: UserDefaults? = nil) + where AppStoredType.Value == Value, Value == String? { self.init(type.key, store: store) } + + public init(for type: AppStoredType.Type, store: UserDefaults? = nil) + where AppStoredType.Value == Value, Value == URL? { self.init(type.key, store: store) } + + public init(for type: AppStoredType.Type, store: UserDefaults? = nil) + where AppStoredType.Value == Value, Value == Data? { self.init(type.key, store: store) } + } + + extension AppStorage { + public init( + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) + where + AppStoredType.Value == R?, + R: RawRepresentable, + Value == AppStoredType.Value, + R.RawValue == String + { self.init(type.key, store: store) } + + public init( + for type: AppStoredType.Type, + store: UserDefaults? = nil + ) + where + AppStoredType.Value == R?, + R: RawRepresentable, + Value == AppStoredType.Value, + R.RawValue == Int + { self.init(type.key, store: store) } + } +#endif diff --git a/Sources/RadiantKit/PropertyWrappers/AppStored.swift b/Sources/RadiantKit/PropertyWrappers/AppStored.swift new file mode 100644 index 0000000..38b2863 --- /dev/null +++ b/Sources/RadiantKit/PropertyWrappers/AppStored.swift @@ -0,0 +1,49 @@ +// +// AppStored.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +public protocol AppStored { + associatedtype Value + static var keyType: KeyType { get } + static var key: String { get } +} + +extension AppStored { + public static var key: String { + switch self.keyType { case .describing: String(describing: Self.self) + + case .reflecting: + String(reflecting: Self.self).components(separatedBy: ".").dropFirst() + .joined(separator: ".") + } + } + + public static var keyType: KeyType { .describing } +} diff --git a/Sources/RadiantKit/PropertyWrappers/DefaultWrapped.swift b/Sources/RadiantKit/PropertyWrappers/DefaultWrapped.swift new file mode 100644 index 0000000..084bb38 --- /dev/null +++ b/Sources/RadiantKit/PropertyWrappers/DefaultWrapped.swift @@ -0,0 +1,32 @@ +// +// DefaultWrapped.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +public protocol DefaultWrapped: AppStored { static var `default`: Value { get } } diff --git a/Sources/RadiantKit/PropertyWrappers/KeyType.swift b/Sources/RadiantKit/PropertyWrappers/KeyType.swift new file mode 100644 index 0000000..4c981c1 --- /dev/null +++ b/Sources/RadiantKit/PropertyWrappers/KeyType.swift @@ -0,0 +1,35 @@ +// +// KeyType.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +public enum KeyType: Sendable { + case describing + case reflecting +} diff --git a/Sources/RadiantKit/TransformedValueObject.swift b/Sources/RadiantKit/TransformedValueObject.swift new file mode 100644 index 0000000..1371932 --- /dev/null +++ b/Sources/RadiantKit/TransformedValueObject.swift @@ -0,0 +1,85 @@ +// +// TransformedValueObject.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import Foundation + + public import SwiftUI + + @Observable public class TransformedValueObject { + private let defaultTransform: (InputValue) -> OutputValue + private let formattable: (OutputValue) -> FormattableValue + + @ObservationIgnored private var transform: ((InputValue) -> OutputValue)? + + @ObservationIgnored private var bindingValue: Binding? + + public var inputValue: InputValue { + didSet { + assert(self.transform != nil) + self.bindingValue?.wrappedValue = inputValue + self.outputValue = (self.transform ?? self.defaultTransform)(inputValue) + } + } + + public private(set) var outputValue: OutputValue { + didSet { self.formattedValue = self.formattable(outputValue) } + } + + public private(set) var formattedValue: FormattableValue + + public init( + defaultTransform: @escaping (InputValue) -> OutputValue, + formattable: @escaping (OutputValue) -> FormattableValue, + inputValue: InputValue, + outputValue: OutputValue? = nil, + fomrattedValue: FormattableValue? = nil, + polynomial: ((InputValue) -> OutputValue)? = nil + ) { + self.formattable = formattable + self.defaultTransform = defaultTransform + self.transform = polynomial + self.inputValue = inputValue + let outputValue = outputValue ?? transform?(inputValue) ?? defaultTransform(inputValue) + self.outputValue = outputValue + self.formattedValue = fomrattedValue ?? self.formattable(outputValue) + } + + public func bindTo( + _ bindingValue: Binding, + using transform: @escaping (InputValue) -> OutputValue + ) { + assert(self.bindingValue == nil) + assert(self.transform == nil) + self.transform = transform + self.inputValue = bindingValue.wrappedValue ?? self.inputValue + self.bindingValue = bindingValue + } + } +#endif diff --git a/Sources/RadiantKit/ViewExtensions/DefaultableViewValue.swift b/Sources/RadiantKit/ViewExtensions/DefaultableViewValue.swift new file mode 100644 index 0000000..11c8b71 --- /dev/null +++ b/Sources/RadiantKit/ViewExtensions/DefaultableViewValue.swift @@ -0,0 +1,32 @@ +// +// DefaultableViewValue.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public protocol DefaultableViewValue: Codable, Hashable, Sendable { + static var `default`: Self { get } +} diff --git a/Sources/RadiantKit/ViewExtensions/IdentifiableView.swift b/Sources/RadiantKit/ViewExtensions/IdentifiableView.swift new file mode 100644 index 0000000..70a0c0c --- /dev/null +++ b/Sources/RadiantKit/ViewExtensions/IdentifiableView.swift @@ -0,0 +1,49 @@ +// +// IdentifiableView.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import SwiftUI + + @MainActor public struct IdentifiableView: Identifiable, View, Sendable { + private let content: any View + public let id: Int + + public var body: some View { AnyView(content) } + + public init(_ content: any View, id: Int) { + self.content = content + self.id = id + } + + public init(_ content: @escaping () -> some View, id: Int) { + self.content = content() + self.id = id + } + } +#endif diff --git a/Sources/RadiantKit/ViewExtensions/IdentifiableViewBuilder.swift b/Sources/RadiantKit/ViewExtensions/IdentifiableViewBuilder.swift new file mode 100644 index 0000000..c64fbf1 --- /dev/null +++ b/Sources/RadiantKit/ViewExtensions/IdentifiableViewBuilder.swift @@ -0,0 +1,49 @@ +// +// IdentifiableViewBuilder.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import Foundation + public import SwiftUI + + @MainActor @resultBuilder public enum IdentifiableViewBuilder { + public static func buildPartialBlock(first: any View) -> [IdentifiableView] { + [IdentifiableView(first, id: 0)] + } + + public static func buildPartialBlock(accumulated: [IdentifiableView], next: any View) + -> [IdentifiableView] + { accumulated + [IdentifiableView(next, id: accumulated.count)] } + + public static func buildPartialBlock(first: IdentifiableView) -> [IdentifiableView] { [first] } + + public static func buildPartialBlock(accumulated: [IdentifiableView], next: IdentifiableView) + -> [IdentifiableView] + { accumulated + [next] } + } +#endif diff --git a/Sources/RadiantKit/ViewExtensions/SingleWindowView.swift b/Sources/RadiantKit/ViewExtensions/SingleWindowView.swift new file mode 100644 index 0000000..1cf39d0 --- /dev/null +++ b/Sources/RadiantKit/ViewExtensions/SingleWindowView.swift @@ -0,0 +1,62 @@ +// +// SingleWindowView.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + + import Foundation + + public import SwiftUI + + // periphery:ignore + public struct SingleWindowViewValue: DefaultableViewValue { + public static var `default`: Self { .init() } + + private init() {} + } + + @MainActor public protocol SingleWindowView: View { + associatedtype Value: DefaultableViewValue = SingleWindowViewValue + init() + } + + extension SingleWindowView { public init(_: Binding) { self.init() } } + + #if os(macOS) || os(iOS) || os(visionOS) + extension WindowGroup { + @MainActor public init(singleOf _: V.Type) + where Content == PresentedWindowContent { + self.init { value in + V(value) + } defaultValue: { + V.Value.default + } + } + } + #endif +#endif diff --git a/Sources/RadiantKit/ViewExtensions/View+GeometryProxy.swift b/Sources/RadiantKit/ViewExtensions/View+GeometryProxy.swift new file mode 100644 index 0000000..ad2fe67 --- /dev/null +++ b/Sources/RadiantKit/ViewExtensions/View+GeometryProxy.swift @@ -0,0 +1,42 @@ +// +// View+GeometryProxy.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import Foundation + + public import SwiftUI + + extension View { + public func onGeometry(_ action: @escaping (GeometryProxy) -> Void) -> some View { + self.overlay { + GeometryReader(content: { geometry in Color.clear.onAppear(perform: { action(geometry) }) }) + } + } + } +#endif diff --git a/Sources/RadiantKit/ViewExtensions/View+Hidden.swift b/Sources/RadiantKit/ViewExtensions/View+Hidden.swift new file mode 100644 index 0000000..bea9046 --- /dev/null +++ b/Sources/RadiantKit/ViewExtensions/View+Hidden.swift @@ -0,0 +1,46 @@ +// +// View+Hidden.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + + public import SwiftUI + + extension View { + public func isHidden(_ value: Bool) -> some View { + Group { + if value { + self.hidden() + } + else { + self + } + } + } + } +#endif diff --git a/Sources/RadiantKit/Views/Button.swift b/Sources/RadiantKit/Views/Button.swift new file mode 100644 index 0000000..7bf607d --- /dev/null +++ b/Sources/RadiantKit/Views/Button.swift @@ -0,0 +1,41 @@ +// +// Button.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import SwiftUI + + extension Button { + public init(_ openURL: OpenURLAction, _ url: URL, @ViewBuilder _ label: () -> Label) { + self.init(action: { openURL.callAsFunction(url) }, label: label) + } + + public init(_ titleKey: LocalizedStringKey, _ openURL: OpenURLAction, _ url: URL) + where Label == Text { self.init(openURL, url) { Text(titleKey) } } + } +#endif diff --git a/Sources/RadiantKit/Views/GuidedLabeledContent.swift b/Sources/RadiantKit/Views/GuidedLabeledContent.swift new file mode 100644 index 0000000..0a26e40 --- /dev/null +++ b/Sources/RadiantKit/Views/GuidedLabeledContent.swift @@ -0,0 +1,69 @@ +// +// GuidedLabeledContent.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + + public import SwiftUI + + public struct GuidedLabeledContent: View { + let content: () -> Content + let label: () -> Label + let description: () -> Description + + public var body: some View { + VStack { + LabeledContent(content: content, label: label) + self.description() + } + } + + public init( + _ content: @escaping () -> Content, + label: @escaping () -> Label, + description: @escaping () -> Description + ) { + self.content = content + self.label = label + self.description = description + } + } + + extension GuidedLabeledContent where Description == GuidedLabeledContentDescriptionView { + public init( + _ content: @escaping () -> Content, + label: @escaping () -> Label, + text: @escaping () -> Text, + descriptionAlignment: GuidedLabeledContentDescriptionView.Alignment? = .leading + ) { + self.init(content, label: label) { + GuidedLabeledContentDescriptionView(alignment: descriptionAlignment, text: text) + } + } + } +#endif diff --git a/Sources/RadiantKit/Views/GuidedLabeledContentDescriptionView.swift b/Sources/RadiantKit/Views/GuidedLabeledContentDescriptionView.swift new file mode 100644 index 0000000..06aae27 --- /dev/null +++ b/Sources/RadiantKit/Views/GuidedLabeledContentDescriptionView.swift @@ -0,0 +1,84 @@ +// +// GuidedLabeledContentDescriptionView.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import SwiftUI + + public struct GuidedLabeledContentDescriptionView: View { + public enum Alignment { + case leading + case trailing + } + + @Environment(\.layoutDirection) var layoutDirection + let text: () -> Text + let alignment: Alignment? + + var multilineTextAlignment: TextAlignment { + switch alignment { case .leading: .leading + + case .trailing: .trailing + + case nil: .center + } + } + + var leftSpacer: Bool { + switch (alignment, layoutDirection) { case (.trailing, .leftToRight): true + + case (.leading, .rightToLeft): true + + default: false + } + } + + var rightSpacer: Bool { + switch (alignment, layoutDirection) { case (.leading, .leftToRight): true + + case (.trailing, .rightToLeft): true + + default: false + } + } + + public var body: some View { + HStack { + if leftSpacer { Spacer() } + text().font(.callout).multilineTextAlignment(self.multilineTextAlignment) + + if rightSpacer { Spacer() } + } + } + + internal init(alignment: Alignment? = nil, text: @escaping () -> Text) { + self.text = text + self.alignment = alignment + } + } +#endif diff --git a/Sources/RadiantKit/Views/PreferredLayoutView.swift b/Sources/RadiantKit/Views/PreferredLayoutView.swift new file mode 100644 index 0000000..a0a9f74 --- /dev/null +++ b/Sources/RadiantKit/Views/PreferredLayoutView.swift @@ -0,0 +1,100 @@ +// +// PreferredLayoutView.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import Foundation + + public import SwiftUI + + public enum PreferredLayout { + public struct Value { + private let value: Value? + + fileprivate let update: (Value) -> Void + + fileprivate init(value: Value?, update: @escaping (Value) -> Void) { + self.value = value + self.update = update + } + + public func get() -> Value? { value } + } + + public struct View: SwiftUI.View { + let reduce: (ValueType, ValueType) -> ValueType + let content: (Value) -> Content + @State private var value: ValueType? + + public var body: some SwiftUI.View { + let valueType = Value(value: self.value) { newValue in + let value = value ?? newValue + self.value = reduce(value, newValue) + } + content(valueType) + } + + public init( + initial: ValueType? = nil, + reduce: @escaping (ValueType, ValueType) -> ValueType, + content: @escaping (Value) -> Content + ) { + self.reduce = reduce + self.content = content + self.value = initial + } + } + } + @available(*, deprecated, renamed: "PreferredLayout.View") public typealias PreferredLayoutView = + PreferredLayout.View + + extension PreferredLayout.View where ValueType: Comparable { + public init(content: @escaping (PreferredLayout.Value) -> Content) { + self.init(reduce: max, content: content) + } + } + + extension View { + public func apply( + _ keyPath: KeyPath, + with valueType: PreferredLayout.Value + ) -> some View { self.apply(keyPath, with: valueType) { Color.clear } } + + public func apply( + _ keyPath: KeyPath, + with valueType: PreferredLayout.Value, + backgroundView: @escaping () -> some View + ) -> some View { + self.background( + GeometryReader { geometry in + backgroundView().onAppear(perform: { valueType.update(geometry[keyPath: keyPath]) }) + } + ) + } + } +#endif diff --git a/Sources/RadiantKit/Views/SliderStepperView.swift b/Sources/RadiantKit/Views/SliderStepperView.swift new file mode 100644 index 0000000..b191016 --- /dev/null +++ b/Sources/RadiantKit/Views/SliderStepperView.swift @@ -0,0 +1,72 @@ +// +// SliderStepperView.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import SwiftUI + + @MainActor public struct SliderStepperView: View { + private let title: TitleType + private let label: @Sendable (TitleType) -> Label + private let bounds: ClosedRange + private let step: Float + private let content: @Sendable @MainActor (TitleType) -> Content + @Binding private var value: Float + + public var body: some View { + LabeledContent { + HStack { + Slider(value: $value, in: bounds, step: step) + self.content(self.title).labelsHidden().frame(width: 50).padding(.horizontal, 6.0) + + Stepper(value: $value, in: bounds, step: 1.0, label: { self.label(self.title) }) + .labelsHidden() + } + } label: { + self.label(self.title) + } + } + + public init( + title: TitleType, + label: @escaping @Sendable (TitleType) -> Label, + value: Binding, + bounds: ClosedRange, + step: Float, + content: @escaping @MainActor @Sendable (TitleType) -> Content + ) { + self.title = title + self.label = label + self._value = value + self.bounds = bounds + self.step = step + self.content = content + } + } + +#endif diff --git a/Sources/RadiantKit/Views/ValueTextBubble.swift b/Sources/RadiantKit/Views/ValueTextBubble.swift new file mode 100644 index 0000000..f929788 --- /dev/null +++ b/Sources/RadiantKit/Views/ValueTextBubble.swift @@ -0,0 +1,90 @@ +// +// ValueTextBubble.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import Foundation + + public import SwiftUI + + public struct ValueTextBubble< + ShapeStyleType: ShapeStyle, + ValueType: Equatable, + FormatStyleType: FormatStyle + >: View where FormatStyleType.FormatInput == ValueType, FormatStyleType.FormatOutput == String { + public let value: ValueType + public let format: FormatStyleType + public let backgroundStyle: ShapeStyleType + public let cornerRadius: CGFloat + + public var body: some View { + Text(value, format: format) + .background { + RoundedRectangle(cornerRadius: cornerRadius).padding(-2).padding(.horizontal, -4) + .foregroundStyle(backgroundStyle) + } + .padding(.horizontal, 2) + } + + public init( + value: ValueType, + format: FormatStyleType, + backgroundStyle: ShapeStyleType, + cornerRadius: CGFloat = 18 + ) { + self.value = value + self.format = format + self.cornerRadius = cornerRadius + self.backgroundStyle = backgroundStyle + } + + public init( + value: ValueType, + format: FormatStyleType, + backgroundColor: Color = Color.primary.opacity(0.25), + cornerRadius: CGFloat = 18 + ) where ShapeStyleType == Color { + self.value = value + self.format = format + self.cornerRadius = cornerRadius + self.backgroundStyle = backgroundColor + } + + public init( + value: Int, + format: IntegerFormatStyle = FormatStyleType.number, + backgroundColor: Color = Color.primary.opacity(0.25), + cornerRadius: CGFloat = 18 + ) where ValueType == Int, ShapeStyleType == Color, FormatStyleType == IntegerFormatStyle { + self.value = value + self.format = format + self.cornerRadius = cornerRadius + self.backgroundStyle = backgroundColor + } + } +#endif diff --git a/Sources/RadiantKit/Views/VerticalLabelStyle.swift b/Sources/RadiantKit/Views/VerticalLabelStyle.swift new file mode 100644 index 0000000..1df8cef --- /dev/null +++ b/Sources/RadiantKit/Views/VerticalLabelStyle.swift @@ -0,0 +1,69 @@ +// +// VerticalLabelStyle.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import SwiftUI + + public struct VerticalLabelStyle: LabeledContentStyle { + let alignment: HorizontalAlignment + let labelFont: Font + let labelPaddingEdgeInsets: EdgeInsets + + public init( + alignment: HorizontalAlignment = .leading, + labelFont: Font = .subheadline, + labelPaddingEdgeInsets: EdgeInsets = .init(top: 0, leading: 4.0, bottom: 0.0, trailing: 0.0) + ) { + self.alignment = alignment + self.labelFont = labelFont + self.labelPaddingEdgeInsets = labelPaddingEdgeInsets + } + + public func makeBody(configuration: Configuration) -> some View { + VStack(alignment: alignment) { + configuration.content.labelsHidden() + configuration.label.font(labelFont).padding(labelPaddingEdgeInsets) + } + } + } + + extension LabeledContentStyle { + public static func vertical( + alignment: HorizontalAlignment = .leading, + labelFont: Font = .subheadline, + labelPaddingEdgeInsets: EdgeInsets = .init(top: 0, leading: 2.0, bottom: 0.0, trailing: 0.0) + ) -> Self where Self == VerticalLabelStyle { + .init( + alignment: alignment, + labelFont: labelFont, + labelPaddingEdgeInsets: labelPaddingEdgeInsets + ) + } + } +#endif diff --git a/Sources/RadiantKit/Views/Video.swift b/Sources/RadiantKit/Views/Video.swift new file mode 100644 index 0000000..a2d3d3d --- /dev/null +++ b/Sources/RadiantKit/Views/Video.swift @@ -0,0 +1,53 @@ +// +// Video.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) && canImport(AppKit) + public import AVKit + + public import SwiftUI + + public struct Video: NSViewRepresentable { + let player: AVPlayer? + + public init(using player: AVPlayer!) { + assert(player != nil) + self.player = player + } + + public func makeNSView(context _: Context) -> AVPlayerView { + let view = AVPlayerView() + view.controlsStyle = .none + view.player = player + player?.play() + return view + } + + public func updateNSView(_: AVPlayerView, context _: Context) {} + } +#endif diff --git a/Sources/RadiantPaging/CancelPageAction.swift b/Sources/RadiantPaging/CancelPageAction.swift new file mode 100644 index 0000000..d634acc --- /dev/null +++ b/Sources/RadiantPaging/CancelPageAction.swift @@ -0,0 +1,46 @@ +// +// CancelPageAction.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import Foundation + public import SwiftUI + + fileprivate struct CancelPageKey: EnvironmentKey, Sendable { + static let defaultValue: CancelPageAction = .default + } + + public typealias CancelPageAction = PageAction + + extension EnvironmentValues { + public var cancelPage: CancelPageAction { + get { self[CancelPageKey.self] } + set { self[CancelPageKey.self] = newValue } + } + } +#endif diff --git a/Sources/RadiantPaging/ContainerView.swift b/Sources/RadiantPaging/ContainerView.swift new file mode 100644 index 0000000..355d866 --- /dev/null +++ b/Sources/RadiantPaging/ContainerView.swift @@ -0,0 +1,100 @@ +// +// ContainerView.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import SwiftUI + + public struct ContainerView: View { + @Environment(\.pageNavigationAvailability) private var pageNavigationAvailability + @Environment(\.previousPage) private var previousPage + @Environment(\.nextPage) private var nextPage + @Environment(\.cancelPage) private var cancel + private let content: (Binding) -> Content + private let label: () -> Label + @State private var isNextReady = false + + private var isPreviousDisabled: Bool { !pageNavigationAvailability.contains(.previous) } + + private var isNextDisabled: Bool { !isNextReady } + + public var body: some View { + VStack { + HStack { + label() + Spacer() + } + Spacer() + HStack { + Spacer() + VStack { + Spacer() + content($isNextReady).padding() + Spacer() + } + Spacer() + } + .border(Color.primary.opacity(0.2)) + Spacer().frame(height: 16) + HStack { + Button { + cancel() + } label: { + Text("Cancel").padding(.horizontal) + } + Spacer() + Button { + previousPage() + } label: { + Text("Previous").padding(.horizontal) + } + .disabled(isPreviousDisabled).opacity(isPreviousDisabled ? 0.8 : 1.0) + Button { + nextPage() + } label: { + Text("Next").padding(.horizontal) + } + .disabled(isNextDisabled).opacity(isNextDisabled ? 0.8 : 1.0) + } + } + .padding() + } + + public init(label: @escaping () -> Label, content: @escaping (Binding) -> Content) { + self.label = label + self.content = content + } + } + + #Preview { + ContainerView( + label: { Text("Hello World") }, + content: { _ in Form { Section { Text("Hello World") } } } + ) + } +#endif diff --git a/Sources/RadiantPaging/DismissParameters.swift b/Sources/RadiantPaging/DismissParameters.swift new file mode 100644 index 0000000..df9ffcc --- /dev/null +++ b/Sources/RadiantPaging/DismissParameters.swift @@ -0,0 +1,48 @@ +// +// DismissParameters.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +public import RadiantKit + +public struct DismissParameters { + #if canImport(SwiftUI) + public typealias PageID = IdentifiableView.ID + #else + public typealias PageID = Int + #endif + public enum Action { + case previous + case next + case cancel + } + + public let currentPageIndex: Int + public let currentPageID: PageID? + public let action: Action +} diff --git a/Sources/RadiantPaging/NextPageAction.swift b/Sources/RadiantPaging/NextPageAction.swift new file mode 100644 index 0000000..70569ef --- /dev/null +++ b/Sources/RadiantPaging/NextPageAction.swift @@ -0,0 +1,57 @@ +// +// NextPageAction.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import Foundation + public import SwiftUI + + fileprivate struct NextPageKey: EnvironmentKey, Sendable { + fileprivate static let defaultValue: NextPageAction = .default + } + + public struct PageAction: Sendable { + internal static let `default`: PageAction = .init { assertionFailure() } + + private let pageFunction: @Sendable @MainActor () -> Void + internal init(_ pageFunction: @Sendable @MainActor @escaping () -> Void) { + self.pageFunction = pageFunction + } + + @MainActor public func callAsFunction() { pageFunction() } + } + + public typealias NextPageAction = PageAction + + extension EnvironmentValues { + public var nextPage: NextPageAction { + get { self[NextPageKey.self] } + set { self[NextPageKey.self] = newValue } + } + } +#endif diff --git a/Sources/RadiantPaging/PageNavigationAvailability.swift b/Sources/RadiantPaging/PageNavigationAvailability.swift new file mode 100644 index 0000000..00bf5d0 --- /dev/null +++ b/Sources/RadiantPaging/PageNavigationAvailability.swift @@ -0,0 +1,61 @@ +// +// PageNavigationAvailability.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import Foundation + public import SwiftUI + + fileprivate struct PageNavigationAvailabilityKey: EnvironmentKey, Sendable { + static let defaultValue: PageNavigationAvailability = .default + } + + public struct PageNavigationAvailability: OptionSet, Sendable { + public typealias RawValue = Int + public static let `default`: PageNavigationAvailability = .none + + public static let none: PageNavigationAvailability = .init() + public static let next: PageNavigationAvailability = .init(rawValue: 1) + public static let previous: PageNavigationAvailability = .init(rawValue: 2) + public static let both: PageNavigationAvailability = [previous, next] + + public let rawValue: Int + + public init(rawValue: RawValue) { + assert(rawValue < 4) + self.rawValue = rawValue + } + } + + extension EnvironmentValues { + public var pageNavigationAvailability: PageNavigationAvailability { + get { self[PageNavigationAvailabilityKey.self] } + set { self[PageNavigationAvailabilityKey.self] = newValue } + } + } +#endif diff --git a/Sources/RadiantPaging/PageView.swift b/Sources/RadiantPaging/PageView.swift new file mode 100644 index 0000000..1d986be --- /dev/null +++ b/Sources/RadiantPaging/PageView.swift @@ -0,0 +1,138 @@ +// +// PageView.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import SwiftUI + public import RadiantKit + + @MainActor public struct PageView: View, Sendable { + @Environment(\.dismiss) private var dismiss + @State private var currentPageID: IdentifiableView.ID? + + private let onDimiss: (@MainActor @Sendable (DismissParameters) -> Void)? + private let pages: [IdentifiableView] + + public var pageNavigationAvailability: PageNavigationAvailability { + switch (currentPageID, currentPageID == pages.first?.id, currentPageID == pages.last?.id) { + case (.none, _, _), (.some, true, true): .none + + case (.some, false, false): .both + + case (.some, false, true): .previous + + case (.some, true, false): .next + } + } + + public var body: some View { + ForEach(pages) { page in + if page.id == currentPageID { + AnyView( + page.environment(\.pageNavigationAvailability, pageNavigationAvailability) + .environment(\.nextPage, NextPageAction { showNextPage() }) + .environment(\.previousPage, PreviousPageAction { showPreviousPage() }) + .environment(\.cancelPage, CancelPageAction { cancelPage() }) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + else if currentPageID == nil, !pages.isEmpty { + Color.clear.onAppear { currentPageID = pages.first?.id } + } + } + } + + public init( + onDismiss: (@MainActor @Sendable (DismissParameters) -> Void)? = nil, + @IdentifiableViewBuilder _ pagesBuilder: () -> [IdentifiableView] + ) { self.init(pages: pagesBuilder(), onDismiss: onDismiss) } + + public init( + pages: [IdentifiableView], + onDismiss: (@Sendable @MainActor (DismissParameters) -> Void)? + ) { + self.pages = pages + onDimiss = onDismiss + + _currentPageID = State(initialValue: pages.first?.id) + } + + private func cancelPage() { + guard let currentIndex = pages.firstIndex(where: { $0.id == self.currentPageID }) else { + return + } + onDimiss?( + .init(currentPageIndex: currentIndex, currentPageID: currentPageID, action: .cancel) + ) + } + + private func showNextPage() { + guard let currentIndex = pages.firstIndex(where: { $0.id == self.currentPageID }) else { + return + } + let nextIndex = currentIndex + 1 + guard nextIndex < pages.count else { + assert(pages.count == nextIndex) + + if pages.count != nextIndex { + // Self.logger.error("Invalid page index \(nextIndex) > \(pages.count)") + } + + onDimiss?( + .init(currentPageIndex: currentIndex, currentPageID: currentPageID, action: .next) + ) + // dismiss() + + return + } + currentPageID = pages[currentIndex + 1].id + } + + private func showPreviousPage() { + guard let currentIndex = pages.firstIndex(where: { $0.id == self.currentPageID }) else { + return + } + let previousIndex = currentIndex - 1 + guard previousIndex >= 0 else { + assert(previousIndex == 0) + + if previousIndex != 0 { + // Self.logger.error("Invalid page index \(nextIndex) > \(pages.count)") + } + + onDimiss?( + .init(currentPageIndex: currentIndex, currentPageID: currentPageID, action: .previous) + ) + dismiss() + + return + } + currentPageID = pages[currentIndex - 1].id + } + } +#endif diff --git a/Sources/RadiantPaging/PreviousPageAction.swift b/Sources/RadiantPaging/PreviousPageAction.swift new file mode 100644 index 0000000..2a8afdb --- /dev/null +++ b/Sources/RadiantPaging/PreviousPageAction.swift @@ -0,0 +1,46 @@ +// +// PreviousPageAction.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import Foundation + public import SwiftUI + + fileprivate struct PreviousPageKey: EnvironmentKey, Sendable { + static let defaultValue: PreviousPageAction = .default + } + + public typealias PreviousPageAction = PageAction + + extension EnvironmentValues { + public var previousPage: PreviousPageAction { + get { self[PreviousPageKey.self] } + set { self[PreviousPageKey.self] = newValue } + } + } +#endif diff --git a/Sources/RadiantProgress/CopyOperation.swift b/Sources/RadiantProgress/CopyOperation.swift new file mode 100644 index 0000000..712c1ac --- /dev/null +++ b/Sources/RadiantProgress/CopyOperation.swift @@ -0,0 +1,139 @@ +// +// CopyOperation.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Observation) + public import Foundation + public import Observation + + #if canImport(OSLog) + public import OSLog + #else + public import Logging + #endif + + @MainActor @Observable + public final class CopyOperation: Identifiable { + private let sourceURL: URL + private let destinationURL: URL + public let totalValue: ValueType? + private let timeInterval: TimeInterval + private nonisolated let getSize: @Sendable (URL) throws -> ValueType? + private let copyFile: @Sendable (CopyPaths) async throws -> Void + public var currentValue = ValueType.zero + private var timer: Timer? + private let logger: Logger? + + public nonisolated var id: URL { sourceURL } + + public init( + sourceURL: URL, + destinationURL: URL, + totalValue: ValueType?, + timeInterval: TimeInterval, + logger: Logger?, + getSize: @escaping @Sendable (URL) throws -> ValueType?, + copyFile: @escaping @Sendable (CopyPaths) async throws -> Void + ) { + self.sourceURL = sourceURL + self.destinationURL = destinationURL + self.totalValue = totalValue + self.timeInterval = timeInterval + self.getSize = getSize + self.copyFile = copyFile + self.logger = logger + } + + private nonisolated func updateValue(_ currentValue: ValueType) { + Task { await self.updatingValue(currentValue) } + } + + private func updatingValue(_ currentValue: ValueType) { + self.currentValue = currentValue + if let totalValue = self.totalValue { + guard self.currentValue < totalValue else { + self.logger?.debug("Copy is complete based on size. Quitting timer.") + self.killTimer() + return + } + } + else { + self.logger?.warning("Total size is missing") + } + } + + private func starTimer() { + timer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: true) { + [weak self] timer in + guard let weakSelf = self else { + timer.invalidate() + return + } + weakSelf.logger?.debug("Timer On") + assert(weakSelf.logger != nil) + Task { + let currentValue: ValueType? + do { currentValue = try weakSelf.getSize(weakSelf.destinationURL) } + catch { + weakSelf.logger?.error("Unable to get size: \(error)") + assertionFailure("Unable to get size: \(error)") + currentValue = nil + } + if let currentValue { + weakSelf.updateValue(currentValue) + weakSelf.logger?.debug("Updating size to: \(currentValue)") + } + else { + weakSelf.logger?.warning("Unable to get size") + } + } + } + } + + public func execute() async throws { + self.logger?.debug("Starting Copy operating") + await starTimer() + do { try await self.copyFile(.init(fromURL: sourceURL, toURL: destinationURL)) } + catch { + self.logger?.error("Error Copying: \(error)") + self.killTimer() + throw error + } + self.logger?.debug("Copy is done. Quitting timer.") + self.killTimer() + } + + private func killTimer() { + timer?.invalidate() + timer = nil + } + } + + extension CopyOperation: ProgressOperation {} + +#endif diff --git a/Sources/RadiantProgress/CopyPaths.swift b/Sources/RadiantProgress/CopyPaths.swift new file mode 100644 index 0000000..b434952 --- /dev/null +++ b/Sources/RadiantProgress/CopyPaths.swift @@ -0,0 +1,35 @@ +// +// CopyPaths.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +public struct CopyPaths { + public let fromURL: URL + public let toURL: URL +} diff --git a/Sources/RadiantProgress/DownloadOperation.swift b/Sources/RadiantProgress/DownloadOperation.swift new file mode 100644 index 0000000..02f0ee3 --- /dev/null +++ b/Sources/RadiantProgress/DownloadOperation.swift @@ -0,0 +1,87 @@ +// +// DownloadOperation.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Observation) + public import Foundation + public import Observation + + #if canImport(FoundationNetworking) + public import FoundationNetworking + #endif + + @MainActor @Observable + public final class DownloadOperation: + + Identifiable, ProgressOperation, Sendable + { + private let download: Downloader + private let sourceURL: URL + private let destinationURL: URL + + public nonisolated var id: URL { sourceURL } + + public var currentValue: ValueType { .init(download.totalBytesWritten) } + + public var totalValue: ValueType? { download.totalBytesExpectedToWrite.map(ValueType.init(_:)) } + + #if canImport(Combine) + public init( + sourceURL: URL, + destinationURL: URL, + totalBytesExpectedToWrite: ValueType?, + configuration: URLSessionConfiguration? = nil, + queue: OperationQueue? = nil + ) { + assert(!sourceURL.isFileURL) + assert(totalBytesExpectedToWrite != nil) + self.sourceURL = sourceURL + self.destinationURL = destinationURL + self.download = ObservableDownloader( + totalBytesExpectedToWrite: totalBytesExpectedToWrite, + configuration: configuration, + queue: queue + ) + } + #endif + public init(sourceURL: URL, destinationURL: URL, download: Downloader) { + self.download = download + self.sourceURL = sourceURL + self.destinationURL = destinationURL + } + + public func execute() async throws { + try await withCheckedThrowingContinuation { continuation in + self.download.begin(from: self.sourceURL, to: self.destinationURL) { result in + continuation.resume(with: result) + } + } + } + } + +#endif diff --git a/Sources/RadiantProgress/DownloadUpdate.swift b/Sources/RadiantProgress/DownloadUpdate.swift new file mode 100644 index 0000000..4bbec4b --- /dev/null +++ b/Sources/RadiantProgress/DownloadUpdate.swift @@ -0,0 +1,33 @@ +// +// DownloadUpdate.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal struct DownloadUpdate: Sendable { + internal let totalBytesWritten: Int64 + internal let totalBytesExpectedToWrite: Int64? +} diff --git a/Sources/RadiantProgress/Downloader.swift b/Sources/RadiantProgress/Downloader.swift new file mode 100644 index 0000000..f8c83bb --- /dev/null +++ b/Sources/RadiantProgress/Downloader.swift @@ -0,0 +1,44 @@ +// +// Downloader.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +#if canImport(FoundationNetworking) + public import FoundationNetworking +#endif + +@MainActor public protocol Downloader { + var totalBytesWritten: Int64 { get } + var totalBytesExpectedToWrite: Int64? { get } + func begin( + from downloadSourceURL: URL, + to destinationFileURL: URL, + _ completion: @escaping @Sendable (Result) -> Void + ) +} diff --git a/Sources/RadiantProgress/FileOperationProgress.swift b/Sources/RadiantProgress/FileOperationProgress.swift new file mode 100644 index 0000000..e0993da --- /dev/null +++ b/Sources/RadiantProgress/FileOperationProgress.swift @@ -0,0 +1,50 @@ +// +// FileOperationProgress.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Observation) + public import Foundation + public import Observation + + @MainActor @Observable + public final class FileOperationProgress: Identifiable { + public let operation: any ProgressOperation + + public nonisolated var id: URL { operation.id } + + public var totalValueBytes: Int64? { operation.totalValue.map(Int64.init) } + + public var currentValueBytes: Int64 { Int64(operation.currentValue) } + + internal var currentValue: Double { Double(operation.currentValue) } + + internal var totalValue: Double? { operation.totalValue.map(Double.init) } + + public init(_ operation: any ProgressOperation) { self.operation = operation } + } +#endif diff --git a/Sources/RadiantProgress/ObservableDownloader.swift b/Sources/RadiantProgress/ObservableDownloader.swift new file mode 100644 index 0000000..c3e99f5 --- /dev/null +++ b/Sources/RadiantProgress/ObservableDownloader.swift @@ -0,0 +1,234 @@ +// +// ObservableDownloader.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Combine) && canImport(Observation) + public import Combine + + public import Foundation + + fileprivate protocol DownloadObserver { + func finishedDownloadingTo(_ location: URL) + func progressUpdated(_ progress: DownloadUpdate) + func didComplete(withError error: Error?) + } + + private actor ObserverContainer: Sendable { + private nonisolated(unsafe) var observer: DownloadObserver? + + nonisolated func setObserver(_ observer: DownloadObserver) { + assert(self.observer == nil) + self.observer = observer + } + + nonisolated func on(_ closure: @escaping @Sendable (DownloadObserver) -> Void) { + Task { await self.withObserver(closure) } + } + + private func withObserver(_ closure: @escaping @Sendable (DownloadObserver) -> Void) { + assert(self.observer != nil) + guard let observer else { return } + + closure(observer) + } + } + + fileprivate final class DownloadDelegate: NSObject, URLSessionDownloadDelegate { + let container = ObserverContainer() + + func setObserver(_ observer: DownloadObserver) { self.container.setObserver(observer) } + + func urlSession( + _: URLSession, + downloadTask _: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + let newLocation = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString).appendingPathExtension(location.pathExtension) + do { try FileManager.default.copyItem(at: location, to: newLocation) } + catch { + container.on { observer in observer.didComplete(withError: error) } + return + } + container.on { observer in observer.finishedDownloadingTo(newLocation) } + } + + func urlSession( + _: URLSession, + downloadTask _: URLSessionDownloadTask, + didWriteData _: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + container.on { observer in + observer.progressUpdated( + .init( + totalBytesWritten: totalBytesWritten, + totalBytesExpectedToWrite: totalBytesExpectedToWrite + ) + ) + } + } + + func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: (any Error)?) + { container.on { observer in observer.didComplete(withError: error) } } + } + + @Observable @MainActor public final class ObservableDownloader: DownloadObserver, Downloader { + internal struct DownloadRequest { + internal let downloadSourceURL: URL + internal let destinationFileURL: URL + } + + // swift-format-ignore: NeverUseImplicitlyUnwrappedOptionals + @ObservationIgnored private var delegate: DownloadDelegate! + + public internal(set) var totalBytesWritten: Int64 = 0 + public internal(set) var totalBytesExpectedToWrite: Int64? + + // swift-format-ignore: NeverUseImplicitlyUnwrappedOptionals + @ObservationIgnored internal private(set) var session: URLSession! + + internal let resumeDataSubject = PassthroughSubject() + internal var task: URLSessionDownloadTask? + + private var cancellables = [AnyCancellable]() + internal let requestSubject = PassthroughSubject() + internal let locationURLSubject = PassthroughSubject() + internal let downloadUpdate = PassthroughSubject() + private var completion: ((Result) -> Void)? + + private let formatter = ByteCountFormatter() + + public var prettyBytesWritten: String { + formatter.string(from: .init(value: .init(totalBytesWritten), unit: .bytes)) + } + + public convenience init( + totalBytesExpectedToWrite: (some BinaryInteger)?, + configuration: URLSessionConfiguration? = nil, + queue: OperationQueue? = nil + ) { + self.init( + totalBytesExpectedToWrite: totalBytesExpectedToWrite, + setupPublishers: .init(), + configuration: configuration, + queue: queue + ) + } + internal convenience init( + totalBytesExpectedToWrite: (some BinaryInteger)?, + setupPublishers: SetupPublishers, + configuration: URLSessionConfiguration? = nil, + queue: OperationQueue? = nil + ) { + self.init( + totalBytesExpectedToWrite: totalBytesExpectedToWrite, + setupPublishers: setupPublishers.callAsFunction(downloader:), + configuration: configuration, + queue: queue + ) + } + + public init( + totalBytesExpectedToWrite: (some BinaryInteger)?, + setupPublishers: @escaping (ObservableDownloader) -> [AnyCancellable], + configuration: URLSessionConfiguration? = nil, + queue: OperationQueue? = nil + ) { + self.totalBytesExpectedToWrite = totalBytesExpectedToWrite.map(Int64.init(_:)) + + let delegate = DownloadDelegate() + self.session = URLSession( + configuration: configuration ?? .default, + delegate: delegate, + delegateQueue: queue + ) + + self.delegate = delegate + + self.cancellables = setupPublishers(self) + } + + func onCompletion(_ result: Result) { + assert(completion != nil) + completion?(result) + } + + public func cancel() { task?.cancel() } + + public func begin( + from downloadSourceURL: URL, + to destinationFileURL: URL, + _ completion: @escaping @Sendable (Result) -> Void + ) { + #warning("Requires Testing") + self.delegate.setObserver(self) + assert(self.completion == nil) + self.completion = completion + requestSubject.send( + .init(downloadSourceURL: downloadSourceURL, destinationFileURL: destinationFileURL) + ) + } + + nonisolated func finishedDownloadingTo(_ location: URL) { + Task { @MainActor in self.finishedDownloadingToAsync(location) } + } + + nonisolated func progressUpdated(_ progress: DownloadUpdate) { + Task { @MainActor in self.progressUpdatedAsync(progress) } + } + + nonisolated func didComplete(withError error: (any Error)?) { + Task { @MainActor in self.didCompleteAsync(withError: error) } + } + + func finishedDownloadingToAsync(_ location: URL) { locationURLSubject.send(location) } + + func progressUpdatedAsync(_ progress: DownloadUpdate) { self.downloadUpdate.send(progress) } + + func didCompleteAsync(withError error: (any Error)?) { + guard let error else { + // Handle success case. + return + } + let userInfo = (error as NSError).userInfo + if let resumeData = userInfo[NSURLSessionDownloadTaskResumeData] as? Data { + resumeDataSubject.send(resumeData) + } + } + deinit { + MainActor.assumeIsolated { + for cancellable in self.cancellables { cancellable.cancel() } + self.cancellables.removeAll() + self.session = nil + self.task = nil + } + } + } +#endif diff --git a/Sources/RadiantProgress/PreviewOperation.swift b/Sources/RadiantProgress/PreviewOperation.swift new file mode 100644 index 0000000..34be65c --- /dev/null +++ b/Sources/RadiantProgress/PreviewOperation.swift @@ -0,0 +1,52 @@ +// +// PreviewOperation.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +@MainActor public struct PreviewOperation: ProgressOperation { + public let currentValue: ValueType + + public let totalValue: ValueType? + + public let id: URL + + public init(currentValue: ValueType, totalValue: ValueType?, id: URL) { + self.currentValue = currentValue + self.totalValue = totalValue + self.id = id + } + + public func execute() async throws {} + + public func cancel() {} +} + +#if canImport(FoundationNetworking) + extension URL: @unchecked Sendable {} +#endif diff --git a/Sources/RadiantProgress/ProgressOperation.swift b/Sources/RadiantProgress/ProgressOperation.swift new file mode 100644 index 0000000..43608ee --- /dev/null +++ b/Sources/RadiantProgress/ProgressOperation.swift @@ -0,0 +1,55 @@ +// +// ProgressOperation.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +@MainActor public protocol ProgressOperation: Identifiable, Sendable where ID == URL { + associatedtype ValueType: BinaryInteger & Sendable + var currentValue: ValueType { get } + var totalValue: ValueType? { get } + func execute() async throws +} + +extension ProgressOperation { + public func percentValue(withFractionDigits fractionDigits: Int = 0) -> String? { + guard let totalValue else { + #warning("logging-note: should we log something here?") + return nil + } + let formatter = NumberFormatter() + formatter.maximumFractionDigits = fractionDigits + formatter.minimumFractionDigits = fractionDigits + let ratioValue = Double(currentValue) / Double(totalValue) * 100.0 + let string = formatter.string(from: .init(value: ratioValue)) + + #warning("logging-note: let's log the calculated percent value if not done somewhere") + assert(string != nil) + return string + } +} diff --git a/Sources/RadiantProgress/ProgressOperationProperties.swift b/Sources/RadiantProgress/ProgressOperationProperties.swift new file mode 100644 index 0000000..957ee90 --- /dev/null +++ b/Sources/RadiantProgress/ProgressOperationProperties.swift @@ -0,0 +1,66 @@ +// +// ProgressOperationProperties.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Observation) && (os(macOS) || os(iOS)) + public import Foundation + + public struct ProgressOperationProperties: Identifiable, Sendable { + internal let imageName: String + internal let text: any (StringProtocol & Sendable) + internal let progress: FileOperationProgress + + public var id: URL { progress.id } + + public init( + imageName: String, + text: any (StringProtocol & Sendable), + progress: FileOperationProgress + ) { + self.imageName = imageName + self.text = text + self.progress = progress + } + } + + #if canImport(SwiftUI) + extension ProgressOperationView { + public typealias Properties = ProgressOperationProperties + public init( + _ properties: Properties, + text: @escaping (FileOperationProgress) -> ProgressText, + image: @escaping (String) -> Icon + ) { + self.init(progress: properties.progress, title: properties.text, text: text) { + image(properties.imageName) + } + } + } + #endif + +#endif diff --git a/Sources/RadiantProgress/ProgressOperationView.swift b/Sources/RadiantProgress/ProgressOperationView.swift new file mode 100644 index 0000000..0cbec60 --- /dev/null +++ b/Sources/RadiantProgress/ProgressOperationView.swift @@ -0,0 +1,75 @@ +// +// ProgressOperationView.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + public import SwiftUI + + public struct ProgressOperationView: View { + private let progress: FileOperationProgress + private let title: any StringProtocol + private let text: (FileOperationProgress) -> ProgressText + private let icon: () -> Icon + + public var body: some View { + VStack { + HStack { + icon() // .accessibilityIdentifier(Progress.icon.identifier) + VStack(alignment: .leading) { + Text(title).lineLimit(1).font(.title) + // .accessibilityIdentifier(Progress.title.identifier) + .accessibilityLabel(title) + HStack { + if let totalValue = progress.totalValue { + ProgressView(value: progress.currentValue, total: totalValue) + } + else { + ProgressView(value: progress.currentValue) + } + } + // .accessibilityIdentifier(Progress.view.identifier) + text(progress) + } + } + } + .padding() + } + + public init( + progress: FileOperationProgress, + title: any StringProtocol, + text: @escaping (FileOperationProgress) -> ProgressText, + icon: @escaping () -> Icon + ) { + self.progress = progress + self.title = title + self.text = text + self.icon = icon + } + } +#endif diff --git a/Sources/RadiantProgress/SetupPublishers.swift b/Sources/RadiantProgress/SetupPublishers.swift new file mode 100644 index 0000000..4acf6d5 --- /dev/null +++ b/Sources/RadiantProgress/SetupPublishers.swift @@ -0,0 +1,96 @@ +// +// SetupPublishers.swift +// RadiantKit +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Combine) + public import Combine + + public import Foundation + + #warning( + "logging-note: can we have some operators for logging the recieved stuff in these subscriptions" + ) + internal struct SetupPublishers { + public init() {} + + @MainActor private func setupDownloadPublsihers(_ downloader: ObservableDownloader) + -> [AnyCancellable] + { + var cancellables = [AnyCancellable]() + + downloader.requestSubject.share() + .map { downloadRequest -> URLSessionDownloadTask in + let task = downloader.session.downloadTask(with: downloadRequest.downloadSourceURL) + task.resume() + return task + } + .assign(to: \.task, on: downloader).store(in: &cancellables) + + downloader.resumeDataSubject + .map { resumeData in + let task = downloader.session.downloadTask(withResumeData: resumeData) + task.resume() + return task + } + .assign(to: \.task, on: downloader).store(in: &cancellables) + + return cancellables + } + + @MainActor private func setupByteUpdatPublishers(_ downloader: ObservableDownloader) + -> [AnyCancellable] + { + var cancellables = [AnyCancellable]() + let downloadUpdate = downloader.downloadUpdate.share() + downloadUpdate.map(\.totalBytesWritten).assign(to: \.totalBytesWritten, on: downloader) + .store(in: &cancellables) + downloadUpdate.map(\.totalBytesExpectedToWrite) + .assign(to: \.totalBytesExpectedToWrite, on: downloader).store(in: &cancellables) + return cancellables + } + + @MainActor internal func callAsFunction(downloader: ObservableDownloader) -> [AnyCancellable] { + var cancellables = [AnyCancellable]() + + let destinationFileURLPublisher = downloader.requestSubject.share().map(\.destinationFileURL) + + cancellables.append(contentsOf: setupDownloadPublsihers(downloader)) + + Publishers.CombineLatest(downloader.locationURLSubject, destinationFileURLPublisher) + .map { sourceURL, destinationURL in + Result { try FileManager.default.moveItem(at: sourceURL, to: destinationURL) } + } + .receive(on: DispatchQueue.main).sink(receiveValue: downloader.onCompletion) + .store(in: &cancellables) + + cancellables.append(contentsOf: setupByteUpdatPublishers(downloader)) + + return cancellables + } + } +#endif diff --git a/Tests/RadiantKitTests/RadiantKitTests.swift b/Tests/RadiantKitTests/RadiantKitTests.swift new file mode 100644 index 0000000..e1f4214 --- /dev/null +++ b/Tests/RadiantKitTests/RadiantKitTests.swift @@ -0,0 +1,16 @@ +// +// Test.swift +// RadiantKit +// +// Created by Leo Dion on 9/9/24. +// + +import Testing + +struct RadiantKitTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..951b97b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "Tests" diff --git a/macros.json b/macros.json new file mode 100644 index 0000000..06a5065 --- /dev/null +++ b/macros.json @@ -0,0 +1,7 @@ +[ + { + "fingerprint" : "c55848b2aa4b29a4df542b235dfdd792a6fbe341", + "packageIdentity" : "swift-testing", + "targetName" : "TestingMacros" + } +] \ No newline at end of file diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..ac1e75f --- /dev/null +++ b/project.yml @@ -0,0 +1,13 @@ +name: RadiantKit +settings: + LINT_MODE: ${LINT_MODE} +packages: + RadiantKit: + path: . +aggregateTargets: + Lint: + buildScripts: + - path: Scripts/lint.sh + name: Lint + basedOnDependencyAnalysis: false + schemes: {}