From e989bea14e2719d56670932f6454237e15866a5f Mon Sep 17 00:00:00 2001 From: david-swift Date: Mon, 2 Dec 2024 22:00:57 +0100 Subject: [PATCH] Initial commit --- .gitea/ISSUE_TEMPLATE/bug_report.yml | 40 ++++ .gitea/ISSUE_TEMPLATE/component_request.yml | 29 +++ .gitea/ISSUE_TEMPLATE/feature_request.yml | 36 ++++ .gitea/PULL_REQUEST_TEMPLATE.md | 14 ++ .gitea/workflows/docs.yml | 34 ++++ .gitea/workflows/swiftlint.yml | 30 +++ .gitignore | 14 ++ .swiftlint.yml | 155 ++++++++++++++++ Bundler.toml | 4 + LICENSE.md | 23 +++ Package.swift | 41 ++++ README.md | 35 ++++ Sources/Demo/Demo.swift | 121 ++++++++++++ .../MacBackend.docc/Documentation.md | 7 + Sources/MacBackend/MacBackend.docc/SwiftUI.md | 59 ++++++ Sources/MacBackend/Menu/Divider.swift | 42 +++++ Sources/MacBackend/Menu/Menu.swift | 71 +++++++ Sources/MacBackend/Menu/MenuButton.swift | 145 +++++++++++++++ Sources/MacBackend/Menu/MenuCollection.swift | 85 +++++++++ Sources/MacBackend/Menu/MenuContext.swift | 21 +++ Sources/MacBackend/Menu/MenuEitherView.swift | 23 +++ Sources/MacBackend/Menu/ServicesMenu.swift | 61 ++++++ .../MacBackend/Model/Enumerations/Edge.swift | 48 +++++ .../MacBackend/Model/Enumerations/Font.swift | 64 +++++++ .../MacBackend/Model/Enumerations/Icon.swift | 24 +++ .../Model/Enumerations/KeyboardShortcut.swift | 115 ++++++++++++ .../Model/Extensions/Meta.Binding.swift | 22 +++ Sources/MacBackend/Model/Extensions/Set.swift | 65 +++++++ Sources/MacBackend/Model/MacApp.swift | 102 ++++++++++ Sources/MacBackend/Model/MacMainView.swift | 18 ++ .../MacBackend/Model/MacSceneElement.swift | 9 + Sources/MacBackend/Model/MacWidget.swift | 9 + .../Model/SwiftUI/MacBackendView.swift | 38 ++++ .../Model/SwiftUI/SwiftUIWidget.swift | 175 ++++++++++++++++++ Sources/MacBackend/View/Alert.swift | 164 ++++++++++++++++ Sources/MacBackend/View/Button.swift | 64 +++++++ Sources/MacBackend/View/EitherView.swift | 48 +++++ Sources/MacBackend/View/Label.swift | 39 ++++ Sources/MacBackend/View/List.swift | 51 +++++ .../MacBackend/View/NavigationSplitView.swift | 46 +++++ Sources/MacBackend/View/PaddingView.swift | 46 +++++ Sources/MacBackend/View/ScrollView.swift | 36 ++++ Sources/MacBackend/View/Spacer.swift | 23 +++ Sources/MacBackend/View/Text.swift | 56 ++++++ Sources/MacBackend/View/VStack.swift | 71 +++++++ Sources/MacBackend/Window/MenuBar.swift | 88 +++++++++ Sources/MacBackend/Window/Window.swift | 157 ++++++++++++++++ 47 files changed, 2668 insertions(+) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .gitea/ISSUE_TEMPLATE/component_request.yml create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitea/workflows/docs.yml create mode 100644 .gitea/workflows/swiftlint.yml create mode 100644 .gitignore create mode 100644 .swiftlint.yml create mode 100644 Bundler.toml create mode 100644 LICENSE.md create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/Demo/Demo.swift create mode 100644 Sources/MacBackend/MacBackend.docc/Documentation.md create mode 100644 Sources/MacBackend/MacBackend.docc/SwiftUI.md create mode 100644 Sources/MacBackend/Menu/Divider.swift create mode 100644 Sources/MacBackend/Menu/Menu.swift create mode 100644 Sources/MacBackend/Menu/MenuButton.swift create mode 100644 Sources/MacBackend/Menu/MenuCollection.swift create mode 100644 Sources/MacBackend/Menu/MenuContext.swift create mode 100644 Sources/MacBackend/Menu/MenuEitherView.swift create mode 100644 Sources/MacBackend/Menu/ServicesMenu.swift create mode 100644 Sources/MacBackend/Model/Enumerations/Edge.swift create mode 100644 Sources/MacBackend/Model/Enumerations/Font.swift create mode 100644 Sources/MacBackend/Model/Enumerations/Icon.swift create mode 100644 Sources/MacBackend/Model/Enumerations/KeyboardShortcut.swift create mode 100644 Sources/MacBackend/Model/Extensions/Meta.Binding.swift create mode 100644 Sources/MacBackend/Model/Extensions/Set.swift create mode 100644 Sources/MacBackend/Model/MacApp.swift create mode 100644 Sources/MacBackend/Model/MacMainView.swift create mode 100644 Sources/MacBackend/Model/MacSceneElement.swift create mode 100644 Sources/MacBackend/Model/MacWidget.swift create mode 100644 Sources/MacBackend/Model/SwiftUI/MacBackendView.swift create mode 100644 Sources/MacBackend/Model/SwiftUI/SwiftUIWidget.swift create mode 100644 Sources/MacBackend/View/Alert.swift create mode 100644 Sources/MacBackend/View/Button.swift create mode 100644 Sources/MacBackend/View/EitherView.swift create mode 100644 Sources/MacBackend/View/Label.swift create mode 100644 Sources/MacBackend/View/List.swift create mode 100644 Sources/MacBackend/View/NavigationSplitView.swift create mode 100644 Sources/MacBackend/View/PaddingView.swift create mode 100644 Sources/MacBackend/View/ScrollView.swift create mode 100644 Sources/MacBackend/View/Spacer.swift create mode 100644 Sources/MacBackend/View/Text.swift create mode 100644 Sources/MacBackend/View/VStack.swift create mode 100644 Sources/MacBackend/Window/MenuBar.swift create mode 100644 Sources/MacBackend/Window/Window.swift diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.yml b/.gitea/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..ee1a591 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,40 @@ +name: Bug report +description: Something is not working as expected. +title: Description of the bug +labels: bug + +body: + - type: textarea + attributes: + label: Describe the bug + description: >- + A clear and concise description of what the bug is. + validations: + required: true + + - type: textarea + attributes: + label: To Reproduce + description: >- + Steps to reproduce the behavior. + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + + - type: textarea + attributes: + label: Expected behavior + description: >- + A clear and concise description of what you expected to happen. + validations: + required: true + + - type: textarea + attributes: + label: Additional context + description: >- + Add any other context about the problem here. diff --git a/.gitea/ISSUE_TEMPLATE/component_request.yml b/.gitea/ISSUE_TEMPLATE/component_request.yml new file mode 100644 index 0000000..6003cba --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/component_request.yml @@ -0,0 +1,29 @@ +name: Components request +description: Suggest an idea for a new component +title: Description of the component request +labels: enhancement + +body: + - type: textarea + attributes: + label: Why would you like to add a new component? + placeholder: >- + A clear and concise description of why the component should be added. + validations: + required: false + + - type: textarea + attributes: + label: Describe your idea for the implementation. + placeholder: >- + What could the implementation be like in the MacBackend? + validations: + required: false + + - type: textarea + attributes: + label: Additional context + placeholder: >- + Add any other context about the component request here. + validations: + required: false diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.yml b/.gitea/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..c244dbb --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,36 @@ +name: Feature request +description: Suggest an idea for this project +title: Description of the feature request +labels: enhancement + +body: + - type: input + attributes: + label: Is your feature request related to a problem? Please describe. + placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + validations: + required: false + + - type: textarea + attributes: + label: Describe the solution you'd like + placeholder: >- + A clear and concise description of what you want to happen. + validations: + required: true + + - type: textarea + attributes: + label: Describe alternatives you've considered + placeholder: >- + A clear and concise description of any alternative solutions or features you've considered. + validations: + required: true + + - type: textarea + attributes: + label: Additional context + placeholder: >- + Add any other context or screenshots about the feature request here. + validations: + required: true diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f0ed3f3 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Steps +- [ ] Add your name or username and a link to your GitHub profile into the [Contributors.md][1] file. +- [ ] Build the project on your machine. If it does not compile, fix the errors. +- [ ] Describe the purpose and approach of your pull request below. +- [ ] Submit the pull request. Thank you very much for your contribution! + +## Purpose +_Describe the problem or feature._ +_If there is a related issue, add the link._ + +## Approach +_Describe how this pull request solves the problem or adds the feature._ + +[1]: /Contributors.md diff --git a/.gitea/workflows/docs.yml b/.gitea/workflows/docs.yml new file mode 100644 index 0000000..b1aa4be --- /dev/null +++ b/.gitea/workflows/docs.yml @@ -0,0 +1,34 @@ +name: Deploy Docs + +on: + push: + branches: ["main"] + +jobs: + publish: + runs-on: david-macbook + steps: + - uses: actions/checkout@v4 + - name: Build Docs + run: | + xcrun xcodebuild docbuild \ + -scheme MacBackend \ + -destination 'generic/platform=macOS' \ + -derivedDataPath "$PWD/.derivedData" \ + -skipPackagePluginValidation + xcrun docc process-archive transform-for-static-hosting \ + "$PWD/.derivedData/Build/Products/Debug/MacBackend.doccarchive" \ + --output-path "docs" \ + --hosting-base-path "/" + - name: Modify Docs + run: | + echo "

Please enable JavaScript to view the documentation here.

" > docs/index.html; + sed -i '' 's/,2px/,10px/g' docs/css/index.*.css + - name: Upload + uses: wangyucode/sftp-upload-action@v2.0.2 + with: + host: 'volans.uberspace.de' + username: 'akforum' + password: ${{ secrets.password }} + localDir: 'docs' + remoteDir: '/var/www/virtual/akforum/macbackend.aparoksha.dev/' diff --git a/.gitea/workflows/swiftlint.yml b/.gitea/workflows/swiftlint.yml new file mode 100644 index 0000000..5348bdb --- /dev/null +++ b/.gitea/workflows/swiftlint.yml @@ -0,0 +1,30 @@ +name: SwiftLint + +on: + push: + paths: + - '.github/workflows/swiftlint.yml' + - '.swiftlint.yml' + - '**/*.swift' + pull_request: + paths: + - '.github/workflows/swiftlint.yml' + - '.swiftlint.yml' + - '**/*.swift' + workflow_dispatch: + paths: + - '.github/workflows/swiftlint.yml' + - '.swiftlint.yml' + - '**/*.swift' + +jobs: + SwiftLint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: SwiftLint + uses: norio-nomura/action-swiftlint@3.2.1 + with: + args: --strict + env: + WORKING_DIRECTORY: Source diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..477926e --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm +.netrc +/Package.resolved +.Ulysses-Group.plist +/.docc-build +/io.github.AparokshaUI.Generation.json +/io.github.AparokshaUI.swiftlint.json +/.vscode diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..ca8b7fd --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,155 @@ +# Opt-In Rules +opt_in_rules: + - anonymous_argument_in_multiline_closure + - array_init + - attributes + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - comma_inheritance + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_none_name + - discouraged_object_literal + - empty_collection_literal + - empty_count + - empty_string + - enum_case_associated_values_count + - explicit_init + - fallthrough + - file_header + - file_name + - file_name_no_space + - first_where + - flatmap_over_map_reduce + - force_unwrapping + - function_default_parameter_at_end + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - joined_default_parameter + - last_where + - legacy_multiple + - let_var_whitespace + - literal_expression_end_indentation + - local_doc_comment + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - multiline_parameters_brackets + - no_extension_access_modifier + - no_grouping_extension + - number_separator + - operator_usage_whitespace + - optional_enum_case_matching + - prefer_self_in_static_references + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - prohibited_interface_builder + - redundant_nil_coalescing + - redundant_type_annotation + - return_value_from_void_function + - shorthand_optional_binding + - sorted_first_last + - sorted_imports + - static_operator + - strict_fileprivate + - switch_case_on_newline + - toggle_bool + - trailing_closure + - type_contents_order + - unneeded_parentheses_in_closure_argument + - yoda_condition + +# Disabled Rules +disabled_rules: + - block_based_kvo + - class_delegate_protocol + - dynamic_inline + - is_disjoint + - no_fallthrough_only + - notification_center_detachment + - ns_number_init_as_function_reference + - nsobject_prefer_isequal + - private_over_fileprivate + - redundant_objc_attribute + - self_in_property_initialization + - todo + - unavailable_condition + - valid_ibinspectable + - xctfail_message + +# Custom Rules +custom_rules: + fatal_error: + name: 'Fatal Error' + regex: 'fatalError.*\(.*\)' + message: 'Fatal error should not be used.' + severity: error + + enum_case_parameter: + name: 'Enum Case Parameter' + regex: 'case [a-zA-Z0-9]*\([a-zA-Z0-9\.<>?,\n\t =]+\)' + message: 'The associated values of an enum case should have parameters.' + severity: warning + + tab: + name: 'Whitespaces Instead of Tab' + regex: '\t' + message: 'Spaces should be used instead of tabs.' + severity: warning + + # Thanks to the creator of the SwiftLint rule + # "empty_first_line" + # https://github.com/coteditor/CotEditor/blob/main/.swiftlint.yml + # in the GitHub repository + # "CotEditor" + # https://github.com/coteditor/CotEditor + empty_first_line: + name: 'Empty First Line' + regex: '(^[ a-zA-Z ]*(?:protocol|extension|class|struct) (?!(?:var|let))[ a-zA-Z:]*\{\n *\S+)' + message: 'There should be an empty line after a declaration' + severity: error + +# Analyzer Rules +analyzer_rules: + - unused_declaration + - unused_import + +# Options +file_header: + required_pattern: '(// swift-tools-version: .+)?//\n// .*.swift\n// MacBackend\n//\n// Created by .* on .*\.(\n// Edited by (.*,)+\.)*\n(\n// Thanks to .* for the .*:\n// ".*"\n// https://.* \(\d\d.\d\d.\d\d\))*//\n' +missing_docs: + warning: [internal, private] + error: [open, public] + excludes_inherited_types: false +type_contents_order: + order: + - case + - type_alias + - associated_type + - type_property + - instance_property + - ib_inspectable + - ib_outlet + - subscript + - initializer + - deinitializer + - subtype + - type_method + - view_life_cycle_method + - ib_action + - other_method + +excluded: + - .build/ diff --git a/Bundler.toml b/Bundler.toml new file mode 100644 index 0000000..9b833fe --- /dev/null +++ b/Bundler.toml @@ -0,0 +1,4 @@ +[apps."Demo"] +product = 'Demo' +version = '0.1.0' +minimum_macos_version = '14' \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..67c6220 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2024 david-swift + +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 CO + +ECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..a31e2cc --- /dev/null +++ b/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 6.0 +// +// Package.swift +// MacBackend +// +// Created by david-swift on 08.06.23. +// + +import PackageDescription + +/// The MacBackend package. +let package = Package( + name: "MacBackend", + platforms: [.macOS(.v13)], + products: [ + .library( + name: "MacBackend", + targets: ["MacBackend"] + ) + ], + dependencies: [ + .package(url: "https://git.aparoksha.dev/aparoksha/meta", branch: "main"), + .package(url: "https://git.aparoksha.dev/aparoksha/meta-sqlite", branch: "main"), + .package(url: "https://git.aparoksha.dev/aparoksha/levenshtein-transformations", branch: "main") + ], + targets: [ + .target( + name: "MacBackend", + dependencies: [ + .product(name: "Meta", package: "Meta"), + .product(name: "MetaSQLite", package: "meta-sqlite"), + .product(name: "LevenshteinTransformations", package: "levenshtein-transformations") + ] + ), + .executableTarget( + name: "Demo", + dependencies: ["MacBackend"] + ) + ], + swiftLanguageModes: [.v5] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..1074d83 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +

+

Backend for macOS

+

+ +

+ + Documentation + + · + + Code + +

+ +_MacBackend_ enables the creation of macOS apps via the [Aparoksha package](https://aparoksha.dev/). It does so by wrapping SwiftUI views. + +Therefore, if you want to create apps for macOS only, it is recommended to use SwiftUI directly. + +Find more information in the [documentation](https://macbackend.aparoksha.dev/). + +## Thanks + +### Dependencies +- [Meta](https://git.aparoksha.dev/aparoksha/meta) licensed under the [MIT License](https://git.aparoksha.dev/aparoksha/meta/src/branch/main/LICENSE.md) +- [Levenshtein Transformations](https://git.aparoksha.dev/aparoksha/levenshtein-transformations) licensed under the [MIT License](https://git.aparoksha.dev/aparoksha/levenshtein-transformations/src/branch/main/LICENSE.md) +- [SQLite for Meta](https://git.aparoksha.dev/aparoksha/meta-sqlite) licensed under the [MIT License](https://git.aparoksha.dev/aparoksha/meta-sqlite/src/branch/main/LICENSE.md) + +### Other Thanks +- [DocC](https://github.com/swiftlang/swift-docc) used for generating the documentation +- [SwiftLint][21] for checking whether code style conventions are violated +- The programming language [Swift][22] +- [SwiftUI](https://developer.apple.com/xcode/swiftui/) + +[21]: https://github.com/realm/SwiftLint +[22]: https://github.com/apple/swift diff --git a/Sources/Demo/Demo.swift b/Sources/Demo/Demo.swift new file mode 100644 index 0000000..6be2a36 --- /dev/null +++ b/Sources/Demo/Demo.swift @@ -0,0 +1,121 @@ +// +// Demo.swift +// MacBackend +// +// Created by david-swift on 25.09.23. +// + +// swiftlint:disable missing_docs no_magic_numbers closure_body_length + +import Foundation +import MacBackend + +@main +struct Demo: App { + + var app = MacApp(id: "dev.aparoksha.Demo") + + @State("bool") + private var bool = false + @State(blockUpdates: true) + private var width = 500 + @State(blockUpdates: true) + private var height = 400 + @State private var elements: [Element] = [.init()] + @State private var selectedElement: String? + @State private var alert = false + + var scene: Scene { + Window("Main", id: "main") { + NavigationSplitView { + List(elements, selection: $selectedElement) { element in + Label(element.id, icon: .system(name: "play.fill")) + } + } detail: { + VStack { + Button(selectedElement ?? "World") { + let element = Element() + elements.append(element) + selectedElement = element.id + } + Button(alert.description) { + alert = true + } + } + .alert("Hello", isPresented: $alert) + .cancelButton("Cancel") { + print("Cancel") + } + .destructiveButton("Destructive", default: true) { + print("Destructive") + } + } + } + .frame(width: $width, height: $height) + MenuBar { + Menu("Test") { + MenuButton(bool.description) { + bool.toggle() + } + .keyboardShortcut("h") + .selected(bool) + Divider() + Menu("Test") { + MenuButton("Hi") {} + Menu("Hi?") { } + } + MenuButton("Hello") { + print("Hello") + } + .enabled(bool) + .keyboardShortcut("s") + MenuButton("World") { + print("World") + } + Menu("Actions") { + MenuButton("Quit") { + app.quit() + } + } + } + Menu("Quit") { + MenuButton("Quit") { + app.quit() + } + .keyboardShortcut("q") + } + } app: { + MenuButton("About Demo") { + app.showAboutWindow() + } + Divider() + ServicesMenu("Services") + Divider() + MenuButton("Hide Demo") { + app.hide() + } + .keyboardShortcut("h") + MenuButton("Hide Others") { + app.hideOthers() + } + .keyboardShortcut(.init("h", alt: true)) + MenuButton("Show All") { + app.showAll() + } + Divider() + MenuButton("Quit Demo") { + app.quit() + } + .keyboardShortcut("q") + } + } + +} + +struct Element: Identifiable { + + var id: String = UUID().uuidString + +} + +// swiftlint:enable missing_docs no_magic_numbers closure_body_length diff --git a/Sources/MacBackend/MacBackend.docc/Documentation.md b/Sources/MacBackend/MacBackend.docc/Documentation.md new file mode 100644 index 0000000..b100966 --- /dev/null +++ b/Sources/MacBackend/MacBackend.docc/Documentation.md @@ -0,0 +1,7 @@ +# ``MacBackend`` + +_MacBackend_ enables the creation of macOS apps via the [Aparoksha package](https://aparoksha.dev/). It does so by wrapping SwiftUI views. + +Therefore, if you want to create apps for macOS only, it is recommended to use SwiftUI directly. + +Find more information in the [documentation](https://macbackend.aparoksha.dev/). diff --git a/Sources/MacBackend/MacBackend.docc/SwiftUI.md b/Sources/MacBackend/MacBackend.docc/SwiftUI.md new file mode 100644 index 0000000..850b69a --- /dev/null +++ b/Sources/MacBackend/MacBackend.docc/SwiftUI.md @@ -0,0 +1,59 @@ +# SwiftUI Integration + +Learn how to render SwiftUI views in a macOS app. + +## Simple Views + +Wrap a SwiftUI view by creating a widget conforming to ``SwiftUIWidget``. +Define your SwiftUI view in the ``SwiftUIWidget/view(properties:)`` function. + +```swift +import MacBackend +import SwiftUI + +struct Label: SwiftUIWidget { + + var label: String + var icon: MacBackend.Icon + + static func view(properties: Self) -> some SwiftUI.View { + SwiftUI.Label { + SwiftUI.Text(properties.label) + } icon: { + properties.icon.image + } + } + +} +``` + +## Container Views + +It is possible to pass `MacBackend` views as child views to SwiftUI views. +Add them to ``SwiftUIWidget/wrappedViews`` and reference via the identifier. + +```swift +import MacBackend +import SwiftUI + +struct ContainerView: SwiftUIWidget { + + var child: Body + + init(@MacBackend.ViewBuilder child: () -> Body) { + self.child = child() + } + + var wrappedViews: [String: MacBackend.AnyView] { + [.mainContent: child] + } + + func view(properties: Self) -> some SwiftUI.View { + MacBackendView(.mainContent) + .background(.red) + } + +} +``` + +You can add new wrapped views or delete old ones dynamically. diff --git a/Sources/MacBackend/Menu/Divider.swift b/Sources/MacBackend/Menu/Divider.swift new file mode 100644 index 0000000..02f2f20 --- /dev/null +++ b/Sources/MacBackend/Menu/Divider.swift @@ -0,0 +1,42 @@ +// +// Divider.swift +// MacBackend +// +// Created by david-swift on 22.10.23. +// + +import AppKit + +/// A button widget for menus. +public struct Divider: MenuWidget { + + /// Initialize a section for menus. + public init() { } + + /// The view storage. + /// - Parameters: + /// - data: The widget data. + /// - type: The type of the views. + /// - Returns: The view storage. + public func container( + data: WidgetData, + type: Data.Type + ) -> ViewStorage where Data: ViewRenderData { + .init(NSMenuItem.separator()) + } + + /// Update the stored content. + /// - Parameters: + /// - storage: The storage to update. + /// - data: The widget data. + /// - updateProperties: Whether to update the properties. + /// - type: The type of the views. + public func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + } + +} diff --git a/Sources/MacBackend/Menu/Menu.swift b/Sources/MacBackend/Menu/Menu.swift new file mode 100644 index 0000000..94507c1 --- /dev/null +++ b/Sources/MacBackend/Menu/Menu.swift @@ -0,0 +1,71 @@ +// +// Submenu.swift +// MacBackend +// +// Created by david-swift on 22.10.23. +// + +import AppKit + +/// A submenu widget. +public struct Menu: MenuWidget { + + /// The label of the submenu. + var label: String + /// The content of the submenu. + var content: Body + + /// Initialize a submenu. + /// - Parameters: + /// - label: The submenu's label. + /// - content: The content of the section. + public init(_ label: String, @ViewBuilder content: () -> Body) { + self.label = label + self.content = content() + } + + /// The view storage. + /// - Parameters: + /// - data: The widget data. + /// - type: The type of the views. + /// - Returns: The view storage. + public func container( + data: WidgetData, + type: Data.Type + ) -> ViewStorage where Data: ViewRenderData { + let item = NSMenuItem() + let menu = NSMenu() + item.submenu = menu + let storage = ViewStorage(item) + let content = MenuCollection { self.content }.getMenu(data: data, menu: menu) + storage.content[.mainContent] = [content] + update(storage, data: data, updateProperties: true, type: type) + return storage + } + + /// Update the stored content. + /// - Parameters: + /// - storage: The storage to update. + /// - data: The widget data. + /// - updateProperties: Whether to update the properties. + /// - type: The type of the views. + public func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + if let content = storage.content[.mainContent]?.first { + MenuCollection { self.content }.update(content, data: data, updateProperties: updateProperties, type: type) + } + guard updateProperties, let item = storage.pointer as? NSMenuItem else { + return + } + let previousState = storage.previousState as? Self + if previousState?.label != label { + item.title = label + } + storage.previousState = self + } + +} diff --git a/Sources/MacBackend/Menu/MenuButton.swift b/Sources/MacBackend/Menu/MenuButton.swift new file mode 100644 index 0000000..8ffa39e --- /dev/null +++ b/Sources/MacBackend/Menu/MenuButton.swift @@ -0,0 +1,145 @@ +// +// MenuButton.swift +// MacBackend +// +// Created by david-swift on 22.10.23. +// + +import AppKit + +/// A button widget for menus. +public struct MenuButton: MenuWidget { + + /// The button's label. + var label: String + /// The button's action handler. + var handler: () -> Void + /// The keyboard shortcut. + var shortcut: KeyboardShortcut? + /// Whether the button is selected. + var selected: Bool? + /// Whether the button is enabled. + var enabled = true + + /// The action label. + var filteredLabel: String { label.filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "." } } + + /// Initialize a menu button. + /// - Parameters: + /// - label: The buttons label. + /// - handler: The button's action handler. + public init(_ label: String, handler: @escaping () -> Void) { + self.label = label + self.handler = handler + } + + /// The view storage. + /// - Parameters: + /// - data: The widget data. + /// - type: The type of the views. + /// - Returns: The view storage. + public func container( + data: WidgetData, + type: Data.Type + ) -> ViewStorage where Data: ViewRenderData { + let button = NSMenuItem() + let storage = ViewStorage(button) + update(storage, data: data, updateProperties: true, type: type) + return storage + } + + /// Update the stored content. + /// - Parameters: + /// - storage: The storage to update. + /// - data: The widget data. + /// - updateProperties: Whether to update the properties. + /// - type: The type of the views. + public func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + guard let button = storage.pointer as? NSMenuItem else { + return + } + if enabled { + button.actionClosure = handler + } else { + button.action = nil + } + guard updateProperties else { + return + } + let previousState = storage.previousState as? Self + if previousState?.label != label { + button.title = label + } + if let shortcut, previousState?.shortcut != shortcut { + button.keyEquivalent = shortcut.character.macOSRepresentation + button.keyEquivalentModifierMask = shortcut.modifiers + } + if let selected, previousState?.selected != selected { + button.state = selected ? .on : .off + } + storage.previousState = self + } + + /// Create a keyboard shortcut for an application from a button. + /// + /// Note that the keyboard shortcut is available after the view has been visible for the first time. + /// - Parameter shortcut: The keyboard shortcut. + /// - Returns: The button. + public func keyboardShortcut(_ shortcut: KeyboardShortcut) -> Self { + modify { $0.shortcut = shortcut } + } + + /// Create a keyboard shortcut for an application from a button. + /// + /// Note that the keyboard shortcut is available after the view has been visible for the first time. + /// - Parameter shortcut: The keyboard shortcut. + /// - Returns: The button. + public func keyboardShortcut(_ shortcut: Character) -> Self { + modify { $0.shortcut = .init(shortcut) } + } + + /// Whether the button is selected. + /// - Parameter selected: Whether it is selected. + /// - Returns: The button. + public func selected(_ selected: Bool = true) -> Self { + modify { $0.selected = selected } + } + + /// Whether the button is enabled. + /// - Parameter enabled: Whether it is enabled. + /// - Returns: The button. + public func enabled(_ enabled: Bool = true) -> Self { + modify { $0.enabled = enabled } + } + +} + +extension NSMenuItem { + + /// The closure key. + private static var closureKey: UInt8 = 0 + + /// The action closure. + var actionClosure: (() -> Void)? { + get { + objc_getAssociatedObject(self, &Self.closureKey) as? () -> Void + } + set { + objc_setAssociatedObject(self, &Self.closureKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + self.target = self + self.action = #selector(menuItemAction) + } + } + + /// The action closure. + @objc + private func menuItemAction() { + actionClosure?() + } + +} diff --git a/Sources/MacBackend/Menu/MenuCollection.swift b/Sources/MacBackend/Menu/MenuCollection.swift new file mode 100644 index 0000000..296526b --- /dev/null +++ b/Sources/MacBackend/Menu/MenuCollection.swift @@ -0,0 +1,85 @@ +// +// MenuCollection.swift +// MacBackend +// +// Created by david-swift on 02.08.2024. +// + +import AppKit +import Foundation + +/// A collection of menus. +public struct MenuCollection: MenuWidget, Wrapper { + + /// The content of the collection. + var content: Body + + /// Initialize a menu. + /// - Parameter content: The content of the collection. + public init(@ViewBuilder content: @escaping () -> Body) { + self.content = content() + } + + /// The view storage. + /// - Parameters: + /// - data: The widget data. + /// - type: The type of the views. + /// - Returns: The view storage. + public func container( + data: WidgetData, + type: Data.Type + ) -> ViewStorage where Data: ViewRenderData { + let storages = content.storages(data: data, type: type) + return .init(nil, content: [.mainContent: storages]) + } + + /// Update the stored content. + /// - Parameters: + /// - storage: The storage to update. + /// - data: The widget data. + /// - updateProperties: Whether to update the properties. + /// - type: The type of the views. + public func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + guard let storages = storage.content[.mainContent] else { + return + } + content.update(storages, data: data, updateProperties: updateProperties, type: type) + } + + /// Render the collection as a menu. + /// - Parameters: + /// - data: The widget data. + /// - menu: The menu. + /// - Returns: The view storage with the GMenu as the pointer. + public func getMenu(data: WidgetData, menu: NSMenu?) -> ViewStorage { + let item = NSMenuItem() + let menu = menu ?? .init() + let storage = container(data: data.noModifiers, type: MenuContext.self) + initializeMenu(menu: menu, storage: storage) + storage.pointer = item + item.menu = menu + return storage + } + + /// Initialize a menu. + /// - Parameters: + /// - menu: The pointer to the GMenu. + /// - storage: The storage for the menu's content. + /// - app: The app object. + /// - window: The window object. + func initializeMenu(menu: NSMenu, storage: ViewStorage) { + if let item = storage.pointer as? NSMenuItem { + menu.addItem(item) + } else { + for element in storage.content[.mainContent] ?? [] { + initializeMenu(menu: menu, storage: element) + } + } + } + +} diff --git a/Sources/MacBackend/Menu/MenuContext.swift b/Sources/MacBackend/Menu/MenuContext.swift new file mode 100644 index 0000000..22e9142 --- /dev/null +++ b/Sources/MacBackend/Menu/MenuContext.swift @@ -0,0 +1,21 @@ +// +// MenuContext.swift +// MacBackend +// +// Created by david-swift on 01.08.24. +// + +/// The menu items view context. +public enum MenuContext: ViewRenderData { + + /// The type of the widgets. + public typealias WidgetType = MenuWidget + /// The wrapper type. + public typealias WrapperType = MenuCollection + /// The either view type. + public typealias EitherViewType = MenuEitherView + +} + +/// The type of the widgets. +public protocol MenuWidget: Meta.Widget { } diff --git a/Sources/MacBackend/Menu/MenuEitherView.swift b/Sources/MacBackend/Menu/MenuEitherView.swift new file mode 100644 index 0000000..b9021b9 --- /dev/null +++ b/Sources/MacBackend/Menu/MenuEitherView.swift @@ -0,0 +1,23 @@ +// +// MenuEitherView.swift +// MacBackend +// +// Created by david-swift on 06.08.2024. +// + +/// Show one of two views depending on a condition. +public struct MenuEitherView: Meta.EitherView, SimpleView { + + /// The view. + public var view: Body + + /// Initialize an either view. + /// - Parameters: + /// - condition: The condition. + /// - view1: The first view. + /// - view2: The second view. + public init(_ condition: Bool, view1: () -> Body, else view2: () -> Body) { + self.view = condition ? view1() : view2() + } + +} diff --git a/Sources/MacBackend/Menu/ServicesMenu.swift b/Sources/MacBackend/Menu/ServicesMenu.swift new file mode 100644 index 0000000..978ae0e --- /dev/null +++ b/Sources/MacBackend/Menu/ServicesMenu.swift @@ -0,0 +1,61 @@ +// +// DefaultMenu.swift +// MacBackend +// +// Created by david-swift on 10.09.2024. +// + +import AppKit + +/// A services submenu. +public struct ServicesMenu: MenuWidget { + + /// The label of the submenu. + var label: String + + /// Initialize a submenu. + /// - Parameter label: The submenu's label. + public init(_ label: String) { + self.label = label + } + + /// The view storage. + /// - Parameters: + /// - data: The widget data. + /// - type: The type of the views. + /// - Returns: The view storage. + public func container( + data: WidgetData, + type: Data.Type + ) -> ViewStorage where Data: ViewRenderData { + let item = NSMenuItem() + item.submenu = .init() + NSApp.servicesMenu = item.submenu + let storage = ViewStorage(item) + update(storage, data: data, updateProperties: true, type: type) + return storage + } + + /// Update the stored content. + /// - Parameters: + /// - storage: The storage to update. + /// - data: The widget data. + /// - updateProperties: Whether to update the properties. + /// - type: The type of the views. + public func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + guard updateProperties, let item = storage.pointer as? NSMenuItem else { + return + } + let previousState = storage.previousState as? Self + if previousState?.label != label { + item.title = label + } + storage.previousState = self + } + +} diff --git a/Sources/MacBackend/Model/Enumerations/Edge.swift b/Sources/MacBackend/Model/Enumerations/Edge.swift new file mode 100644 index 0000000..14af93f --- /dev/null +++ b/Sources/MacBackend/Model/Enumerations/Edge.swift @@ -0,0 +1,48 @@ +// +// Edge.swift +// MacBackend +// +// Created by david-swift on 11.10.2024. +// + +import AppKit + +/// A view's edges. +public enum Edge { + + /// The leading edge. + case leading + /// The trailing edge. + case trailing + /// The top edge. + case top + /// The bottom edge. + case bottom + + /// Activate layout constraints affecting this edge. + /// - Parameters: + /// - view: The view. + /// - parent: The parent view. + /// - padding: The padding value. + func activate(in view: NSView, to parent: NSView, padding: CGFloat) { + switch self { + case .top: + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: view.topAnchor, constant: padding) + ]) + case .bottom: + NSLayoutConstraint.activate([ + view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -padding) + ]) + case .leading: + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding) + ]) + case .trailing: + NSLayoutConstraint.activate([ + view.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -padding) + ]) + } + } + +} diff --git a/Sources/MacBackend/Model/Enumerations/Font.swift b/Sources/MacBackend/Model/Enumerations/Font.swift new file mode 100644 index 0000000..ed98fa7 --- /dev/null +++ b/Sources/MacBackend/Model/Enumerations/Font.swift @@ -0,0 +1,64 @@ +// +// Font.swift +// MacBackend +// +// Created by david-swift on 01.12.2024. +// + +import SwiftUI + +/// The font. +public enum Font { + + /// The body font. + case body + /// The callout font. + case callout + /// The caption font. + case caption + /// The caption font 2. + case caption2 + /// The footnote font. + case footnote + /// The headline font. + case headline + /// The large title font. + case largeTitle + /// The subheadline font. + case subheadline + /// The title font. + case title + /// The title font 2. + case title2 + /// The title font 3. + case title3 + + /// The SwiftUI font. + var swiftUI: SwiftUI.Font { + switch self { + case .body: + .body + case .callout: + .callout + case .caption: + .caption + case .caption2: + .caption2 + case .footnote: + .footnote + case .headline: + .headline + case .largeTitle: + .largeTitle + case .subheadline: + .subheadline + case .title: + .title + case .title2: + .title2 + case .title3: + .title3 + } + } + +} diff --git a/Sources/MacBackend/Model/Enumerations/Icon.swift b/Sources/MacBackend/Model/Enumerations/Icon.swift new file mode 100644 index 0000000..6b72514 --- /dev/null +++ b/Sources/MacBackend/Model/Enumerations/Icon.swift @@ -0,0 +1,24 @@ +// +// Icon.swift +// MacBackend +// +// Created by david-swift on 30.11.2024. +// + +import SwiftUI + +/// The icon type. +public enum Icon { + + /// A system icon. + case system(name: String) + + /// The SwiftUI image. + var image: Image { + switch self { + case let .system(name): + .init(systemName: name) + } + } + +} diff --git a/Sources/MacBackend/Model/Enumerations/KeyboardShortcut.swift b/Sources/MacBackend/Model/Enumerations/KeyboardShortcut.swift new file mode 100644 index 0000000..1aa1629 --- /dev/null +++ b/Sources/MacBackend/Model/Enumerations/KeyboardShortcut.swift @@ -0,0 +1,115 @@ +// +// KeyboardShortcut.swift +// MacBackend +// +// Created by david-swift on 10.09.2024. +// + +import AppKit + +/// A keyboard shortcut used e.g. in menus. +public struct KeyboardShortcut: Equatable { + + /// The character or a sequence representing a letter or symbol. + public var character: ShortcutCharacter + /// Whether the Ctrl key is part of the shortcut. + public var command: Bool + /// Whether the Shift key is part of the shortcut. + public var shift: Bool + /// Whether the Alt key is part of the shortcut. + public var option: Bool + + /// The modifiers for the macOS shortcut. + var modifiers: NSEvent.ModifierFlags { + var flags: NSEvent.ModifierFlags = [] + if command { + flags.insert(.command) + } + if shift { + flags.insert(.shift) + } + if option { + flags.insert(.option) + } + return flags + } + + /// Initialize a keyboard shortcut. + /// - Parameters: + /// - character: A letter. + /// - ctrl: Whether Ctrl is part of the shortcut. + /// - shift: Whether Shift is part of the shortcut. + /// - alt: Whether Alt is part of the shortcut. + public init(_ character: Character, ctrl: Bool = true, shift: Bool = false, alt: Bool = false) { + self.character = .character(character) + self.command = ctrl + self.shift = shift + self.option = alt + } + + /// Initialize a keyboard shortcut. + /// - Parameters: + /// - symbol: A character. + /// - ctrl: Whether Ctrl is part of the shortcut. + /// - shift: Whether Shift is part of the shortcut. + /// - alt: Whether Alt is part of the shortcut. + public init(symbol: ShortcutCharacter, ctrl: Bool = true, shift: Bool = false, alt: Bool = false) { + self.character = symbol + self.command = ctrl + self.shift = shift + self.option = alt + } + + /// The special characters available for shortcuts. + public enum ShortcutCharacter: Equatable { + + /// The ⌫ character. + case backspace + /// The ⌦ character. + case delete + /// The ⇥ character. + case tab + /// The ⏎ character. + case enter + /// The ⎋ character. + case escape + /// The ␣ character. + case space + // swiftlint:disable identifier_name + /// An arrow key. + case up, down, left, right + // swiftlint:enable identifier_name + /// A custom character. + case character(_ character: Character) + + /// A representation of the keys for macOS. + var macOSRepresentation: String { + switch self { + case .backspace: + return "\u{8}" + case .delete: + return "\u{7F}" + case .tab: + return "\u{9}" + case .enter: + return "\u{A}" + case .escape: + return "\u{1B}" + case .space: + return " " + case .up: + return "↑" + case .down: + return "↓" + case .left: + return "←" + case .right: + return "→" + case let .character(character): + return "\(character)" + } + } + + } + +} diff --git a/Sources/MacBackend/Model/Extensions/Meta.Binding.swift b/Sources/MacBackend/Model/Extensions/Meta.Binding.swift new file mode 100644 index 0000000..df88538 --- /dev/null +++ b/Sources/MacBackend/Model/Extensions/Meta.Binding.swift @@ -0,0 +1,22 @@ +// +// Meta.Binding.swift +// MacBackend +// +// Created by david-swift on 29.11.2024. +// + +import SwiftUI + +extension Meta.Binding { + + /// The SwiftUI binding. + public var swiftUI: SwiftUI.Binding { + .init { + wrappedValue + } set: { newValue in + wrappedValue = newValue + } + + } + +} diff --git a/Sources/MacBackend/Model/Extensions/Set.swift b/Sources/MacBackend/Model/Extensions/Set.swift new file mode 100644 index 0000000..5d55aaa --- /dev/null +++ b/Sources/MacBackend/Model/Extensions/Set.swift @@ -0,0 +1,65 @@ +// +// Set.swift +// MacBackend +// +// Created by david-swift on 11.10.2024. +// + +import SwiftUI + +extension Set where Element == Edge { + + /// All edges. + public static var all: Self { + vertical.union(horizontal) + } + + /// The vertical edges. + public static var vertical: Self { + top.union(bottom) + } + + /// The horizontal edges. + public static var horizontal: Self { + leading.union(trailing) + } + + /// The top edge. + public static var top: Self { + [.top] + } + + /// The bottom edge. + public static var bottom: Self { + [.bottom] + } + + /// The leading edge. + public static var leading: Self { + [.leading] + } + + /// The trailing edge. + public static var trailing: Self { + [.trailing] + } + + /// The SwiftUI edge. + var swiftUI: SwiftUI.Edge.Set { + var edges: SwiftUI.Edge.Set = [] + for edge in self { + switch edge { + case .top: + edges.insert(.top) + case .bottom: + edges.insert(.bottom) + case .leading: + edges.insert(.leading) + case .trailing: + edges.insert(.trailing) + } + } + return edges + } + +} diff --git a/Sources/MacBackend/Model/MacApp.swift b/Sources/MacBackend/Model/MacApp.swift new file mode 100644 index 0000000..cd2dedb --- /dev/null +++ b/Sources/MacBackend/Model/MacApp.swift @@ -0,0 +1,102 @@ +// +// MacApp.swift +// MacBackend +// +// Created by david-swift on 31.07.2024. +// + +import AppKit +@_exported import Meta +@_exported import MetaSQLite + +/// The Meta app storage for the macOS backend. +public class MacApp: AppStorage { + + /// The scene element type of the macOS backend. + public typealias SceneElementType = MacSceneElement + + /// The app storage. + public var storage: StandardAppStorage = .init() + /// The application. + let app = NSApplication.shared + /// The menu bar. + let mainMenu = NSMenu() + /// The "" menu. + let appItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") + /// The "Window" menu. + let windowsItem = NSMenuItem(title: "Window", action: nil, keyEquivalent: "") + /// The "Help" menu. + let helpItem = NSMenuItem(title: "Help", action: nil, keyEquivalent: "") + + /// Initialize the app storage. + /// - Parameter id: The identifier. + public init(id: String) { + app.mainMenu = mainMenu + DatabaseInformation.setPath( + URL.applicationSupportDirectory + .appendingPathComponent(id) + .appendingPathComponent("database.sqlite") + .path + ) + } + + /// The app menu. + func appMenu() { + let appMenu = NSMenu() + mainMenu.addItem(appItem) + appItem.submenu = appMenu + } + + /// The windows menu. + func windowsMenu() { + let windowsMenu = NSMenu() + mainMenu.addItem(windowsItem) + windowsItem.submenu = windowsMenu + app.windowsMenu = windowsMenu + } + + /// The help menu. + func helpMenu() { + let helpMenu = NSMenu() + mainMenu.addItem(helpItem) + helpItem.submenu = helpMenu + app.helpMenu = helpMenu + } + + /// Execute the app. + /// - Parameter setup: Set the scene elements up. + public func run(setup: @escaping () -> Void) { + appMenu() + setup() + windowsMenu() + helpMenu() + app.run() + } + + /// Present the about window. + public func showAboutWindow() { + app.orderFrontStandardAboutPanel() + } + + /// Quit the app. + @objc + public func quit() { + app.terminate(nil) + } + + /// Hide the app. + public func hide() { + app.hide(nil) + } + + /// Hide other apps. + public func hideOthers() { + app.hideOtherApplications(nil) + } + + /// Show all the apps. + public func showAll() { + app.unhideAllApplications(nil) + } + +} diff --git a/Sources/MacBackend/Model/MacMainView.swift b/Sources/MacBackend/Model/MacMainView.swift new file mode 100644 index 0000000..eed0eed --- /dev/null +++ b/Sources/MacBackend/Model/MacMainView.swift @@ -0,0 +1,18 @@ +// +// MacMainView.swift +// MacBackend +// +// Created by david-swift on 31.07.2024. +// + +/// The type of widgets of the macOS backend. +public enum MacMainView: ViewRenderData { + + /// The type of the widgets. + public typealias WidgetType = MacWidget + /// The wrapper type. + public typealias WrapperType = VStack + /// The either view type. + public typealias EitherViewType = EitherView + +} diff --git a/Sources/MacBackend/Model/MacSceneElement.swift b/Sources/MacBackend/Model/MacSceneElement.swift new file mode 100644 index 0000000..752d6fd --- /dev/null +++ b/Sources/MacBackend/Model/MacSceneElement.swift @@ -0,0 +1,9 @@ +// +// MacSceneElement.swift +// MacBackend +// +// Created by david-swift on 31.07.2024. +// + +/// The type of scene elements of the macOS backend. +public protocol MacSceneElement: SceneElement { } diff --git a/Sources/MacBackend/Model/MacWidget.swift b/Sources/MacBackend/Model/MacWidget.swift new file mode 100644 index 0000000..0219983 --- /dev/null +++ b/Sources/MacBackend/Model/MacWidget.swift @@ -0,0 +1,9 @@ +// +// MacWidget.swift +// MacBackend +// +// Created by david-swift on 31.07.2024. +// + +/// The type of widgets of the macOS backend. +public protocol MacWidget: Widget { } diff --git a/Sources/MacBackend/Model/SwiftUI/MacBackendView.swift b/Sources/MacBackend/Model/SwiftUI/MacBackendView.swift new file mode 100644 index 0000000..10eacb6 --- /dev/null +++ b/Sources/MacBackend/Model/SwiftUI/MacBackendView.swift @@ -0,0 +1,38 @@ +// +// MacBackendView.swift +// MacBackend +// +// Created by david-swift on 27.11.2024. +// + +import SwiftUI + +/// Display a view inside a SwiftUI view which is defined in the parent `MacBackend` widget. +struct MacBackendView: NSViewRepresentable { + + /// The views defined in the parent `MacBackend` widget. + @SwiftUI.Environment(\.views) + var views + /// The view's identifier. + var id: String + + /// Initialize a SwiftUI view displaying a view which is defined in the parent `MacBackend` widget. + /// - Parameter id: The identifier. + init(_ id: String) { + self.id = id + } + + /// Initialize the `NSView`. + /// - Parameter context: The view context. + /// - Returns: The view. + func makeNSView(context: Context) -> NSView { + views?[id]?.pointer as? NSView ?? .init() + } + + /// Update the `NSView`. + /// - Parameters: + /// - nsView: The view. + /// - context: The view context. + func updateNSView(_ nsView: NSViewType, context: Context) { } + +} diff --git a/Sources/MacBackend/Model/SwiftUI/SwiftUIWidget.swift b/Sources/MacBackend/Model/SwiftUI/SwiftUIWidget.swift new file mode 100644 index 0000000..b8905f1 --- /dev/null +++ b/Sources/MacBackend/Model/SwiftUI/SwiftUIWidget.swift @@ -0,0 +1,175 @@ +// +// SwiftUIWidget.swift +// MacBackend +// +// Created by david-swift on 25.11.2024. +// + +import LevenshteinTransformations +import SwiftUI + +/// Wrap a SwiftUI widget to be used inside a `MacBackend` view. +public protocol SwiftUIWidget: MacWidget { + + /// The content SwiftUI view. + associatedtype Content: SwiftUI.View + + /// The wrapped views. + var wrappedViews: [String: Meta.AnyView] { get } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + @SwiftUI.ViewBuilder + static func view(properties: Self) -> Content + +} + +extension SwiftUIWidget { + + /// The wrapped views. + public var wrappedViews: [String: Meta.AnyView] { + [:] + } + + /// The view storage. + /// - Parameters: + /// - data: Modify views before being updated. + /// - type: The view render data type. + /// - Returns: The view storage. + public func container( + data: WidgetData, + type: Data.Type + ) -> ViewStorage where Data: ViewRenderData { + internalContainer(data: data, type: type) + } + + /// The view storage. + /// - Parameters: + /// - data: Modify views before being updated. + /// - type: The view render data type. + /// - Returns: The view storage. + func internalContainer( + data: WidgetData, + type: Data.Type + ) -> ViewStorage where Data: ViewRenderData { + let id = UUID().uuidString + let updater = SwiftUIUpdater.updater + updater.state[id] = self + let wrappedStorages = wrappedViews.reduce(into: [String: ViewStorage]()) { partialResult, element in + partialResult[element.key] = element.value.storage(data: data, type: type) + } + let storage: ViewStorage = .init(nil) + storage.fields["child-storages"] = wrappedStorages + let view = NSHostingView( + rootView: SwiftUIWrapperView(updater: updater, id: id, data: data) { value in + if let value = value as? Self { + Self.view(properties: value) + .environment(\.views, storage.fields["child-storages"] as? [String: ViewStorage]) + } + } + ) + storage.pointer = view + storage.fields["updater-id"] = id + return storage + } + + /// Update the stored content. + /// - Parameters: + /// - storage: The storage to update. + /// - data: Modify views before being updated + /// - updateProperties: Whether to update the view's properties. + /// - type: The view render data type. + public func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + internalUpdate(storage, data: data, updateProperties: updateProperties, type: type) + } + + /// Update the stored content. + /// - Parameters: + /// - storage: The storage to update. + /// - data: Modify views before being updated + /// - updateProperties: Whether to update the view's properties. + /// - type: The view render data type. + func internalUpdate( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + if updateProperties, let id = storage.fields["updater-id"] as? String { + SwiftUIUpdater.updater.state[id] = self + } + var children = storage.fields["child-storages"] as? [String: ViewStorage] ?? [:] + for view in wrappedViews where !children.contains(where: { $0.key == view.key }) { + children[view.key] = view.value.storage(data: data, type: type) + } + for view in children where !wrappedViews.contains(where: { $0.key == view.key }) { + children[view.key] = nil + } + storage.fields["child-storages"] = children + for (key, storage) in children { + wrappedViews[key]?.updateStorage(storage, data: data, updateProperties: updateProperties, type: type) + } + } + +} + +/// A SwiftUI view which can be displayed and updated inside a `MacBackend` widget. +struct SwiftUIWrapperView: SwiftUI.View where Content: SwiftUI.View { + + /// The updater observable object. + @ObservedObject var updater: SwiftUIUpdater + /// The identifier. + var id: String + /// The widget data. + var data: WidgetData + /// The wrapped view. + var view: (Any) -> Content + + /// The SwiftUI view content. + var body: some SwiftUI.View { + if let state = updater.state[id] { + view(state) + } + } + + /// Initialize the SwiftUI wrapper view. + /// - Parameters: + /// - updater: The updater observable object. + /// - id: The identifier. + /// - data: The widget data. + /// - view: The wrapped view. + init(updater: SwiftUIUpdater, id: String, data: WidgetData, @SwiftUI.ViewBuilder view: @escaping (Any) -> Content) { + self.updater = updater + self.id = id + self.view = view + self.data = data + } + +} + +extension EnvironmentValues { + + /// The views environment value. + @Entry var views: [String: ViewStorage]? + +} + +/// The SwiftUI updater object. +class SwiftUIUpdater: ObservableObject { + + /// The updater. + static var updater: SwiftUIUpdater = .init() + + /// The state for SwiftUI views. + @Published var state: [String: Any] = [:] + + /// Initialize an updater. + init() { } + +} diff --git a/Sources/MacBackend/View/Alert.swift b/Sources/MacBackend/View/Alert.swift new file mode 100644 index 0000000..8621da1 --- /dev/null +++ b/Sources/MacBackend/View/Alert.swift @@ -0,0 +1,164 @@ +// +// Alert.swift +// MacBackend +// +// Created by david-swift on 01.12.2024. +// + +import SwiftUI + +/// The alert view. +public struct Alert: SwiftUIWidget { + + /// The alert's title. + var title: String + /// The alert's description. + var description: String + /// Whether the alert is presented. + var isPresented: Meta.Binding + /// The alert's actions. + var actions: [Action] = [] + /// The wrapped view. + var child: Meta.AnyView + + /// The wrapped views. + public var wrappedViews: [String: Meta.AnyView] { + [.mainContent: child] + } + + /// An alert action. + enum Action { + + /// A regular button. + case button(button: Button) + /// A cancel button. + case cancel(button: Button) + /// A destructive button. + case destructive(button: Button) + + /// Get the SwiftUI button. + @SwiftUI.ViewBuilder var button: some SwiftUI.View { + switch self { + case let .button(button): + button.button() + case let .cancel(button): + button.button(cancel: true) + case let .destructive(button): + button.button(destructive: true) + } + } + + } + + /// The button. + struct Button { + + /// The button's label. + var label: String + /// The button's action. + var action: () -> Void + /// Whether it is the default action. + var defaultAction: Bool + + /// Get the SwiftUI button. + /// - Parameters: + /// - cancel: Whether it is the cancel action. + /// - destructive: Whether it is a destructive action. + /// - Returns: The SwiftUI view. + @SwiftUI.ViewBuilder + func button(cancel: Bool = false, destructive: Bool = false) -> some SwiftUI.View { + let button = SwiftUI.Button( + label, + role: cancel ? .cancel : (destructive ? .destructive : nil), + action: action + ) + if defaultAction { + button + .keyboardShortcut(.defaultAction) + } else { + button + } + } + + } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + public static func view(properties: Self) -> some SwiftUI.View { + MacBackendView(.mainContent) + .alert(properties.title, isPresented: properties.isPresented.swiftUI) { + ForEach(Array(properties.actions.enumerated()), id: \.offset) { action in + action.element.button + } + } message: { + SwiftUI.Text(properties.description) + } + } + + /// Add a button to the alert. + /// - Parameters: + /// - label: The button's label. + /// - defaultAction: Whether it is a default action. + /// - action: The handler. + /// - Returns: The alert. + public func button( + _ label: String, + default defaultAction: Bool = false, + action: @escaping () -> Void + ) -> Self { + modify { alert in + alert.actions.append(.button(button: .init(label: label, action: action, defaultAction: defaultAction))) + } + } + + /// Add a cancel button to the alert. + /// - Parameters: + /// - label: The button's label. + /// - defaultAction: Whether it is a default action. + /// - action: The handler. + /// - Returns: The alert. + public func cancelButton( + _ label: String, + default defaultAction: Bool = false, + action: @escaping () -> Void + ) -> Self { + modify { alert in + alert.actions.append(.cancel(button: .init(label: label, action: action, defaultAction: defaultAction))) + } + } + + /// Add a destructive button to the alert. + /// - Parameters: + /// - label: The button's label. + /// - defaultAction: Whether it is a default action. + /// - action: The handler. + /// - Returns: The alert. + public func destructiveButton( + _ label: String, + default defaultAction: Bool = false, + action: @escaping () -> Void + ) -> Self { + modify { alert in + alert.actions + .append(.destructive(button: .init(label: label, action: action, defaultAction: defaultAction))) + } + } + +} + +extension Meta.AnyView { + + // swiftlint:disable function_default_parameter_at_end + /// Add an alert to a view. + /// - Parameters: + /// - title: The title. + /// - description: The description. + /// - isPresented: Whether the alert is visible. + /// - Returns: The alert. + public func alert(_ title: String, description: String = "", isPresented: Meta.Binding) -> Alert { + .init(title: title, description: description, isPresented: isPresented, child: self) + } + // swiftlint:enable function_default_parameter_at_end + +} diff --git a/Sources/MacBackend/View/Button.swift b/Sources/MacBackend/View/Button.swift new file mode 100644 index 0000000..112c21d --- /dev/null +++ b/Sources/MacBackend/View/Button.swift @@ -0,0 +1,64 @@ +// +// Button.swift +// MacBackend +// +// Created by david-swift on 18.09.2024. +// + +import SwiftUI + +/// A button widget. +public struct Button: SwiftUIWidget { + + /// The button's label. + var label: String? + /// The button's icon. + var icon: Icon? + /// The button's action. + var action: () -> Void + + /// Initialize a button. + /// - Parameters: + /// - label: The button's label. + /// - icon: The button's icon. + /// - action: The handler. + public init(_ label: String, icon: Icon? = nil, action: @escaping () -> Void) { + self.label = label + self.icon = icon + self.action = action + } + + /// Initialize a button. + /// - Parameters: + /// - icon: The button's icon. + /// - action: The handler. + public init(icon: Icon, action: @escaping () -> Void) { + self.icon = icon + self.action = action + } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + public static func view(properties: Self) -> some SwiftUI.View { + if let icon = properties.icon { + SwiftUI.Button { + properties.action() + } label: { + SwiftUI.Label { + if let label = properties.label { + SwiftUI.Text(label) + } + } icon: { + icon.image + } + + } + } else if let label = properties.label { + SwiftUI.Button(label) { + properties.action() + } + } + } + +} diff --git a/Sources/MacBackend/View/EitherView.swift b/Sources/MacBackend/View/EitherView.swift new file mode 100644 index 0000000..aa690e1 --- /dev/null +++ b/Sources/MacBackend/View/EitherView.swift @@ -0,0 +1,48 @@ +// +// EitherView.swift +// MacBackend +// +// Created by david-swift on 02.12.24. +// + +import SwiftUI + +/// A widget showing one of two widgets based on a condition. +public struct EitherView: SwiftUIWidget, Meta.EitherView { + + /// The condition. + var condition: Bool + /// The first view. + var view1: Body + /// The second view. + var view2: Body + + /// The wrapped views. + public var wrappedViews: [String: Meta.AnyView] { + condition ? ["1": view1] : ["2": view2] + } + + /// Initialize an either view. + /// - Parameters: + /// - condition: The condition. + /// - view1: The first view. + /// - view2: The second view. + public init(_ condition: Bool, view1: () -> Body, else view2: () -> Body) { + self.condition = condition + self.view1 = view1() + self.view2 = view2() + } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + @SwiftUI.ViewBuilder + public static func view(properties: Self) -> some SwiftUI.View { + if properties.condition { + MacBackendView("1") + } else { + MacBackendView("2") + } + } + +} diff --git a/Sources/MacBackend/View/Label.swift b/Sources/MacBackend/View/Label.swift new file mode 100644 index 0000000..47331e6 --- /dev/null +++ b/Sources/MacBackend/View/Label.swift @@ -0,0 +1,39 @@ +// +// Label.swift +// MacBackend +// +// Created by david-swift on 30.11.2024. +// + +import SwiftUI + +/// A label widget. +public struct Label: SwiftUIWidget { + + /// The label. + var label: String + /// The icon. + var icon: Icon + + /// Initialize the label. + /// - Parameters: + /// - label: The text. + /// - icon: The icon. + public init(_ label: String, icon: Icon) { + self.label = label + self.icon = icon + } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + public static func view(properties: Self) -> some SwiftUI.View { + SwiftUI.Label { + SwiftUI.Text(properties.label) + } icon: { + properties.icon.image + } + + } + +} diff --git a/Sources/MacBackend/View/List.swift b/Sources/MacBackend/View/List.swift new file mode 100644 index 0000000..abc51cc --- /dev/null +++ b/Sources/MacBackend/View/List.swift @@ -0,0 +1,51 @@ +// +// List.swift +// MacBackend +// +// Created by david-swift on 23.11.2024. +// + +import SwiftUI + +/// A list widget. +public struct List: SwiftUIWidget where Element: Identifiable { + + /// The elements. + var elements: [Element] + /// The selected element. + var selection: Meta.Binding + /// The content for an element. + var content: (Element) -> Body + + /// The wrapped views. + public var wrappedViews: [String: any Meta.AnyView] { + elements.reduce(into: [:]) { partialResult, element in + partialResult["\(element.id)"] = content(element) + } + } + + /// Initialize a list widget. + /// - Parameters: + /// - elements: The elements. + /// - selection: The selected element. + /// - content: The content for an element. + public init( + _ elements: [Element], + selection: Meta.Binding, + @Meta.ViewBuilder content: @escaping (Element) -> Body + ) { + self.elements = elements + self.content = content + self.selection = selection + } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + public static func view(properties: Self) -> some SwiftUI.View { + SwiftUI.List(properties.elements, selection: properties.selection.swiftUI) { element in + MacBackendView("\(element.id)") + } + } + +} diff --git a/Sources/MacBackend/View/NavigationSplitView.swift b/Sources/MacBackend/View/NavigationSplitView.swift new file mode 100644 index 0000000..4dab89d --- /dev/null +++ b/Sources/MacBackend/View/NavigationSplitView.swift @@ -0,0 +1,46 @@ +// +// NavigationSplitView.swift +// MacBackend +// +// Created by david-swift on 18.09.2024. +// + +import SwiftUI + +/// A navigation split view widget. +public struct NavigationSplitView: SwiftUIWidget { + + /// The sidebar view. + var sidebar: Body + /// The detail view. + var detail: Body + + /// The wrapped views. + public var wrappedViews: [String: Meta.AnyView] { + ["sidebar": sidebar, "detail": detail] + } + + /// Initialize the navigation split view. + /// - Parameters: + /// - sidebar: The sidebar view. + /// - detail: The detail view. + public init( + @Meta.ViewBuilder sidebar: () -> Body, + @Meta.ViewBuilder detail: () -> Body + ) { + self.detail = detail() + self.sidebar = sidebar() + } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + public static func view(properties: Self) -> some SwiftUI.View { + SwiftUI.NavigationSplitView { + MacBackendView("sidebar") + } detail: { + MacBackendView("detail") + } + } + +} diff --git a/Sources/MacBackend/View/PaddingView.swift b/Sources/MacBackend/View/PaddingView.swift new file mode 100644 index 0000000..ecbdbc9 --- /dev/null +++ b/Sources/MacBackend/View/PaddingView.swift @@ -0,0 +1,46 @@ +// +// PaddingView.swift +// MacBackend +// +// Created by david-swift on 11.10.2024. +// + +import SwiftUI + +/// The padding view. +struct PaddingView: SwiftUIWidget { + + /// The padding. + var padding: Double + /// The edges. + var edges: Set + /// The wrapped view. + var child: Meta.AnyView + + /// The wrapped views. + var wrappedViews: [String: Meta.AnyView] { + [.mainContent: child] + } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + static func view(properties: Self) -> some SwiftUI.View { + MacBackendView(.mainContent) + .padding(properties.edges.swiftUI, properties.padding) + } + +} + +extension Meta.AnyView { + + /// Set the padding. + /// - Parameters: + /// - padding: The padding. + /// - edges: The edges. + /// - Returns: The view. + public func padding(_ padding: Double, edges: Set = .all) -> Meta.AnyView { + PaddingView(padding: padding, edges: edges, child: self) + } + +} diff --git a/Sources/MacBackend/View/ScrollView.swift b/Sources/MacBackend/View/ScrollView.swift new file mode 100644 index 0000000..bc2d483 --- /dev/null +++ b/Sources/MacBackend/View/ScrollView.swift @@ -0,0 +1,36 @@ +// +// ScrollView.swift +// MacBackend +// +// Created by david-swift on 02.12.2024. +// + +import SwiftUI + +/// The scroll view widget. +public struct ScrollView: SwiftUIWidget { + + /// The view's content. + var content: Body + + /// The wrapped views. + public var wrappedViews: [String: any Meta.AnyView] { + [.mainContent: content] + } + + /// Initialize the scroll view. + /// - Parameter content: The content view. + public init(@Meta.ViewBuilder content: () -> Body) { + self.content = content() + } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + public static func view(properties: Self) -> some SwiftUI.View { + SwiftUI.ScrollView { + MacBackendView(.mainContent) + } + } + +} diff --git a/Sources/MacBackend/View/Spacer.swift b/Sources/MacBackend/View/Spacer.swift new file mode 100644 index 0000000..5e3c49b --- /dev/null +++ b/Sources/MacBackend/View/Spacer.swift @@ -0,0 +1,23 @@ +// +// Spacer.swift +// MacBackend +// +// Created by david-swift on 01.12.2024. +// + +import SwiftUI + +/// The spacer widget. +public struct Spacer: SwiftUIWidget { + + /// Initialize the spacer. + public init() { } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + public static func view(properties: Self) -> some SwiftUI.View { + SwiftUI.Spacer() + } + +} diff --git a/Sources/MacBackend/View/Text.swift b/Sources/MacBackend/View/Text.swift new file mode 100644 index 0000000..e3a37ce --- /dev/null +++ b/Sources/MacBackend/View/Text.swift @@ -0,0 +1,56 @@ +// +// Text.swift +// MacBackend +// +// Created by david-swift on 29.11.2024. +// + +import SwiftUI + +/// The text widget. +public struct Text: SwiftUIWidget { + + /// The label. + var label: String + /// The font. + var font: Font? + /// Whether the text is selectable. + var selectionDisabled = true + + /// Initialize the text widget. + /// - Parameter label: The text. + public init(_ label: String) { + self.label = label + } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + @SwiftUI.ViewBuilder + public static func view(properties: Self) -> some SwiftUI.View { + let text = SwiftUI.Text(properties.label) + .font(properties.font?.swiftUI) + if properties.selectionDisabled { + text + .textSelection(.disabled) + } else { + text + .textSelection(.enabled) + } + } + + /// Set the font. + /// - Parameter font: The font. + /// - Returns: The text view. + public func font(_ font: Font?) -> Self { + modify { $0.font = font } + } + + /// Whether the selection is disabled. + /// - Parameter isDisabled: The selection is disabled. + /// - Returns: The text view. + public func selectionDisabled(_ isDisabled: Bool = true) -> Self { + modify { $0.selectionDisabled = isDisabled } + } + +} diff --git a/Sources/MacBackend/View/VStack.swift b/Sources/MacBackend/View/VStack.swift new file mode 100644 index 0000000..7852969 --- /dev/null +++ b/Sources/MacBackend/View/VStack.swift @@ -0,0 +1,71 @@ +// +// VStack.swift +// MacBackend +// +// Created by david-swift on 23.08.23. +// + +import SwiftUI + +/// A `VStack` view. +public struct VStack: SwiftUIWidget, Wrapper { + + /// The content view. + var content: Body + + /// The wrapped views. + public var wrappedViews: [String: Meta.AnyView] { + content.enumerated().reduce(into: [:]) { partialResult, element in + partialResult["\(element.offset)"] = element.element + } + } + + /// Initialize the ``VStack``. + /// - Parameter content: The content. + public init(@Meta.ViewBuilder content: @escaping () -> Body) { + self.content = content() + } + + /// Get the SwiftUI view. + /// - Parameter properties: The widget data. + /// - Returns: The SwiftUI view. + public static func view(properties: Self) -> some SwiftUI.View { + SwiftUI.VStack { + ForEach(properties.content.indices, id: \.self) { index in + MacBackendView("\(index)") + } + } + } + + /// The view storage. + /// - Parameters: + /// - data: Modify views before being updated. + /// - type: The view render data type. + /// - Returns: The view storage. + public func container(data: WidgetData, type: Data.Type) -> ViewStorage where Data: ViewRenderData { + if content.count == 1, let storage = content.first?.storage(data: data, type: type) { + return storage + } + return internalContainer(data: data, type: type) + } + + /// Update the stored content. + /// - Parameters: + /// - storage: The storage to update. + /// - data: Modify views before being updated + /// - updateProperties: Whether to update the view's properties. + /// - type: The view render data type. + public func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + if content.count == 1, let first = content.first { + first.updateStorage(storage, data: data, updateProperties: updateProperties, type: type) + } else { + internalUpdate(storage, data: data, updateProperties: updateProperties, type: type) + } + } + +} diff --git a/Sources/MacBackend/Window/MenuBar.swift b/Sources/MacBackend/Window/MenuBar.swift new file mode 100644 index 0000000..2bb8fab --- /dev/null +++ b/Sources/MacBackend/Window/MenuBar.swift @@ -0,0 +1,88 @@ +// +// MenuBar.swift +// MacBackend +// +// Created by david-swift on 08.09.2024. +// + +import AppKit + +/// A structure representing the menu bar. +public struct MenuBar: MacSceneElement { + + /// The window's identifier. + public var id: String + /// The window's content. + var content: Body + /// The app menu. + var app: Body + /// The window menu. + var window: Body + /// The help menu. + var help: Body + + /// Create a menu bar. + /// - Parameters: + /// - id: The identifier. + /// - content: The content. + /// - app: The app menu. + /// - window: The window menu. + /// - help: The help menu. + public init( + id: String = "main-menu-bar", + @ViewBuilder content: @escaping () -> Body, + @ViewBuilder app: @escaping () -> Body = { [] }, + @ViewBuilder window: @escaping () -> Body = { [] }, + @ViewBuilder help: @escaping () -> Body = { [] } + ) { + self.content = content() + self.id = id + self.app = app() + self.window = window() + self.help = help() + } + + /// Set up the initial scene storages. + /// - Parameter app: The app storage. + public func setupInitialContainers(app: Storage) where Storage: AppStorage { + let container = container(app: app) + container.show() + app.storage.sceneStorage.append(container) + } + + /// The scene storage. + /// - Parameter app: The app storage. + public func container(app: Storage) -> SceneStorage where Storage: AppStorage { + guard let app = app as? MacApp else { + return .init(id: id, pointer: nil) { } + } + let scene = SceneStorage(id: id, pointer: app.mainMenu) { } + let data = WidgetData(sceneStorage: scene, appStorage: app) + let storage = MenuCollection { self.content }.getMenu(data: data, menu: app.mainMenu) + let appStorage = MenuCollection { self.app }.getMenu(data: data, menu: app.appItem.submenu) + scene.content[.mainContent] = [storage] + scene.content["app"] = [appStorage] + return scene + } + + /// Update the stored content. + /// - Parameters: + /// - storage: The storage to update. + /// - app: The app storage. + /// - updateProperties: Whether to update the view's properties. + public func update( + _ storage: SceneStorage, + app: Storage, + updateProperties: Bool + ) where Storage: AppStorage { + let data = WidgetData(sceneStorage: storage, appStorage: app) + if let content = storage.content["app"]?.first { + self.app.updateStorage(content, data: data, updateProperties: updateProperties, type: MenuContext.self) + } + guard let content = storage.content[.mainContent]?.first else { + return + } + self.content.updateStorage(content, data: data, updateProperties: updateProperties, type: MenuContext.self) + } + +} diff --git a/Sources/MacBackend/Window/Window.swift b/Sources/MacBackend/Window/Window.swift new file mode 100644 index 0000000..887a866 --- /dev/null +++ b/Sources/MacBackend/Window/Window.swift @@ -0,0 +1,157 @@ +// +// Window.swift +// MacBackend +// +// Created by david-swift on 14.09.23. +// + +import AppKit + +/// A structure representing an application window type. +/// +/// Note that it may be possible to open multiple instances of a window at the same time. +public struct Window: MacSceneElement { + + /// The window's identifier. + public var id: String + /// The window's content. + var content: Body + /// Whether an instance of the window type should be opened when the app is starting up. + var `open`: Int + /// The window's title. + var title: String + /// Whether the window is miniaturizable. + var miniaturizable = true + /// Whether the window is resizable. + var resizable = true + /// The window's width. + var width: Binding? + /// The window's height. + var height: Binding? + + // swiftlint:disable function_default_parameter_at_end + /// Create a window type with a certain identifier and user interface. + /// - Parameters: + /// - id: The identifier. + /// - open: The number of instances of the window type when the app is starting. + /// - content: The window's content. + /// - title: The window's title. + public init(_ title: String = "", id: String, `open`: Int = 1, @ViewBuilder content: @escaping () -> Body) { + self.title = title + self.content = content() + self.id = id + self.open = open + } + // swiftlint:enable function_default_parameter_at_end + + /// Set up the initial scene storages. + /// - Parameter app: The app storage. + public func setupInitialContainers(app: Storage) where Storage: AppStorage { + for _ in 0..(app: Storage) -> SceneStorage where Storage: AppStorage { + let window = NSWindow() + let storage = SceneStorage(id: id, pointer: window) { + window.makeKeyAndOrderFront(nil) + } + NotificationCenter.default + .addObserver(forName: NSWindow.willCloseNotification, object: window, queue: nil) { _ in + storage.destroy = true + } + let content = content.storage(data: .init(sceneStorage: storage, appStorage: app), type: MacMainView.self) + if let pointer = content.pointer as? NSView { + window.contentView = pointer + } + storage.content[.mainContent] = [content] + window.styleMask = [.titled, .closable, .fullSizeContentView, .resizable, .miniaturizable] + update(storage, app: app, updateProperties: true) + window.setFrame( + .init(origin: .zero, size: .init(width: width?.wrappedValue ?? -1, height: height?.wrappedValue ?? -1)), + display: true + ) + return storage + } + + /// Update the stored content. + /// - Parameters: + /// - storage: The storage to update. + /// - app: The app storage. + /// - updateProperties: Whether to update the view's properties. + public func update( + _ storage: SceneStorage, + app: Storage, + updateProperties: Bool + ) where Storage: AppStorage { + if let content = storage.content[.mainContent]?.first { + self.content.updateStorage( + content, + data: .init(sceneStorage: storage, appStorage: app), + updateProperties: updateProperties, + type: MacMainView.self + ) + } + guard let window = storage.pointer as? NSWindow else { + return + } + guard updateProperties else { + return + } + let previousState = storage.previousState as? Self + if previousState?.title != title { + window.title = title + } + if previousState?.miniaturizable != miniaturizable { + if miniaturizable { + window.styleMask.insert(.miniaturizable) + } else { + window.styleMask.remove(.miniaturizable) + } + } + if previousState?.resizable != resizable { + if resizable { + window.styleMask.insert(.resizable) + } else { + window.styleMask.remove(.resizable) + } + } + storage.previousState = self + } + + /// The window's width and height. + /// - Parameters: + /// - width: The width. + /// - height: The height. + /// - Returns: The window. + public func frame(width: Binding? = nil, height: Binding? = nil) -> Self { + var newSelf = self + newSelf.width = width + newSelf.height = height + return newSelf + } + + /// Whether the window is miniaturizable. + /// - Parameter miniaturizable: Whether the window is miniaturizable. + /// - Returns: The window. + public func miniaturizable(_ miniaturizable: Bool = true) -> Self { + var newSelf = self + newSelf.miniaturizable = miniaturizable + return newSelf + } + + /// Whether the window is resizable. + /// - Parameter resizable: Whether the window is resizable. + /// - Returns: The window. + public func resizable(_ resizable: Bool = true) -> Self { + var newSelf = self + newSelf.resizable = resizable + return newSelf + } + +}