Implement basic support for views
This commit is contained in:
commit
3c17404dc6
40
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -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.
|
29
.github/ISSUE_TEMPLATE/component_request.yml
vendored
Normal file
29
.github/ISSUE_TEMPLATE/component_request.yml
vendored
Normal file
@ -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 Adwaita?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
placeholder: >-
|
||||
Add any other context about the component request here.
|
||||
validations:
|
||||
required: false
|
36
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
36
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -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
|
14
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
14
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -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
|
57
.github/workflows/docs.yml
vendored
Normal file
57
.github/workflows/docs.yml
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Libadwaita
|
||||
run: |
|
||||
brew update
|
||||
brew install libadwaita
|
||||
sed -i '' 's/-I..includedir.//g' $(brew --prefix)/Library/Homebrew/os/mac/pkgconfig/*/libffi.pc
|
||||
- name: Clone DocC Repo
|
||||
run: |
|
||||
git clone https://github.com/AparokshaUI/Adwaita.docc Sources/Adwaita/Adwaita.docc
|
||||
rm Sources/Adwaita/Adwaita.docc/LICENSE.md
|
||||
rm Sources/Adwaita/Adwaita.docc/README.md
|
||||
y | rm -r Sources/Adwaita/Adwaita.docc/.git
|
||||
- name: Build Docs
|
||||
run: |
|
||||
xcrun xcodebuild docbuild \
|
||||
-scheme Adwaita \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-derivedDataPath "$PWD/.derivedData"
|
||||
xcrun docc process-archive transform-for-static-hosting \
|
||||
"$PWD/.derivedData/Build/Products/Debug/Adwaita.doccarchive" \
|
||||
--output-path "docs" \
|
||||
--hosting-base-path "adwaita-swift"
|
||||
- name: Modify Docs
|
||||
run: |
|
||||
echo "<script>window.location.href += \"/documentation/adwaita\"</script>" > docs/index.html;
|
||||
sed -i '' 's/#06f/#ea3358/g' docs/css/documentation-topic~topic~tutorials-overview.d6f5411c.css
|
||||
sed -i '' 's/,2px/,10px/g' docs/css/index.038e887c.css
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: 'docs'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
30
.github/workflows/swiftlint.yml
vendored
Normal file
30
.github/workflows/swiftlint.yml
vendored
Normal file
@ -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
|
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
/Package.resolved
|
||||
.Ulysses-Group.plist
|
||||
/.docc-build
|
||||
/io.github.AparokshaUI.Generation.json
|
||||
/.vscode
|
165
.swiftlint.yml
Normal file
165
.swiftlint.yml
Normal file
@ -0,0 +1,165 @@
|
||||
# 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
|
||||
- discouraged_optional_boolean
|
||||
- discouraged_optional_collection
|
||||
- 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
|
||||
- no_magic_numbers
|
||||
- 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:
|
||||
github_issue:
|
||||
name: 'GitHub Issue'
|
||||
regex: '//.(TODO|FIXME):.(?!.*(https://github\.com/AparokshaUI/Meta/issues/\d))'
|
||||
message: 'The related GitHub issue must be included in a TODO or FIXME.'
|
||||
severity: warning
|
||||
|
||||
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// Meta\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_extensions: false
|
||||
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:
|
||||
- Tests/
|
||||
- .build/
|
40
CONTRIBUTING.md
Normal file
40
CONTRIBUTING.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Contributing
|
||||
|
||||
Thank you very much for taking the time for contributing to this project.
|
||||
|
||||
## Report a Bug
|
||||
Just open a new issue on GitHub and describe the bug. It helps if your description is detailed. Thank you very much for your contribution!
|
||||
|
||||
## Suggest a New Feature
|
||||
Just open a new issue on GitHub and describe the idea. Thank you very much for your contribution!
|
||||
|
||||
## Pull Requests
|
||||
I am happy for every pull request, you do not have to follow these guidelines. However, it might help you to understand the project structure and make it easier for me to merge your pull request. Thank you very much for your contribution!
|
||||
|
||||
### 1. Fork & Clone this Project
|
||||
Start by clicking on the `Fork` button at the top of the page. Then, clone this repository to your computer.
|
||||
|
||||
### 2. Open the Project
|
||||
Open the project folder in GNOME Builder, Xcode or another IDE.
|
||||
|
||||
### 3. Understand the Project Structure
|
||||
- The `README.md` file contains a description of the app or package.
|
||||
- The `LICENSE.md` contains an MIT license.
|
||||
- `CONTRIBUTING.md` is this file.
|
||||
- Directory `Icons` that contains SVG files for the images used in the app and guides.
|
||||
- `Sources` contains the source code of the project.
|
||||
- `Model` contains the project's basis.
|
||||
- `Data Flow` contains property wrappers and protocols required for managing the updates of a view.
|
||||
- `Extensions` contains all the extensions of types that are not defined in this project.
|
||||
- `User Interface` contains protocols and structures that are the basis of presenting content to the user.
|
||||
- `View` contains structures that conform to the `AnyView` protocol.
|
||||
- `Tests` contains a sample application using two sample backends for testing the package.
|
||||
|
||||
### 4. Edit the Code
|
||||
Edit the code. If you add a new type, add documentation in the code.
|
||||
|
||||
### 5. Commit to the Fork
|
||||
Commit and push the fork.
|
||||
|
||||
### 6. Pull Request
|
||||
Open GitHub to submit a pull request. Thank you very much for your contribution!
|
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
@ -0,0 +1,21 @@
|
||||
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 CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
36
Package.swift
Normal file
36
Package.swift
Normal file
@ -0,0 +1,36 @@
|
||||
// swift-tools-version: 5.8
|
||||
//
|
||||
// Package.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
import PackageDescription
|
||||
|
||||
/// The Meta package is the foundation of the Aparoksha project.
|
||||
let package = Package(
|
||||
name: "Meta",
|
||||
products: [
|
||||
.library(
|
||||
name: "Meta",
|
||||
targets: ["Meta"]
|
||||
)
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Meta",
|
||||
path: "Sources"
|
||||
),
|
||||
.target(
|
||||
name: "SampleBackends",
|
||||
dependencies: ["Meta"],
|
||||
path: "Tests/SampleBackends"
|
||||
),
|
||||
.executableTarget(
|
||||
name: "DemoApp",
|
||||
dependencies: ["SampleBackends"],
|
||||
path: "Tests/DemoApp"
|
||||
)
|
||||
]
|
||||
)
|
141
README.md
Normal file
141
README.md
Normal file
@ -0,0 +1,141 @@
|
||||
<p align="center">
|
||||
<img width="256" alt="Adwaita Icon" src="Icons/AdwaitaIcon.png">
|
||||
<h1 align="center">Adwaita for Swift</h1>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://aparokshaui.github.io/adwaita-swift/">
|
||||
Documentation
|
||||
</a>
|
||||
·
|
||||
<a href="https://github.com/AparokshaUI/Adwaita">
|
||||
GitHub
|
||||
</a>
|
||||
</p>
|
||||
|
||||
_Adwaita_ is a framework for creating user interfaces for GNOME with an API similar to SwiftUI.
|
||||
|
||||
The following code:
|
||||
|
||||
```swift
|
||||
struct Counter: View {
|
||||
|
||||
@State private var count = 0
|
||||
|
||||
var view: Body {
|
||||
HStack {
|
||||
Button(icon: .default(icon: .goPrevious)) {
|
||||
count -= 1
|
||||
}
|
||||
Text("\(count)")
|
||||
.style("title-1")
|
||||
.frame(minWidth: 100)
|
||||
Button(icon: .default(icon: .goNext)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
Describes a simple counter view:
|
||||
|
||||
![Counter Example][image-1]
|
||||
|
||||
More examples are available in the [demo app][1].
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Goals][2]
|
||||
- [Widgets][3]
|
||||
- [Installation][4]
|
||||
- [Usage][5]
|
||||
- [Thanks][6]
|
||||
|
||||
## Goals
|
||||
|
||||
_Adwaita_’s main goal is to provide an easy-to-use interface for creating GNOME apps. The backend should stay as simple as possible, while not limiting the possibilities there are with [Libadwaita][7] and [GTK][8].
|
||||
|
||||
If you want to use _Adwaita_ in a project, but there are widgets missing, open an [issue on GitHub][9].
|
||||
|
||||
Find more information about the project's motivation in [this blog post](https://www.swift.org/blog/adwaita-swift/).
|
||||
|
||||
## Widgets
|
||||
|
||||
An overview of the widgets supported by _Adwaita_ is available [here](user-manual/Information/Widgets.md).
|
||||
|
||||
## Installation
|
||||
### Dependencies
|
||||
#### Flatpak
|
||||
|
||||
It is recommended to develop apps inside of a Flatpak.
|
||||
That way, you don't have to install Swift or any of the dependencies on your system, and you always have access to the latest versions.
|
||||
Take a look at the [template repository](https://github.com/AparokshaUI/AdwaitaTemplate).
|
||||
This works on Linux only.
|
||||
|
||||
#### Directly on System
|
||||
|
||||
You can also run your apps directly on the system.
|
||||
|
||||
If you are using a Linux distribution, install `libadwaita-devel` or `libadwaita` (or something similar, based on the package manager) as well as `gtk4-devel`, `gtk4` or similar.
|
||||
|
||||
On macOS, follow these steps:
|
||||
1. Install [Homebrew][11].
|
||||
2. Install Libadwaita (and thereby GTK 4):
|
||||
```
|
||||
brew install libadwaita
|
||||
```
|
||||
|
||||
### Swift Package
|
||||
1. Open your Swift package in GNOME Builder, Xcode, or any other IDE.
|
||||
2. Open the `Package.swift` file.
|
||||
3. Into the `Package` initializer, under `dependencies`, paste:
|
||||
```swift
|
||||
.package(url: "https://github.com/AparokshaUI/Adwaita", from: "0.1.0")
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
I recommend using the [template repository](https://github.com/AparokshaUI/AdwaitaTemplate) as a starting point.
|
||||
|
||||
Follow the [interactive tutorial](https://aparokshaui.github.io/adwaita-swift/tutorials/table-of-contents) or [read the docs](https://aparokshaui.github.io/adwaita-swift/documentation/adwaita) in order to get to know _Adwaita for Swift_.
|
||||
|
||||
## Thanks
|
||||
|
||||
### Dependencies
|
||||
- [XMLCoder][18] licensed under the [MIT license][19]
|
||||
- [Levenshtein Transformations](https://github.com/david-swift/LevenshteinTransformations) licensed under the [MIT license](https://github.com/david-swift/LevenshteinTransformations/blob/main/LICENSE.md)
|
||||
|
||||
### Other Thanks
|
||||
- The [contributors][20]
|
||||
- The auto-generation of widgets is based on [Swift Cross UI](https://github.com/stackotter/swift-cross-ui)
|
||||
- [SwiftLint][21] for checking whether code style conventions are violated
|
||||
- The programming language [Swift][22]
|
||||
|
||||
[1]: Tests/
|
||||
[2]: #goals
|
||||
[3]: #widgets
|
||||
[4]: #installation
|
||||
[5]: #usage
|
||||
[6]: #thanks
|
||||
[7]: https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1-latest/index.html
|
||||
[8]: https://docs.gtk.org/gtk4/
|
||||
[9]: https://github.com/AparokshaUI/Adwaita/issues
|
||||
[10]: https://github.com/AparokshaUI/Libadwaita
|
||||
[11]: https://brew.sh
|
||||
[12]: user-manual/GettingStarted.md
|
||||
[13]: user-manual/Basics/HelloWorld.md
|
||||
[14]: user-manual/Basics/CreatingViews.md
|
||||
[15]: user-manual/Basics/Windows.md
|
||||
[16]: user-manual/Basics/KeyboardShortcuts.md
|
||||
[17]: user-manual/Advanced/CreatingWidgets.md
|
||||
[18]: https://github.com/CoreOffice/XMLCoder
|
||||
[19]: https://github.com/CoreOffice/XMLCoder/blob/main/LICENSE
|
||||
[20]: Contributors.md
|
||||
[21]: https://github.com/realm/SwiftLint
|
||||
[22]: https://github.com/apple/swift
|
||||
[23]: https://github.com/SourceDocs/SourceDocs
|
||||
|
||||
[image-1]: Icons/Counter.png
|
||||
[image-2]: Icons/Demo.png
|
157
Sources/Model/Data Flow/Binding.swift
Normal file
157
Sources/Model/Data Flow/Binding.swift
Normal file
@ -0,0 +1,157 @@
|
||||
//
|
||||
// Binding.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
/// A property wrapper for a property of a view that binds the property of the parent view.
|
||||
///
|
||||
/// ```swift
|
||||
/// struct Grandparent: View {
|
||||
///
|
||||
/// @State private var state = false
|
||||
///
|
||||
/// var view: Body {
|
||||
/// Parent(value: $state)
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// struct Parent: View {
|
||||
///
|
||||
/// @Binding var value: Bool
|
||||
///
|
||||
/// var view: Body {
|
||||
/// Child(value: $value)
|
||||
/// }
|
||||
///
|
||||
/// }
|
||||
///
|
||||
/// struct Child: View {
|
||||
///
|
||||
/// @Binding var value: Bool
|
||||
///
|
||||
/// var view: Body {
|
||||
/// // ...
|
||||
/// }
|
||||
///
|
||||
/// }
|
||||
/// ```
|
||||
@propertyWrapper
|
||||
@dynamicMemberLookup
|
||||
public struct Binding<Value> {
|
||||
|
||||
/// The value.
|
||||
public var wrappedValue: Value {
|
||||
get {
|
||||
getValue()
|
||||
}
|
||||
nonmutating set {
|
||||
setValue(newValue)
|
||||
for handler in handlers {
|
||||
handler(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the value as a binding using the `$` prefix.
|
||||
public var projectedValue: Binding<Value> {
|
||||
.init {
|
||||
wrappedValue
|
||||
} set: { newValue in
|
||||
wrappedValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
/// The closure for getting the value.
|
||||
private let getValue: () -> Value
|
||||
/// The closure for settings the value.
|
||||
private let setValue: (Value) -> Void
|
||||
/// Handlers observing whether the binding changes.
|
||||
private var handlers: [(Value) -> Void] = []
|
||||
|
||||
/// Get a property of any content of a `Binding` as a `Binding`.
|
||||
/// - Parameter dynamicMember: The path to the member.
|
||||
/// - Returns: The binding.
|
||||
public subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Value, Subject>) -> Binding<Subject> {
|
||||
.init {
|
||||
wrappedValue[keyPath: keyPath]
|
||||
} set: { newValue in
|
||||
wrappedValue[keyPath: keyPath] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a property that is bound from a parent view.
|
||||
/// - Parameters:
|
||||
/// - get: The closure for getting the value.
|
||||
/// - set: The closure for setting the value.
|
||||
public init(get: @escaping () -> Value, set: @escaping (Value) -> Void) {
|
||||
self.getValue = get
|
||||
self.setValue = set
|
||||
}
|
||||
|
||||
/// Initialize a property that does not react to changes in the child view.
|
||||
/// - Parameters:
|
||||
/// - value: The constant value.
|
||||
/// - Returns: The binding.
|
||||
public static func constant(_ value: Value) -> Binding<Value> {
|
||||
.init {
|
||||
value
|
||||
} set: { _ in
|
||||
}
|
||||
}
|
||||
|
||||
/// Observe whether data is changed over this binding.
|
||||
/// - Parameter handler: The handler.
|
||||
/// - Returns: The binding.
|
||||
public func onSet(_ handler: @escaping (Value) -> Void) -> Self {
|
||||
var newSelf = self
|
||||
newSelf.handlers.append(handler)
|
||||
return newSelf
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Binding where Value: MutableCollection {
|
||||
|
||||
/// Get a child at a certain index of the array as a binding.
|
||||
/// - Parameters:
|
||||
/// - index: The child's index.
|
||||
/// - defaultValue: The value used if the index is out of range does not exist.
|
||||
/// - Returns: The child as a binding.
|
||||
public subscript(safe index: Value.Index?, default defaultValue: Value.Element) -> Binding<Value.Element> {
|
||||
.init {
|
||||
if let index, wrappedValue.indices.contains(index) {
|
||||
return wrappedValue[index]
|
||||
}
|
||||
return defaultValue
|
||||
} set: { newValue in
|
||||
if let index, wrappedValue.indices.contains(index) {
|
||||
wrappedValue[index] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Binding where Value: MutableCollection, Value.Element: Identifiable {
|
||||
|
||||
/// Get a child of the array with a certain id as a binding.
|
||||
/// - Parameters:
|
||||
/// - id: The child's id.
|
||||
/// - defaultValue: The value used if the index is out of range does not exist.
|
||||
/// - Returns: The child as a binding.
|
||||
public subscript(id id: Value.Element.ID?, default defaultValue: Value.Element) -> Binding<Value.Element> {
|
||||
self[safe: wrappedValue.firstIndex { $0.id == id }, default: defaultValue]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Binding: CustomStringConvertible where Value: CustomStringConvertible {
|
||||
|
||||
/// A textual description of the wrapped value.
|
||||
public var description: String {
|
||||
wrappedValue.description
|
||||
}
|
||||
|
||||
}
|
30
Sources/Model/Data Flow/Signal.swift
Normal file
30
Sources/Model/Data Flow/Signal.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// Signal.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A type that signalizes an action.
|
||||
public struct Signal {
|
||||
|
||||
/// An action is signalized by toggling a boolean to `true` and back to `false`.
|
||||
@State var boolean = false
|
||||
/// A signal has a unique identifier.
|
||||
public let id: UUID = .init()
|
||||
|
||||
/// Whether the action has caused an update.
|
||||
public var update: Bool { boolean }
|
||||
|
||||
/// Initialize a signal.
|
||||
public init() { }
|
||||
|
||||
/// Activate a signal.
|
||||
public func signal() {
|
||||
boolean = true
|
||||
boolean = false
|
||||
}
|
||||
|
||||
}
|
78
Sources/Model/Data Flow/State.swift
Normal file
78
Sources/Model/Data Flow/State.swift
Normal file
@ -0,0 +1,78 @@
|
||||
//
|
||||
// State.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A property wrapper for properties in a view that should be stored throughout view updates.
|
||||
@propertyWrapper
|
||||
public struct State<Value>: StateProtocol {
|
||||
|
||||
/// Access the stored value. This updates the views when being changed.
|
||||
public var wrappedValue: Value {
|
||||
get {
|
||||
rawValue
|
||||
}
|
||||
nonmutating set {
|
||||
rawValue = newValue
|
||||
content.storage.update = true
|
||||
UpdateManager.updateViews(force: forceUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the value as a binding using the `$` prefix.
|
||||
public var projectedValue: Binding<Value> {
|
||||
.init {
|
||||
wrappedValue
|
||||
} set: { newValue in
|
||||
self.wrappedValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable force_cast
|
||||
/// Get and set the value without updating the views.
|
||||
public var rawValue: Value {
|
||||
get {
|
||||
content.storage.value as! Value
|
||||
}
|
||||
nonmutating set {
|
||||
content.storage.value = newValue
|
||||
writeValue?(newValue)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable force_cast
|
||||
|
||||
/// The stored value.
|
||||
let content: StateContent
|
||||
|
||||
/// Whether to force update the views when the value changes.
|
||||
public var forceUpdates: Bool
|
||||
|
||||
/// The function for updating the value in the settings file.
|
||||
private var writeValue: ((Value) -> Void)?
|
||||
|
||||
/// The value with an erased type.
|
||||
public var value: Any {
|
||||
get {
|
||||
wrappedValue
|
||||
}
|
||||
nonmutating set {
|
||||
if let newValue = newValue as? Value {
|
||||
content.storage.value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a property representing a state in the view with an autoclosure.
|
||||
/// - Parameters:
|
||||
/// - wrappedValue: The wrapped value.
|
||||
/// - forceUpdates: Whether to force update all available views when the property gets modified.
|
||||
public init(wrappedValue: @autoclosure @escaping () -> Value, forceUpdates: Bool = false) {
|
||||
content = .init(getInitialValue: wrappedValue)
|
||||
self.forceUpdates = forceUpdates
|
||||
}
|
||||
|
||||
}
|
54
Sources/Model/Data Flow/StateContent.swift
Normal file
54
Sources/Model/Data Flow/StateContent.swift
Normal file
@ -0,0 +1,54 @@
|
||||
//
|
||||
// StateContent.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
/// A class storing the state's content.
|
||||
class StateContent {
|
||||
|
||||
/// The storage.
|
||||
var storage: Storage {
|
||||
get {
|
||||
if let internalStorage {
|
||||
return internalStorage
|
||||
}
|
||||
let value = getInitialValue()
|
||||
let storage = Storage(value: value)
|
||||
internalStorage = storage
|
||||
return storage
|
||||
}
|
||||
set {
|
||||
internalStorage = newValue
|
||||
}
|
||||
}
|
||||
/// The internal storage.
|
||||
var internalStorage: Storage?
|
||||
/// The initial value.
|
||||
private var getInitialValue: () -> Any
|
||||
|
||||
/// Initialize the content without already initializing the storage or initializing the value.
|
||||
/// - Parameter initialValue: The initial value.
|
||||
init(getInitialValue: @escaping () -> Any) {
|
||||
self.getInitialValue = getInitialValue
|
||||
}
|
||||
|
||||
/// A class storing the value.
|
||||
class Storage {
|
||||
|
||||
/// The stored value.
|
||||
var value: Any
|
||||
/// Whether to update the affected views.
|
||||
var update = false
|
||||
|
||||
/// Initialize the storage.
|
||||
/// - Parameters:
|
||||
/// - value: The value.
|
||||
init(value: Any) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
14
Sources/Model/Data Flow/StateProtocol.swift
Normal file
14
Sources/Model/Data Flow/StateProtocol.swift
Normal file
@ -0,0 +1,14 @@
|
||||
//
|
||||
// StateProtocol.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
/// An interface for accessing `State` without specifying the generic type.
|
||||
protocol StateProtocol {
|
||||
|
||||
/// The `StateContent`.
|
||||
var content: StateContent { get }
|
||||
|
||||
}
|
34
Sources/Model/Data Flow/UpdateManager.swift
Normal file
34
Sources/Model/Data Flow/UpdateManager.swift
Normal file
@ -0,0 +1,34 @@
|
||||
//
|
||||
// UpdateManager.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
/// This type manages view updates.
|
||||
public enum UpdateManager {
|
||||
|
||||
/// Whether to block updates in general.
|
||||
public static var blockUpdates = false
|
||||
/// The functions handling view updates.
|
||||
static var updateHandlers: [(Bool) -> Void] = []
|
||||
|
||||
/// Update all of the views.
|
||||
/// - Parameter force: Whether to force all views to update.
|
||||
///
|
||||
/// Nothing happens if ``UpdateManager/blockUpdates`` is true.
|
||||
static func updateViews(force: Bool = false) {
|
||||
if !blockUpdates {
|
||||
for handler in updateHandlers {
|
||||
handler(force)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a handler that is called when the user interface should update.
|
||||
/// - Parameter handler: The handler. The parameter defines whether the whole UI should be force updated.
|
||||
static func addUpdateHandler(handler: @escaping (Bool) -> Void) {
|
||||
updateHandlers.append(handler)
|
||||
}
|
||||
|
||||
}
|
134
Sources/Model/Extensions/Array.swift
Normal file
134
Sources/Model/Extensions/Array.swift
Normal file
@ -0,0 +1,134 @@
|
||||
//
|
||||
// Array.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Array: AnyView where Element == AnyView {
|
||||
|
||||
/// The array's view body is the array itself.
|
||||
public var viewContent: Body { self }
|
||||
|
||||
/// Get the debug tree for an array of views.
|
||||
/// - Parameter parameters: Whether the widget parameters should be visible in the tree.
|
||||
/// - Returns: The tree.
|
||||
public func getBodyDebugTree<ViewType>(parameters: Bool, type: ViewType.Type) -> String {
|
||||
var description = ""
|
||||
for view in self where view as? ViewType != nil {
|
||||
let viewDescription: String
|
||||
if let widget = view as? Widget {
|
||||
viewDescription = widget.getViewDescription(parameters: parameters, type: type)
|
||||
} else {
|
||||
viewDescription = view.getDebugTree(parameters: parameters, type: type)
|
||||
}
|
||||
description += viewDescription + "\n"
|
||||
}
|
||||
if !description.isEmpty {
|
||||
description.removeLast()
|
||||
}
|
||||
return description
|
||||
}
|
||||
|
||||
/// Get a widget from a collection of views.
|
||||
/// - Parameter modifiers: Modify views before being updated.
|
||||
/// - Returns: A widget.
|
||||
public func widget(modifiers: [(AnyView) -> AnyView]) -> Widget {
|
||||
if count == 1, let widget = self[safe: 0]?.widget(modifiers: modifiers) {
|
||||
return widget
|
||||
} else {
|
||||
var modified = self
|
||||
for (index, view) in modified.enumerated() {
|
||||
for modifier in modifiers {
|
||||
modified[safe: index] = modifier(view)
|
||||
}
|
||||
}
|
||||
// TODO: Is wrapper correct choice?
|
||||
return Wrapper { modified }
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a collection of views with a collection of view storages.
|
||||
/// - Parameters:
|
||||
/// - storage: The collection of view storages.
|
||||
/// - modifiers: Modify views before being updated.
|
||||
/// - updateProperties: Whether to update properties.
|
||||
public func update(_ storage: [ViewStorage], modifiers: [(AnyView) -> AnyView], updateProperties: Bool) {
|
||||
for (index, element) in enumerated() {
|
||||
if let storage = storage[safe: index] {
|
||||
element
|
||||
.widget(modifiers: modifiers)
|
||||
.updateStorage(storage, modifiers: modifiers, updateProperties: updateProperties)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Array where Element == String {
|
||||
|
||||
/// Get the C version of the array.
|
||||
var cArray: UnsafePointer<UnsafePointer<CChar>?>? {
|
||||
let cStrings = self.map { $0.utf8CString }
|
||||
let cStringPointers = cStrings.map { $0.withUnsafeBufferPointer { $0.baseAddress } }
|
||||
let optionalCStringPointers = cStringPointers + [nil]
|
||||
var optionalCStringPointersCopy = optionalCStringPointers
|
||||
optionalCStringPointersCopy.withUnsafeMutableBufferPointer { bufferPointer in
|
||||
bufferPointer.baseAddress?.advanced(by: cStrings.count).pointee = nil
|
||||
}
|
||||
let flatArray = optionalCStringPointersCopy.compactMap { $0 }
|
||||
let pointer = UnsafeMutablePointer<UnsafePointer<CChar>?>.allocate(capacity: flatArray.count + 1)
|
||||
for (index, element) in flatArray.enumerated() {
|
||||
pointer.advanced(by: index).pointee = element
|
||||
}
|
||||
pointer.advanced(by: flatArray.count).pointee = nil
|
||||
return UnsafePointer(pointer)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Array {
|
||||
|
||||
/// Accesses the element at the specified position safely.
|
||||
/// - Parameters:
|
||||
/// - index: The position of the element to access.
|
||||
///
|
||||
/// Access and set elements the safe way:
|
||||
/// ```swift
|
||||
/// var array = ["Hello", "World"]
|
||||
/// print(array[safe: 2] ?? "Out of range")
|
||||
/// ```
|
||||
public subscript(safe index: Int?) -> Element? {
|
||||
get {
|
||||
if let index, indices.contains(index) {
|
||||
return self[index]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
set {
|
||||
if let index, let value = newValue, indices.contains(index) {
|
||||
self[index] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Array where Element: Identifiable {
|
||||
|
||||
/// Accesses the element with a certain id safely.
|
||||
/// - Parameters:
|
||||
/// - id: The child's id.
|
||||
///
|
||||
/// Access and set elements the safe way:
|
||||
/// ```swift
|
||||
/// var array = ["Hello", "World"]
|
||||
/// print(array[safe: 2] ?? "Out of range")
|
||||
/// ```
|
||||
public subscript(id id: Element.ID) -> Element? {
|
||||
self[safe: firstIndex { $0.id == id }]
|
||||
}
|
||||
|
||||
}
|
35
Sources/Model/Extensions/DefaultStringInterpolation.swift
Normal file
35
Sources/Model/Extensions/DefaultStringInterpolation.swift
Normal file
@ -0,0 +1,35 @@
|
||||
//
|
||||
// DefaultStringInterpolation.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
// Thanks to Eneko Alonso, Pyry Jahkola, cukr for the comments in this Swift forum discussion:
|
||||
// "Multi-line string nested indentation with interpolation"
|
||||
// https://forums.swift.org/t/multi-line-string-nested-indentation-with-interpolation/36933
|
||||
//
|
||||
|
||||
extension DefaultStringInterpolation {
|
||||
|
||||
/// Preserve the indentation in a multi line string.
|
||||
/// - Parameter string: The string.
|
||||
///
|
||||
/// Use it the following way:
|
||||
/// """
|
||||
/// Hello
|
||||
/// \(indented: "World\n Test")
|
||||
/// """
|
||||
public mutating func appendInterpolation(indented string: String) {
|
||||
// swiftlint:disable compiler_protocol_init
|
||||
let indent = String(stringInterpolation: self).reversed().prefix { " \t".contains($0) }
|
||||
// swiftlint:enable compiler_protocol_init
|
||||
if indent.isEmpty {
|
||||
appendInterpolation(string)
|
||||
} else {
|
||||
appendLiteral(
|
||||
string.split(separator: "\n", omittingEmptySubsequences: false).joined(separator: "\n" + indent)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
13
Sources/Model/Extensions/String.swift
Normal file
13
Sources/Model/Extensions/String.swift
Normal file
@ -0,0 +1,13 @@
|
||||
//
|
||||
// String.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 09.06.24.
|
||||
//
|
||||
|
||||
extension String {
|
||||
|
||||
/// A label for main content in a view storage.
|
||||
static var mainContent: Self { "main" }
|
||||
|
||||
}
|
76
Sources/Model/User Interface/View/AnyView.swift
Normal file
76
Sources/Model/User Interface/View/AnyView.swift
Normal file
@ -0,0 +1,76 @@
|
||||
//
|
||||
// AnyView.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
/// The view type used for any form of a view.
|
||||
public protocol AnyView {
|
||||
|
||||
/// The view's content.
|
||||
@ViewBuilder var viewContent: Body { get }
|
||||
|
||||
}
|
||||
|
||||
extension AnyView {
|
||||
|
||||
/// Get the view's debug tree.
|
||||
/// - Parameter parameters: Whether the widget parameters should be included in the debug tree.
|
||||
/// - Returns: A textual description.
|
||||
public func getDebugTree<ViewType>(parameters: Bool, type: ViewType.Type) -> String {
|
||||
if let body = self as? Body {
|
||||
return body.getBodyDebugTree(parameters: parameters, type: type)
|
||||
}
|
||||
return """
|
||||
\(Self.self) {
|
||||
\(indented: viewContent.getBodyDebugTree(parameters: parameters, type: type))
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
func getModified(modifiers: [(AnyView) -> AnyView]) -> AnyView {
|
||||
var modified: AnyView = self
|
||||
for modifier in modifiers {
|
||||
modified = modifier(modified)
|
||||
}
|
||||
return modified
|
||||
}
|
||||
|
||||
/// Update a storage to a view.
|
||||
/// - Parameters:
|
||||
/// - storage: The storage.
|
||||
/// - modifiers: Modify views before being updated.
|
||||
/// - updateProperties: Whether to update properties.
|
||||
public func updateStorage(_ storage: ViewStorage, modifiers: [(AnyView) -> AnyView], updateProperties: Bool) {
|
||||
let modified = getModified(modifiers: modifiers)
|
||||
if let widget = modified as? Widget {
|
||||
widget.update(storage, modifiers: modifiers, updateProperties: updateProperties)
|
||||
} else {
|
||||
Wrapper { viewContent }
|
||||
.update(storage, modifiers: modifiers, updateProperties: updateProperties)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a storage.
|
||||
/// - Parameter modifiers: Modify views before being updated.
|
||||
/// - Returns: The storage.
|
||||
public func storage(modifiers: [(AnyView) -> AnyView]) -> ViewStorage {
|
||||
widget(modifiers: modifiers).container(modifiers: modifiers)
|
||||
}
|
||||
|
||||
/// Wrap the view into a widget.
|
||||
/// - Parameter modifiers: Modify views before being updated.
|
||||
/// - Returns: The widget.
|
||||
public func widget(modifiers: [(AnyView) -> AnyView]) -> Widget {
|
||||
let modified = getModified(modifiers: modifiers)
|
||||
if let peer = modified as? Widget {
|
||||
return peer
|
||||
}
|
||||
return Wrapper { viewContent }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// `Body` is an array of views.
|
||||
public typealias Body = [AnyView]
|
37
Sources/Model/User Interface/View/SimpleView.swift
Normal file
37
Sources/Model/User Interface/View/SimpleView.swift
Normal file
@ -0,0 +1,37 @@
|
||||
//
|
||||
// SimpleView.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 09.06.24.
|
||||
//
|
||||
|
||||
/// A structure conforming to `SimpleView` is referred to as a view.
|
||||
/// It can be part of a body.
|
||||
///
|
||||
/// ```swift
|
||||
/// struct Test: SimpleView {
|
||||
///
|
||||
/// var view: Body {
|
||||
/// AnotherView()
|
||||
/// }
|
||||
///
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// A simple view cannot save state. Use ``View`` for saving state.
|
||||
///
|
||||
public protocol SimpleView: AnyView {
|
||||
|
||||
/// The view's content.
|
||||
@ViewBuilder var view: Body { get }
|
||||
|
||||
}
|
||||
|
||||
extension SimpleView {
|
||||
|
||||
/// The view's content.
|
||||
public var viewContent: Body {
|
||||
view
|
||||
}
|
||||
|
||||
}
|
47
Sources/Model/User Interface/View/View.swift
Normal file
47
Sources/Model/User Interface/View/View.swift
Normal file
@ -0,0 +1,47 @@
|
||||
//
|
||||
// View.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 09.06.24.
|
||||
//
|
||||
|
||||
/// A structure conforming to `View` is referred to as a view.
|
||||
/// It can be part of a body.
|
||||
///
|
||||
/// ```swift
|
||||
/// struct Test: View {
|
||||
///
|
||||
/// var view: Body {
|
||||
/// AnotherView()
|
||||
/// }
|
||||
///
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Use ``SimpleView`` instead if a view does not have to save state.
|
||||
///
|
||||
public protocol View: AnyView {
|
||||
|
||||
/// The view's content.
|
||||
@ViewBuilder var view: Body { get }
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
/// The view's content.
|
||||
public var viewContent: Body {
|
||||
[StateWrapper(content: { view }, state: getState())]
|
||||
}
|
||||
|
||||
func getState() -> [String: StateProtocol] {
|
||||
var state: [String: StateProtocol] = [:]
|
||||
for property in Mirror(reflecting: self).children {
|
||||
if let label = property.label, let value = property.value as? StateProtocol {
|
||||
state[label] = value
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
}
|
102
Sources/Model/User Interface/View/ViewBuilder.swift
Normal file
102
Sources/Model/User Interface/View/ViewBuilder.swift
Normal file
@ -0,0 +1,102 @@
|
||||
//
|
||||
// ViewBuilder.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The ``ViewBuilder`` is a result builder for views.
|
||||
@resultBuilder
|
||||
public enum ViewBuilder {
|
||||
|
||||
/// A component used in the ``ArrayBuilder``.
|
||||
public enum Component {
|
||||
|
||||
/// A view as a component.
|
||||
case element(_: AnyView)
|
||||
/// An array of components as a component.
|
||||
case components(_: [Self])
|
||||
|
||||
}
|
||||
|
||||
/// Build combined results from statement blocks.
|
||||
/// - Parameter components: The components.
|
||||
/// - Returns: The components in a component.
|
||||
public static func buildBlock(_ elements: Component...) -> Component {
|
||||
.components(elements)
|
||||
}
|
||||
|
||||
/// Translate an element into a ``ViewBuilder.Component``.
|
||||
/// - Parameter element: The element to translate.
|
||||
/// - Returns: A component created from the element.
|
||||
public static func buildExpression(_ element: AnyView) -> Component {
|
||||
.element(element)
|
||||
}
|
||||
|
||||
/// Translate an array of elements into a ``ViewBuilder.Component``.
|
||||
/// - Parameter elements: The elements to translate.
|
||||
/// - Returns: A component created from the element.
|
||||
public static func buildExpression(_ elements: [AnyView]) -> Component {
|
||||
var components: [Component] = []
|
||||
for element in elements {
|
||||
components.append(.element(element))
|
||||
}
|
||||
return .components(components)
|
||||
}
|
||||
|
||||
/// Fetch a component.
|
||||
/// - Parameter component: A component.
|
||||
/// - Returns: The component.
|
||||
public static func buildExpression(_ component: Component) -> Component {
|
||||
component
|
||||
}
|
||||
|
||||
// TODO: Add support for optionals, building either
|
||||
/*
|
||||
/// Enables support for `if` statements without an `else`.
|
||||
/// - Parameter component: An optional component.
|
||||
/// - Returns: A nonoptional component.
|
||||
public static func buildOptional(_ component: Component?) -> Component {
|
||||
.element(
|
||||
Bin()
|
||||
.child {
|
||||
if let component {
|
||||
buildFinalResult(component)
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
}
|
||||
.visible(component != nil)
|
||||
)
|
||||
}
|
||||
|
||||
/// Enables support for `if`-`else` and `switch` statements.
|
||||
/// - Parameter component: A component.
|
||||
/// - Returns: The component.
|
||||
public static func buildEither(first component: Component) -> Component {
|
||||
.element(ViewStack(id: true) { _ in buildFinalResult(component) })
|
||||
}
|
||||
|
||||
/// Enables support for `if`-`else` and `switch` statements.
|
||||
/// - Parameter component: A component.
|
||||
/// - Returns: The component.
|
||||
public static func buildEither(second component: Component) -> Component {
|
||||
.element(ViewStack(id: false) { _ in buildFinalResult(component) })
|
||||
}
|
||||
*/
|
||||
|
||||
/// Convert a component to an array of elements.
|
||||
/// - Parameter component: The component to convert.
|
||||
/// - Returns: The generated array of elements.
|
||||
public static func buildFinalResult(_ component: Component) -> [AnyView] {
|
||||
switch component {
|
||||
case let .element(element):
|
||||
return [element]
|
||||
case let .components(components):
|
||||
return components.flatMap { buildFinalResult($0) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
42
Sources/Model/User Interface/View/ViewStorage.swift
Normal file
42
Sources/Model/User Interface/View/ViewStorage.swift
Normal file
@ -0,0 +1,42 @@
|
||||
//
|
||||
// ViewStorage.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
/// Store a rendered view in a view storage.
|
||||
public class ViewStorage {
|
||||
|
||||
/// The pointer.
|
||||
public var pointer: Any?
|
||||
/// The view's content.
|
||||
public var content: [String: [ViewStorage]]
|
||||
/// The view's state (used in `StateWrapper`).
|
||||
var state: [String: StateProtocol] = [:]
|
||||
/// Other properties.
|
||||
public var fields: [String: Any] = [:]
|
||||
|
||||
/// The pointer as an opaque pointer.
|
||||
public var opaquePointer: OpaquePointer? {
|
||||
get {
|
||||
pointer as? OpaquePointer
|
||||
}
|
||||
set {
|
||||
pointer = newValue
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a view storage.
|
||||
/// - Parameters:
|
||||
/// - pointer: The pointer to the widget, its type depends on the backend.
|
||||
/// - content: The view's content.
|
||||
public init(
|
||||
_ pointer: Any?,
|
||||
content: [String: [ViewStorage]] = [:]
|
||||
) {
|
||||
self.pointer = pointer
|
||||
self.content = content
|
||||
}
|
||||
|
||||
}
|
57
Sources/Model/User Interface/View/Widget.swift
Normal file
57
Sources/Model/User Interface/View/Widget.swift
Normal file
@ -0,0 +1,57 @@
|
||||
//
|
||||
// Widget.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 26.05.24.
|
||||
//
|
||||
|
||||
/// A widget is a view that know about its GTUI widget.
|
||||
public protocol Widget: AnyView {
|
||||
|
||||
/// The debug tree parameters.
|
||||
var debugTreeParameters: [(String, value: CustomStringConvertible)] { get }
|
||||
/// The debug tree's content.
|
||||
var debugTreeContent: [(String, body: Body)] { get }
|
||||
/// The view storage.
|
||||
/// - Parameter modifiers: Modify views before being updated.
|
||||
func container(modifiers: [(AnyView) -> AnyView]) -> ViewStorage
|
||||
/// Update the stored content.
|
||||
/// - Parameters:
|
||||
/// - storage: The storage to update.
|
||||
/// - modifiers: Modify views before being updated
|
||||
/// - updateProperties: Whether to update the view's properties.
|
||||
func update(_ storage: ViewStorage, modifiers: [(AnyView) -> AnyView], updateProperties: Bool)
|
||||
|
||||
}
|
||||
|
||||
extension Widget {
|
||||
|
||||
/// A widget's view is empty.
|
||||
public var viewContent: Body { [] }
|
||||
|
||||
/// A description of the view.
|
||||
public func getViewDescription<ViewType>(parameters: Bool, type: ViewType.Type) -> String {
|
||||
var content = ""
|
||||
for element in debugTreeContent {
|
||||
if content.isEmpty {
|
||||
content += """
|
||||
{
|
||||
\(indented: element.body.getDebugTree(parameters: parameters, type: type))
|
||||
}
|
||||
"""
|
||||
} else {
|
||||
content += """
|
||||
\(element.0): {
|
||||
\(indented: element.body.getDebugTree(parameters: parameters, type: type))
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
if parameters {
|
||||
let parametersString = debugTreeParameters.map { "\($0.0): \($0.value)" }.joined(separator: ", ")
|
||||
return "\(Self.self)(\(parametersString))\(content)"
|
||||
}
|
||||
return "\(Self.self)\(content)"
|
||||
}
|
||||
|
||||
}
|
95
Sources/View/StateWrapper.swift
Normal file
95
Sources/View/StateWrapper.swift
Normal file
@ -0,0 +1,95 @@
|
||||
//
|
||||
// StateWrapper.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 09.06.24.
|
||||
//
|
||||
|
||||
import Observation
|
||||
|
||||
/// A storage for `@State` properties.
|
||||
public struct StateWrapper: Widget {
|
||||
|
||||
/// The content.
|
||||
var content: () -> Body
|
||||
/// The state information (from properties with the `State` wrapper).
|
||||
var state: [String: StateProtocol] = [:]
|
||||
|
||||
/// The debug tree parameters.
|
||||
public var debugTreeParameters: [(String, value: CustomStringConvertible)] {
|
||||
[
|
||||
("state", value: state)
|
||||
]
|
||||
}
|
||||
|
||||
/// The debug tree's content.
|
||||
public var debugTreeContent: [(String, body: Body)] {
|
||||
[("content", body: content())]
|
||||
}
|
||||
|
||||
/// The identifier of the field storing whether to update the wrapper's content.
|
||||
private var updateID: String { "update" }
|
||||
|
||||
/// Initialize a `StateWrapper`.
|
||||
/// - Parameter content: The view content.
|
||||
public init(@ViewBuilder content: @escaping () -> Body) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
/// Initialize a `StateWrapper`.
|
||||
/// - Parameters:
|
||||
/// - content: The view content.
|
||||
/// - state: The state information.
|
||||
init(content: @escaping () -> Body, state: [String: StateProtocol]) {
|
||||
self.content = content
|
||||
self.state = state
|
||||
}
|
||||
|
||||
/// Update a view storage.
|
||||
/// - Parameters:
|
||||
/// - storage: The view storage.
|
||||
/// - modifiers: Modify views before being updated.
|
||||
/// - updateProperties: Whether to update properties.
|
||||
public func update(_ storage: ViewStorage, modifiers: [(AnyView) -> AnyView], updateProperties: Bool) {
|
||||
var updateProperties = storage.fields[updateID] as? Bool ?? false
|
||||
storage.fields[updateID] = false
|
||||
for property in state {
|
||||
if let storage = storage.state[property.key]?.content.storage {
|
||||
property.value.content.storage = storage
|
||||
}
|
||||
if property.value.content.storage.update {
|
||||
updateProperties = true
|
||||
property.value.content.storage.update = false
|
||||
}
|
||||
}
|
||||
if let storage = storage.content[.mainContent]?.first {
|
||||
content()
|
||||
.widget(modifiers: modifiers)
|
||||
.update(storage, modifiers: modifiers, updateProperties: updateProperties)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a view storage.
|
||||
/// - Parameter modifiers: Modify views before being updated.
|
||||
/// - Returns: The view storage.
|
||||
public func container(modifiers: [(AnyView) -> AnyView]) -> ViewStorage {
|
||||
let content = content().widget(modifiers: modifiers).container(modifiers: modifiers)
|
||||
let storage = ViewStorage(content.pointer, content: [.mainContent: [content]])
|
||||
storage.state = state
|
||||
observe(storage: storage)
|
||||
return storage
|
||||
}
|
||||
|
||||
/// Observe the observable properties accessed in the view.
|
||||
/// - Parameter storage: The view storage
|
||||
func observe(storage: ViewStorage) {
|
||||
withObservationTracking {
|
||||
_ = content().getDebugTree(parameters: true, type: AnyView.self)
|
||||
} onChange: {
|
||||
storage.fields[updateID] = true
|
||||
UpdateManager.updateViews()
|
||||
observe(storage: storage)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
51
Sources/View/Wrapper.swift
Normal file
51
Sources/View/Wrapper.swift
Normal file
@ -0,0 +1,51 @@
|
||||
//
|
||||
// Wrapper.swift
|
||||
// Meta
|
||||
//
|
||||
// Created by david-swift on 27.05.24.
|
||||
//
|
||||
|
||||
/// Wrap a view into a single widget.
|
||||
public struct Wrapper: Widget {
|
||||
|
||||
/// The content.
|
||||
var content: Body
|
||||
|
||||
/// The debug tree parameters.
|
||||
public var debugTreeParameters: [(String, value: CustomStringConvertible)] {
|
||||
[]
|
||||
}
|
||||
|
||||
/// The debug tree's content.
|
||||
public var debugTreeContent: [(String, body: Body)] {
|
||||
[("content", body: content)]
|
||||
}
|
||||
|
||||
/// Initialize a `Wrapper`.
|
||||
/// - Parameter content: The view content.
|
||||
public init(@ViewBuilder content: @escaping () -> Body) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
/// Update a view storage.
|
||||
/// - Parameters:
|
||||
/// - storage: The view storage.
|
||||
/// - modifiers: Modify views before being updated.
|
||||
/// - updateProperties: Whether to update properties.
|
||||
public func update(_ storage: ViewStorage, modifiers: [(AnyView) -> AnyView], updateProperties: Bool) {
|
||||
if let storage = storage.content[.mainContent]?.first {
|
||||
content
|
||||
.widget(modifiers: modifiers)
|
||||
.update(storage, modifiers: modifiers, updateProperties: updateProperties)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a view storage.
|
||||
/// - Parameter modifiers: Modify views before being updated.
|
||||
/// - Returns: The view storage.
|
||||
public func container(modifiers: [(AnyView) -> AnyView]) -> ViewStorage {
|
||||
let content = content.widget(modifiers: modifiers).container(modifiers: modifiers)
|
||||
return .init(content.pointer, content: [.mainContent: [content]])
|
||||
}
|
||||
|
||||
}
|
20
Tests/DemoApp/DemoApp.swift
Normal file
20
Tests/DemoApp/DemoApp.swift
Normal file
@ -0,0 +1,20 @@
|
||||
import Meta
|
||||
import SampleBackends
|
||||
|
||||
struct DemoView: SimpleView {
|
||||
|
||||
var view: Body {
|
||||
Backend1.TestWidget()
|
||||
testContent
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var testContent: Body {
|
||||
Backend2.TestWidget()
|
||||
Backend1.TestWidget()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
print(DemoView().getDebugTree(parameters: true, type: Backend1.BackendView.self))
|
||||
print(DemoView().getDebugTree(parameters: true, type: Backend2.BackendView.self))
|
34
Tests/SampleBackends/Backend1.swift
Normal file
34
Tests/SampleBackends/Backend1.swift
Normal file
@ -0,0 +1,34 @@
|
||||
import Meta
|
||||
|
||||
public enum Backend1 {
|
||||
|
||||
public struct TestWidget: BackendWidget {
|
||||
|
||||
public init() { }
|
||||
|
||||
public var debugTreeContent: [(String, body: Body)] {
|
||||
[]
|
||||
}
|
||||
|
||||
public var debugTreeParameters: [(String, value: CustomStringConvertible)] {
|
||||
[]
|
||||
}
|
||||
|
||||
public func container(modifiers: [(AnyView) -> AnyView]) -> ViewStorage {
|
||||
print("Init Content")
|
||||
let storage = ViewStorage(nil)
|
||||
storage.fields["test"] = 0
|
||||
return storage
|
||||
}
|
||||
|
||||
public func update(_ storage: ViewStorage, modifiers: [(AnyView) -> AnyView], updateProperties: Bool) {
|
||||
storage.fields["test"] = storage.fields["tests"] as? Int ?? 0 + 1
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public protocol BackendView: AnyView { }
|
||||
|
||||
public protocol BackendWidget: BackendView, Widget { }
|
||||
|
||||
}
|
34
Tests/SampleBackends/Backend2.swift
Normal file
34
Tests/SampleBackends/Backend2.swift
Normal file
@ -0,0 +1,34 @@
|
||||
import Meta
|
||||
|
||||
public enum Backend2 {
|
||||
|
||||
public struct TestWidget: BackendWidget {
|
||||
|
||||
public init() { }
|
||||
|
||||
public var debugTreeContent: [(String, body: Body)] {
|
||||
[]
|
||||
}
|
||||
|
||||
public var debugTreeParameters: [(String, value: CustomStringConvertible)] {
|
||||
[]
|
||||
}
|
||||
|
||||
public func container(modifiers: [(AnyView) -> AnyView]) -> ViewStorage {
|
||||
print("Init Content")
|
||||
let storage = ViewStorage(nil)
|
||||
storage.fields["test"] = 0
|
||||
return storage
|
||||
}
|
||||
|
||||
public func update(_ storage: ViewStorage, modifiers: [(AnyView) -> AnyView], updateProperties: Bool) {
|
||||
storage.fields["test"] = storage.fields["tests"] as? Int ?? 0 + 1
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public protocol BackendView: AnyView { }
|
||||
|
||||
public protocol BackendWidget: BackendView, Widget { }
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user