├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── cd.yml │ ├── ci.yml │ ├── github-release.yml │ └── tests.yml ├── .gitignore ├── .idea ├── .gitignore ├── leptos-bevy-canvas.iml ├── misc.xml ├── modules.xml └── vcs.xml ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── docs ├── check_version_in_docs.py ├── demo.gif ├── synced_bevy_query.webp └── unidir_events.webp ├── examples ├── Cargo.toml ├── src │ └── lib.rs ├── synced_bevy_query │ ├── Cargo.toml │ ├── README.md │ ├── assets │ │ ├── environment_maps │ │ │ ├── pisa_diffuse_rgb9e5_zstd.ktx2 │ │ │ └── pisa_specular_rgb9e5_zstd.ktx2 │ │ └── fonts │ │ │ └── FiraMono-Regular.ttf │ ├── index.html │ ├── input.css │ ├── package.json │ ├── rust-toolchain.toml │ ├── src │ │ ├── bevy_app │ │ │ ├── components.rs │ │ │ ├── mod.rs │ │ │ ├── resources.rs │ │ │ ├── setup.rs │ │ │ └── systems.rs │ │ ├── events.rs │ │ ├── leptos_app.rs │ │ └── main.rs │ └── tailwind.config.js └── unidir_events │ ├── Cargo.toml │ ├── README.md │ ├── assets │ ├── environment_maps │ │ ├── pisa_diffuse_rgb9e5_zstd.ktx2 │ │ └── pisa_specular_rgb9e5_zstd.ktx2 │ └── fonts │ │ └── FiraMono-Regular.ttf │ ├── index.html │ ├── input.css │ ├── package.json │ ├── rust-toolchain.toml │ ├── src │ ├── bevy_app │ │ ├── components.rs │ │ ├── mod.rs │ │ ├── resources.rs │ │ ├── setup.rs │ │ └── systems.rs │ ├── events.rs │ ├── leptos_app.rs │ └── main.rs │ └── tailwind.config.js └── src ├── app_extension.rs ├── events ├── bevy │ ├── macros.rs │ └── mod.rs ├── leptos │ ├── macros.rs │ ├── mod.rs │ └── traits.rs └── mod.rs ├── leptos_component.rs ├── lib.rs ├── queries.rs ├── signal_synced.rs ├── systems.rs ├── traits.rs └── utils.rs /.gitattributes: -------------------------------------------------------------------------------- 1 | *.gif filter=lfs diff=lfs merge=lfs -text 2 | *.webm filter=lfs diff=lfs merge=lfs -text 3 | *.webp filter=lfs diff=lfs merge=lfs -text 4 | *.ktx2 filter=lfs diff=lfs merge=lfs -text 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Synphonyte] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: daily -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Pattern matched against refs/tags 4 | tags: 5 | - '*' # Push events to every tag not containing / 6 | workflow_dispatch: 7 | 8 | name: CD 9 | 10 | permissions: write-all 11 | 12 | jobs: 13 | publish: 14 | name: Publish 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions-rs/toolchain@v1 19 | with: 20 | toolchain: nightly 21 | profile: minimal 22 | override: true 23 | components: rustfmt, clippy, rust-src 24 | - name: Cache 25 | uses: Swatinem/rust-cache@v2 26 | 27 | - name: Check version in docs 28 | run: python3 docs/check_version_in_docs.py 29 | 30 | - name: Check formatting 31 | run: cargo fmt --check 32 | - name: Clippy 33 | run: cargo clippy --tests -- -D warnings 34 | 35 | - name: Check if the README is up to date. 36 | run: | 37 | cargo install cargo-rdme 38 | cargo rdme --check 39 | 40 | - name: Run tests 41 | run: cargo test 42 | 43 | #### mdbook 44 | # - name: Install mdbook I 45 | # uses: taiki-e/install-action@v2 46 | # with: 47 | # tool: cargo-binstall,mdbook 48 | # - name: Install mdbook II 49 | # run: | 50 | # cargo binstall -y mdbook-cmdrun 51 | # cargo binstall -y trunk@0.17.5 52 | # rustup target add wasm32-unknown-unknown 53 | # - name: Setup Pages 54 | # id: pages 55 | # uses: actions/configure-pages@v3 56 | # - name: Build mdbook # TODO : run mdbook tests 57 | # run: | 58 | # cd docs/book 59 | # mdbook build 60 | # python3 post_build.py 61 | # - name: Upload artifact 62 | # uses: actions/upload-pages-artifact@v1 63 | # with: 64 | # path: ./docs/book/book 65 | # - name: Deploy book to github pages 66 | # id: deployment 67 | # uses: actions/deploy-pages@v2 68 | ##### mdbook end 69 | 70 | - name: Publish crate leptos-bevy-canvas 71 | uses: katyo/publish-crates@v2 72 | with: 73 | registry-token: ${{ secrets.CRATES_TOKEN }} 74 | 75 | - uses: CSchoel/release-notes-from-changelog@v1 76 | - name: Create Release using GitHub CLI 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | run: > 80 | gh release create 81 | -d 82 | -F RELEASE.md 83 | -t "Version $RELEASE_VERSION" 84 | ${GITHUB_REF#refs/*/} 85 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | paths: 8 | - "**" 9 | - "!/*.md" 10 | - "!/**.md" 11 | 12 | concurrency: 13 | group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | integrity: 18 | name: Integrity Checks on Rust ${{ matrix.toolchain }} 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 15 21 | strategy: 22 | matrix: 23 | toolchain: 24 | - stable 25 | - nightly 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Setup Rust 32 | uses: dtolnay/rust-toolchain@master 33 | with: 34 | toolchain: ${{ matrix.toolchain }} 35 | targets: wasm32-unknown-unknown 36 | components: clippy, rustfmt 37 | 38 | - name: Setup Rust Cache 39 | uses: Swatinem/rust-cache@v2 40 | 41 | - name: Build 42 | run: cargo build 43 | 44 | - name: Format 45 | run: cargo fmt --check 46 | 47 | - name: Clippy 48 | run: cargo clippy -- -D warnings 49 | -------------------------------------------------------------------------------- /.github/workflows/github-release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | 4 | name: GitHub Release 5 | 6 | permissions: write-all 7 | 8 | jobs: 9 | publish: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: CSchoel/release-notes-from-changelog@v1 15 | - name: Create Release using GitHub CLI 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | run: > 19 | gh release create 20 | -d 21 | -F RELEASE.md 22 | -t "Version $RELEASE_VERSION" 23 | ${GITHUB_REF#refs/*/} 24 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: 4 | - main 5 | paths: 6 | - "**" 7 | - "!/*.md" 8 | - "!/**.md" 9 | workflow_dispatch: 10 | 11 | name: Tests 12 | 13 | permissions: write-all 14 | 15 | jobs: 16 | tests: 17 | name: Tests 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions-rs/toolchain@v1 22 | with: 23 | toolchain: nightly 24 | profile: minimal 25 | override: true 26 | components: rustfmt, clippy, rust-src 27 | - name: Cache 28 | uses: Swatinem/rust-cache@v2 29 | 30 | - name: Run tests 31 | run: cargo test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | dist 3 | Cargo.lock -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/leptos-bevy-canvas.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 4 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## [0.2.0] - 2025-03-19 7 | 8 | ### Breaking Changes 🛠 9 | 10 | - Renamed `RwSignalResource` to `RwSignalSynced` 11 | - Renamed `signal_resource` to `signal_synced` 12 | 13 | ### New Features 🎉 14 | 15 | - `RwSignalSynced` now implements `Write` and `Copy` + `Clone` 16 | - Add synced single queries via `single_query_signal()` 17 | 18 | 19 | ## [0.1.0] - 2024-12-03 20 | 21 | - Updated to Leptos 0.7.0 and Bevy 0.15.0 22 | - Added example and readme gif 23 | 24 | ## [0.1.0-alpha1] - 2024-09-12 25 | 26 | - Added Bevy <-> Leptos events 27 | - Added Resource <-> RwSignal syncing 28 | - Added `BevyCanvas` Leptos component 29 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "leptos-bevy-canvas" 3 | version = "0.2.0" 4 | edition = "2021" 5 | authors = ["Marc-Stefan Cassola"] 6 | categories = ["gui", "web-programming", "rendering", "graphics", "wasm"] 7 | description = "Embed an idiomatic Bevy app inside your Leptos app with ease." 8 | exclude = ["examples/", "tests/"] 9 | keywords = ["leptos", "bevy", "graphics", "wasm", "canvas"] 10 | license = "MIT OR Apache-2.0" 11 | readme = "README.md" 12 | repository = "https://github.com/Synphonyte/leptos-bevy-canvas" 13 | 14 | [dependencies] 15 | bevy = { version = "0.15", default-features = false } 16 | crossbeam-channel = "0.5" 17 | leptos = "0.7.0" 18 | leptos-use = { version = "0.15.1", default-features = false, features = ["use_raf_fn"] } 19 | paste = "1.0.15" 20 | 21 | [dev-dependencies] 22 | bevy = { version = "0.15", default-features = false, features = ["bevy_window"] } 23 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Synphonyte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Leptos Bevy Canvas 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/leptos-bevy-canvas.svg)](https://crates.io/crates/leptos-bevy-canvas) 4 | [![Docs](https://docs.rs/leptos-bevy-canvas/badge.svg)](https://docs.rs/leptos-bevy-canvas/) 5 | [![MIT/Apache 2.0](https://img.shields.io/badge/license-MIT%2FApache-blue.svg)](https://github.com/synphonyte/leptos-bevy-canvas#license) 6 | [![Build Status](https://github.com/synphonyte/leptos-bevy-canvas/actions/workflows/ci.yml/badge.svg)](https://github.com/synphonyte/leptos-bevy-canvas/actions/workflows/ci.yml) 7 | [![built with Codeium](https://codeium.com/badges/main)](https://codeium.com) 8 | 9 | 10 | 11 | Embed an idiomatic Bevy app inside your Leptos app. 12 | 13 | [Send and Receive Events ![Events Demo](https://media.githubusercontent.com/media/Synphonyte/leptos-bevy-canvas/refs/heads/main/docs/unidir_events.webp)](https://github.com/Synphonyte/leptos-bevy-canvas/tree/main/examples/unidir_events) 14 | 15 | [Sync Bevy Queries ![Query Sync Demo](https://media.githubusercontent.com/media/Synphonyte/leptos-bevy-canvas/refs/heads/main/docs/synced_bevy_query.webp)](https://github.com/Synphonyte/leptos-bevy-canvas/tree/main/examples/synced_bevy_query) 16 | 17 | ## Features 18 | 19 | - **Easy to use** - Simply embed your Bevy app inside your Leptos app with the 20 | [`BevyCanvas`](fn@crate::prelude::BevyCanvas) component. 21 | - **Idiomatic** - This crate doesn't want you to do anything differently in the way you write 22 | your Bevy app or your Leptos app. It just gives you the tools for them to communicate. 23 | - **Events** - Send events in either or both directions between your Bevy app and your Leptos app. 24 | - **Resource signals** - Synchronize Bevy `Resource`s with `RwSignal`s in your Leptos app. 25 | 26 | ## Example 27 | 28 | ```rust 29 | use bevy::prelude::*; 30 | use leptos::prelude::*; 31 | use leptos_bevy_canvas::prelude::*; 32 | 33 | #[derive(Event)] 34 | pub struct TextEvent { 35 | pub text: String, 36 | } 37 | 38 | #[component] 39 | pub fn App() -> impl IntoView { 40 | // This initializes a sender for the Leptos app and 41 | // a receiver for the Bevy app 42 | let (text_event_sender, bevy_text_receiver) = event_l2b::(); 43 | 44 | let on_input = move |evt| { 45 | // send the event over to Bevy 46 | text_event_sender 47 | .send(TextEvent { text: event_target_value(&evt) }) 48 | .ok(); 49 | }; 50 | 51 | view! { 52 | 53 | 54 | 64 | } 65 | } 66 | 67 | // In Bevy it ends up just as a normal event 68 | pub fn set_text( 69 | mut event_reader: EventReader, 70 | ) { 71 | for event in event_reader.read() { 72 | // do something with the event 73 | } 74 | } 75 | 76 | // This initializes a normal Bevy app 77 | fn init_bevy_app( text_receiver: BevyEventReceiver) -> App { 78 | let mut app = App::new(); 79 | app 80 | .add_plugins( 81 | DefaultPlugins.set(WindowPlugin { 82 | primary_window: Some(Window { 83 | // "#bevy_canvas" is the default and can be 84 | // changed in the component 85 | canvas: Some("#bevy_canvas".into()), 86 | ..default() 87 | }), 88 | ..default() 89 | }), 90 | ) 91 | // import the event here into Bevy 92 | .import_event_from_leptos(text_receiver) 93 | .add_systems(Update, set_text); 94 | 95 | app 96 | } 97 | ``` 98 | 99 | Please check the examples to see how to synchronize a `Resource` or a `Query`. 100 | 101 | ## Compatibility 102 | 103 | | Crate version | Compatible Leptos version | Compatible Bevy version | 104 | |---------------|---------------------------|-------------------------| 105 | | 0.1, 0.2 | 0.7 | 0.15 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /docs/check_version_in_docs.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from datetime import datetime 4 | 5 | 6 | def main(): 7 | with open("Cargo.toml", "r") as f: 8 | cargo_text = f.read() 9 | 10 | m = re.search(r'leptos = "(\d+)\.(\d+)', cargo_text) 11 | leptos_version = f"{m.group(1)}.{m.group(2)}" 12 | 13 | m = re.search(r'bevy = \{ version = "(\d+)\.(\d+)', cargo_text) 14 | bevy_version = f"{m.group(1)}.{m.group(2)}" 15 | 16 | m = re.search(r'version = "(\d+)\.(\d+)\.(\d+)', cargo_text) 17 | crate_version_short = f"{m.group(1)}.{m.group(2)}" 18 | crate_version_long = f"{m.group(1)}.{m.group(2)}.{m.group(3)}" 19 | 20 | print("Found crate version", crate_version_short, ", leptos version", leptos_version, "and bevy version", 21 | bevy_version) 22 | 23 | with open("README.md", "r") as f: 24 | original_text = f.read() 25 | 26 | text = check_compat_table(leptos_version, bevy_version, crate_version_short, original_text) 27 | 28 | if check_compat_table(leptos_version, bevy_version, crate_version_short, original_text): 29 | print("[OK] README.md does contain the current crate version in the compatibility table") 30 | else: 31 | print("[Failed] README.md doesn't contain the current crate version in the compatibility table", 32 | file=sys.stderr) 33 | quit(1) 34 | 35 | with open("CHANGELOG.md", "r") as f: 36 | original_text = f.read() 37 | 38 | if check_in_changelog(original_text): 39 | print("[Failed] CHANGELOG.md still contains an [Unreleased] header", 40 | file=sys.stderr) 41 | quit(1) 42 | else: 43 | print("[OK] CHANGELOG.md doesn't contain an [Unreleased] header") 44 | 45 | 46 | def check_compat_table(leptos_version: str, bevy_version: str, crate_version: str, original_text: str): 47 | lines = original_text.splitlines() 48 | 49 | for line in lines: 50 | if re.search(rf"^\| (.* )?{crate_version}\s*\| {leptos_version}\s*\| {bevy_version}", line) is not None: 51 | return True 52 | 53 | return False 54 | 55 | 56 | def check_in_changelog(original_text: str): 57 | return "## [Unreleased]" in original_text 58 | 59 | 60 | if __name__ == '__main__': 61 | main() 62 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0a22dc010885bfcb46057a53d9b308c75a77a7bc27d8691ec9e8bdb6bd5697c5 3 | size 2134151 4 | -------------------------------------------------------------------------------- /docs/synced_bevy_query.webp: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:bea4da68a19b2e01a0ffc32084f75195e295dd44354705408d87ca8664f8bf9c 3 | size 2517856 4 | -------------------------------------------------------------------------------- /docs/unidir_events.webp: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c224a5f5467e80714f8547451cfb3cc0ee40d612a7c1526d03db281b44448a1c 3 | size 1973858 4 | -------------------------------------------------------------------------------- /examples/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | 4 | members = ["unidir_events", "synced_bevy_query"] 5 | 6 | [workspace.dependencies] 7 | bevy = { version = "0.15", default-features = false } 8 | bevy-inspector-egui = "0.30" 9 | bevy_rand = { version = "0.9", features = ["wyrand"] } 10 | console_error_panic_hook = "0.1" 11 | #getrandom = { version = "0.3.1", features = ["js"] } 12 | leptos = { version = "0.7.0", features = ["csr"] } 13 | leptos-bevy-canvas = { path = "../../leptos-bevy-canvas" } 14 | leptos-bevy-canvas-examples = { path = "." } 15 | leptos-use = "0.15" 16 | rand_core = "0.6" 17 | web-sys = "0.3" 18 | wasm-bindgen = "0.2" 19 | wasm-bindgen-test = "0.3.0" 20 | 21 | [package] 22 | name = "leptos-bevy-canvas-examples" 23 | version = "0.3.3" 24 | edition = "2021" 25 | 26 | [profile.dev] 27 | opt-level = 1 28 | 29 | [profile.dev.package."*"] 30 | opt-level = 3 31 | -------------------------------------------------------------------------------- /examples/src/lib.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "synced_bevy_query" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | bevy-inspector-egui.workspace = true 8 | console_error_panic_hook.workspace = true 9 | leptos.workspace = true 10 | leptos-bevy-canvas.workspace = true 11 | leptos-use.workspace = true 12 | rand_core.workspace = true 13 | 14 | [dependencies.bevy] 15 | workspace = true 16 | features = [ 17 | "bevy_mesh_picking_backend", 18 | "bevy_pbr", 19 | "bevy_picking", 20 | "bevy_window", 21 | "bevy_winit", 22 | "tonemapping_luts", 23 | "webgl2", 24 | ] 25 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/README.md: -------------------------------------------------------------------------------- 1 | # Synced Bevy Query Example 2 | 3 | Shows off how to synchronize a Bevy Query with a RwSignal in your Leptos app. 4 | 5 | ![Query Sync Demo](https://media.githubusercontent.com/media/Synphonyte/leptos-bevy-canvas/refs/heads/main/docs/synced_bevy_query.webp) 6 | 7 | ## Run the Example 8 | 9 | This runs just like any other CSR Leptos app. 10 | 11 | Make sure you have `trunk` installed. 12 | 13 | ```bash 14 | cargo install trunk 15 | ``` 16 | 17 | Then, to run the example, run 18 | 19 | ```bash 20 | trunk serve --open 21 | ``` -------------------------------------------------------------------------------- /examples/synced_bevy_query/assets/environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:18c9a0ef0af29ec5cd4202cc77f2b000a4bdb603b5df80446e1740b94d54841b 3 | size 23305 4 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/assets/environment_maps/pisa_specular_rgb9e5_zstd.ktx2: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5f33159662393d62f7a8538e39e77356b8bc5ada1b5e7bf9ddfdcc790d7d7c84 3 | size 6791940 4 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/assets/fonts/FiraMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Synphonyte/leptos-bevy-canvas/fa699c8d487ce43970303e13e78488d258b47f61/examples/synced_bevy_query/assets/fonts/FiraMono-Regular.ttf -------------------------------------------------------------------------------- /examples/synced_bevy_query/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /examples/synced_bevy_query/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@tailwindcss/forms": "^0.5.7", 4 | "tailwindcss": "^3.4.4" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/src/bevy_app/components.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | 3 | /// Amount of y-rotation applied every frame. 4 | #[derive(Component, Deref, DerefMut, Copy, Clone, Debug)] 5 | pub struct RotationSpeed(f32); 6 | 7 | /// Marker component for selected entities. Only the one selected entity has this component. 8 | #[derive(Component, Copy, Clone, Debug)] 9 | pub struct Selected; 10 | 11 | /// Color of the object. Will be synchronized to the material. 12 | #[derive(Component, Deref, DerefMut, Clone, Debug, PartialEq)] 13 | pub struct ObjectColor(Color); 14 | 15 | impl RotationSpeed { 16 | pub fn new(speed: f32) -> Self { 17 | RotationSpeed(speed) 18 | } 19 | } 20 | 21 | impl ObjectColor { 22 | pub fn new(color: Color) -> Self { 23 | ObjectColor(color) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/src/bevy_app/mod.rs: -------------------------------------------------------------------------------- 1 | mod components; 2 | mod resources; 3 | mod setup; 4 | mod systems; 5 | 6 | pub use crate::bevy_app::components::*; 7 | use crate::bevy_app::setup::setup_scene; 8 | use crate::bevy_app::systems::*; 9 | use crate::{RENDER_HEIGHT, RENDER_WIDTH}; 10 | use bevy::asset::AssetMetaCheck; 11 | use bevy::prelude::*; 12 | use bevy::window::WindowResolution; 13 | use leptos_bevy_canvas::prelude::*; 14 | 15 | pub fn init_bevy_app( 16 | selected_query_duplex: BevyQueryDuplex<(RotationSpeed, ObjectColor, Selected), ()>, 17 | ) -> App { 18 | let mut app = App::new(); 19 | app.add_plugins(( 20 | DefaultPlugins 21 | .set(AssetPlugin { 22 | meta_check: AssetMetaCheck::Never, 23 | ..default() 24 | }) 25 | .set(WindowPlugin { 26 | primary_window: Some(Window { 27 | focused: false, 28 | fit_canvas_to_parent: true, 29 | canvas: Some("#bevy_canvas".into()), 30 | resolution: WindowResolution::new(RENDER_WIDTH, RENDER_HEIGHT), 31 | ..default() 32 | }), 33 | ..default() 34 | }), 35 | MeshPickingPlugin, 36 | // bevy_inspector_egui::quick::WorldInspectorPlugin::new(), 37 | )) 38 | .sync_leptos_signal_with_query(selected_query_duplex) 39 | .add_systems(Startup, (setup_scene,)) 40 | .add_systems(Update, (apply_color, selected_outline)) 41 | .add_systems(FixedUpdate, (apply_rotation,)); 42 | 43 | app 44 | } 45 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/src/bevy_app/resources.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Synphonyte/leptos-bevy-canvas/fa699c8d487ce43970303e13e78488d258b47f61/examples/synced_bevy_query/src/bevy_app/resources.rs -------------------------------------------------------------------------------- /examples/synced_bevy_query/src/bevy_app/setup.rs: -------------------------------------------------------------------------------- 1 | use crate::bevy_app::components::*; 2 | use bevy::asset::Assets; 3 | use bevy::color::palettes::tailwind::*; 4 | use bevy::color::Color; 5 | use bevy::core_pipeline::Skybox; 6 | use bevy::math::Vec3; 7 | use bevy::pbr::{MeshMaterial3d, PointLight, StandardMaterial}; 8 | use bevy::picking::PickingBehavior; 9 | use bevy::prelude::*; 10 | use bevy::render::render_resource::Face; 11 | 12 | const CUBE_X: f32 = 4.0; 13 | const CUBE_Y: f32 = 0.0; 14 | const CUBE_SCALE: f32 = 3.0; 15 | const HIGHLIGHT_SCALE: f32 = 1.03; 16 | 17 | pub fn setup_scene( 18 | mut commands: Commands, 19 | mut meshes: ResMut>, 20 | mut materials: ResMut>, 21 | asset_server: Res, 22 | ) { 23 | // Cubes 24 | let cube = meshes.add(Cuboid::default()); 25 | 26 | let highlight_material = materials.add(StandardMaterial { 27 | base_color: Color::WHITE, 28 | cull_mode: Some(Face::Front), 29 | unlit: true, 30 | ..default() 31 | }); 32 | 33 | commands 34 | .spawn(( 35 | Mesh3d(cube.clone()), 36 | ObjectColor::new(Color::from(RED_500)), 37 | MeshMaterial3d(materials.add(Color::from(RED_500))), 38 | Transform::from_xyz(-CUBE_X, CUBE_Y, 0.0).with_scale(Vec3::splat(CUBE_SCALE)), 39 | RotationSpeed::new(0.01), 40 | )) 41 | .observe(select_on_click) 42 | .with_children(|parent| { 43 | parent 44 | .spawn(( 45 | Mesh3d(cube.clone()), 46 | MeshMaterial3d(highlight_material.clone()), 47 | Transform::from_scale(Vec3::splat(HIGHLIGHT_SCALE)), 48 | PickingBehavior::IGNORE, 49 | Visibility::Hidden, 50 | )) 51 | .observe(select_on_click); 52 | }); 53 | 54 | commands 55 | .spawn(( 56 | Mesh3d(cube.clone()), 57 | ObjectColor::new(Color::from(GREEN_500)), 58 | MeshMaterial3d(materials.add(Color::from(GREEN_500))), 59 | Transform::from_xyz(CUBE_X, CUBE_Y, 0.0).with_scale(Vec3::splat(CUBE_SCALE)), 60 | RotationSpeed::new(-0.02), 61 | )) 62 | .observe(select_on_click) 63 | .with_children(|parent| { 64 | parent 65 | .spawn(( 66 | Mesh3d(cube.clone()), 67 | MeshMaterial3d(highlight_material), 68 | Transform::from_scale(Vec3::splat(HIGHLIGHT_SCALE)), 69 | PickingBehavior::IGNORE, 70 | Visibility::Hidden, 71 | )) 72 | .observe(select_on_click); 73 | }); 74 | 75 | // Light 76 | commands.spawn(( 77 | PointLight { 78 | shadows_enabled: true, 79 | intensity: 10_000_000., 80 | range: 100.0, 81 | shadow_depth_bias: 0.2, 82 | ..default() 83 | }, 84 | Transform::from_xyz(8.0, 16.0, 8.0), 85 | )); 86 | 87 | // Camera 88 | commands.spawn(( 89 | Camera3d::default(), 90 | Transform::from_xyz(0.0, 7., 14.0).looking_at(Vec3::new(0.0, 0.0, 0.0), Vec3::Y), 91 | EnvironmentMapLight { 92 | diffuse_map: asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2"), 93 | specular_map: asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2"), 94 | intensity: 900.0, 95 | ..default() 96 | }, 97 | Skybox { 98 | image: asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2"), 99 | brightness: 500.0, 100 | rotation: Quat::IDENTITY, 101 | }, 102 | )); 103 | } 104 | 105 | pub fn select_on_click( 106 | click: Trigger>, 107 | mut commands: Commands, 108 | prev_selected: Query>, 109 | ) { 110 | if let Ok(entity) = prev_selected.get_single() { 111 | commands.entity(entity).remove::(); 112 | } 113 | 114 | commands.entity(click.entity()).insert(Selected); 115 | } 116 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/src/bevy_app/systems.rs: -------------------------------------------------------------------------------- 1 | use crate::bevy_app::components::*; 2 | use bevy::prelude::*; 3 | 4 | pub fn apply_rotation(mut query: Query<(&mut Transform, &RotationSpeed)>) { 5 | for (mut transform, rotation_speed) in query.iter_mut() { 6 | transform.rotate_y(**rotation_speed); 7 | } 8 | } 9 | 10 | pub fn apply_color( 11 | query: Query<(&ObjectColor, &MeshMaterial3d)>, 12 | mut materials: ResMut>, 13 | ) { 14 | for (color, material) in &query { 15 | if let Some(material) = materials.get_mut(material.0.id()) { 16 | material.base_color = **color; 17 | } 18 | } 19 | } 20 | 21 | pub fn selected_outline( 22 | mut query: Query<(&Parent, &mut Visibility)>, 23 | selected_query: Query<&Selected>, 24 | ) { 25 | for (parent, mut visibility) in query.iter_mut() { 26 | if selected_query.contains(parent.get()) { 27 | *visibility = Visibility::Inherited; 28 | } else { 29 | *visibility = Visibility::Hidden; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/src/events.rs: -------------------------------------------------------------------------------- 1 | // #[derive(Event, Debug, Copy, Clone)] 2 | // pub struct ClickEvent { 3 | // pub char_index: usize, 4 | // } 5 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/src/leptos_app.rs: -------------------------------------------------------------------------------- 1 | use crate::bevy_app::*; 2 | use crate::{RENDER_HEIGHT, RENDER_WIDTH}; 3 | use bevy::color::{palettes::tailwind::*, Srgba}; 4 | use leptos::prelude::*; 5 | use leptos_bevy_canvas::prelude::*; 6 | 7 | #[component] 8 | pub fn App() -> impl IntoView { 9 | let (selected, selected_query_duplex) = 10 | single_query_signal::<(RotationSpeed, ObjectColor, Selected), ()>(); 11 | 12 | Effect::new(move || { 13 | leptos::logging::log!("changed: {:?}", selected.get()); 14 | }); 15 | 16 | let inputs_disabled = Signal::derive(move || selected.read().is_none()); 17 | 18 | view! { 19 |
20 | 25 |
Click on a cube to select
26 | 27 |

Bevy

28 |
33 | 39 |
40 | 41 | 42 | 43 |

Leptos

44 | 45 |
48 | 49 | 57 | 58 | 59 | 83 |
84 | 85 |
86 | } 87 | } 88 | 89 | #[component] 90 | pub fn Frame(class: &'static str, children: Children) -> impl IntoView { 91 | view! {
{children()}
} 92 | } 93 | 94 | #[component] 95 | pub fn TailwindColorSelector( 96 | value: Signal>, 97 | #[prop(into)] on_input: Callback<(ObjectColor,)>, 98 | ) -> impl IntoView { 99 | const COLORS: [Srgba; 16] = [ 100 | YELLOW_500, 101 | AMBER_500, 102 | ORANGE_500, 103 | RED_500, 104 | VIOLET_500, 105 | PURPLE_500, 106 | FUCHSIA_500, 107 | PINK_500, 108 | INDIGO_500, 109 | BLUE_500, 110 | SKY_500, 111 | CYAN_500, 112 | LIME_500, 113 | GREEN_500, 114 | EMERALD_500, 115 | TEAL_500, 116 | ]; 117 | 118 | view! { 119 |
120 | 121 |
137 |
138 |
139 | } 140 | } 141 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/src/main.rs: -------------------------------------------------------------------------------- 1 | mod bevy_app; 2 | mod events; 3 | mod leptos_app; 4 | 5 | use crate::leptos_app::App; 6 | use leptos::prelude::mount_to_body; 7 | 8 | pub const RENDER_WIDTH: f32 = 996.0; 9 | pub const RENDER_HEIGHT: f32 = 622.5; 10 | 11 | fn main() { 12 | console_error_panic_hook::set_once(); 13 | 14 | mount_to_body(App); 15 | } 16 | -------------------------------------------------------------------------------- /examples/synced_bevy_query/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: { 4 | files: ["*.html", "./src/**/*.rs", "../../src/docs/**/*.rs"], 5 | }, 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [ 10 | require('@tailwindcss/forms'), 11 | ], 12 | } -------------------------------------------------------------------------------- /examples/unidir_events/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "unidir_events" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | bevy-inspector-egui.workspace = true 8 | console_error_panic_hook.workspace = true 9 | leptos.workspace = true 10 | leptos-bevy-canvas.workspace = true 11 | leptos-use.workspace = true 12 | meshtext = "0.3.1" 13 | 14 | [dependencies.bevy] 15 | workspace = true 16 | features = [ 17 | "bevy_mesh_picking_backend", 18 | "bevy_pbr", 19 | "bevy_picking", 20 | "bevy_window", 21 | "bevy_winit", 22 | "tonemapping_luts", 23 | "webgl2", 24 | ] 25 | -------------------------------------------------------------------------------- /examples/unidir_events/README.md: -------------------------------------------------------------------------------- 1 | # Unidirectional Events Example 2 | 3 | Shows off how to send and receive events from and to a Bevy app unidirectionally. 4 | 5 | ![Events Demo](https://media.githubusercontent.com/media/Synphonyte/leptos-bevy-canvas/refs/heads/main/docs/unidir_events.webp) 6 | 7 | ## Run the Example 8 | 9 | This runs just like any other CSR Leptos app. 10 | 11 | Make sure you have `trunk` installed. 12 | 13 | ```bash 14 | cargo install trunk 15 | ``` 16 | 17 | Then, to run the example, run 18 | 19 | ```bash 20 | trunk serve --open 21 | ``` -------------------------------------------------------------------------------- /examples/unidir_events/assets/environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:18c9a0ef0af29ec5cd4202cc77f2b000a4bdb603b5df80446e1740b94d54841b 3 | size 23305 4 | -------------------------------------------------------------------------------- /examples/unidir_events/assets/environment_maps/pisa_specular_rgb9e5_zstd.ktx2: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5f33159662393d62f7a8538e39e77356b8bc5ada1b5e7bf9ddfdcc790d7d7c84 3 | size 6791940 4 | -------------------------------------------------------------------------------- /examples/unidir_events/assets/fonts/FiraMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Synphonyte/leptos-bevy-canvas/fa699c8d487ce43970303e13e78488d258b47f61/examples/unidir_events/assets/fonts/FiraMono-Regular.ttf -------------------------------------------------------------------------------- /examples/unidir_events/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/unidir_events/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /examples/unidir_events/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@tailwindcss/forms": "^0.5.7", 4 | "tailwindcss": "^3.4.4" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/unidir_events/rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | -------------------------------------------------------------------------------- /examples/unidir_events/src/bevy_app/components.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | 3 | #[derive(Component, Deref, DerefMut)] 4 | pub struct CharIndex(usize); 5 | 6 | impl CharIndex { 7 | pub fn new(index: usize) -> Self { 8 | CharIndex(index) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/unidir_events/src/bevy_app/mod.rs: -------------------------------------------------------------------------------- 1 | mod components; 2 | mod resources; 3 | mod setup; 4 | mod systems; 5 | 6 | use crate::bevy_app::resources::*; 7 | use crate::bevy_app::setup::setup_scene; 8 | use crate::bevy_app::systems::*; 9 | use crate::events::{ClickEvent, TextEvent}; 10 | use crate::{RENDER_HEIGHT, RENDER_WIDTH}; 11 | use bevy::asset::AssetMetaCheck; 12 | use bevy::prelude::*; 13 | use bevy::window::WindowResolution; 14 | use leptos_bevy_canvas::prelude::*; 15 | 16 | pub fn init_bevy_app( 17 | text_receiver: BevyEventReceiver, 18 | click_sender: BevyEventSender, 19 | ) -> App { 20 | let mut app = App::new(); 21 | app.add_plugins(( 22 | DefaultPlugins 23 | .set(AssetPlugin { 24 | meta_check: AssetMetaCheck::Never, 25 | ..default() 26 | }) 27 | .set(WindowPlugin { 28 | primary_window: Some(Window { 29 | focused: false, 30 | fit_canvas_to_parent: true, 31 | canvas: Some("#bevy_canvas".into()), 32 | resolution: WindowResolution::new(RENDER_WIDTH, RENDER_HEIGHT), 33 | ..default() 34 | }), 35 | ..default() 36 | }), 37 | MeshPickingPlugin, 38 | // bevy_inspector_egui::quick::WorldInspectorPlugin::new(), 39 | )) 40 | .init_resource::() 41 | .init_resource::() 42 | .import_event_from_leptos(text_receiver) 43 | .export_event_to_leptos(click_sender) 44 | .add_systems(Startup, (setup_scene,)) 45 | .add_systems(Update, (update_text,)); 46 | 47 | app 48 | } 49 | -------------------------------------------------------------------------------- /examples/unidir_events/src/bevy_app/resources.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | 3 | #[derive(Resource, Clone, Default)] 4 | pub struct CurrentText { 5 | pub text: String, 6 | pub glyph_entities: Vec, 7 | } 8 | 9 | #[derive(Resource, Clone, Copy, Default)] 10 | pub enum SelectedGlyph { 11 | #[default] 12 | None, 13 | Some { 14 | entity: Entity, 15 | index: usize, 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /examples/unidir_events/src/bevy_app/setup.rs: -------------------------------------------------------------------------------- 1 | use bevy::asset::Assets; 2 | use bevy::color::palettes::tailwind::GRAY_700; 3 | use bevy::color::Color; 4 | use bevy::core_pipeline::Skybox; 5 | use bevy::math::Vec3; 6 | use bevy::pbr::{MeshMaterial3d, PointLight, StandardMaterial}; 7 | use bevy::picking::PickingBehavior; 8 | use bevy::prelude::*; 9 | use bevy::render::mesh::CylinderMeshBuilder; 10 | 11 | pub const CAMERA_LOOK_AT: Vec3 = Vec3::new(0.0, 0.0, -0.2); 12 | 13 | pub fn setup_scene( 14 | mut commands: Commands, 15 | mut meshes: ResMut>, 16 | mut materials: ResMut>, 17 | asset_server: Res, 18 | ) { 19 | let ground_matl = materials.add(Color::from(GRAY_700)); 20 | 21 | // Ground 22 | commands.spawn(( 23 | Mesh3d(meshes.add(CylinderMeshBuilder::new(7.0, 10.0, 16).build())), 24 | MeshMaterial3d(ground_matl.clone()), 25 | PickingBehavior::IGNORE, // Disable picking for the ground plane. 26 | Transform::from_xyz(0.0, -5.5, CAMERA_LOOK_AT.z), 27 | Name::new("Ground"), 28 | )); 29 | 30 | // Light 31 | commands.spawn(( 32 | PointLight { 33 | shadows_enabled: true, 34 | intensity: 10_000_000., 35 | range: 100.0, 36 | shadow_depth_bias: 0.2, 37 | ..default() 38 | }, 39 | Transform::from_xyz(8.0, 16.0, 8.0), 40 | )); 41 | 42 | // Camera 43 | commands.spawn(( 44 | Camera3d::default(), 45 | Transform::from_xyz(0.0, 7., 14.0).looking_at(CAMERA_LOOK_AT, Vec3::Y), 46 | EnvironmentMapLight { 47 | diffuse_map: asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2"), 48 | specular_map: asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2"), 49 | intensity: 900.0, 50 | ..default() 51 | }, 52 | Skybox { 53 | image: asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2"), 54 | brightness: 500.0, 55 | rotation: Quat::IDENTITY, 56 | }, 57 | )); 58 | } 59 | -------------------------------------------------------------------------------- /examples/unidir_events/src/bevy_app/systems.rs: -------------------------------------------------------------------------------- 1 | use crate::bevy_app::components::CharIndex; 2 | use crate::bevy_app::resources::*; 3 | use crate::bevy_app::setup::CAMERA_LOOK_AT; 4 | use crate::events::{ClickEvent, TextEvent}; 5 | use bevy::asset::{Assets, RenderAssetUsages}; 6 | use bevy::color::palettes::tailwind::GREEN_100; 7 | use bevy::color::Color; 8 | use bevy::math::{Mat4, Vec3}; 9 | use bevy::pbr::{MeshMaterial3d, StandardMaterial}; 10 | use bevy::prelude::*; 11 | use bevy::render::mesh::PrimitiveTopology; 12 | use meshtext::{Face, Glyph, MeshGenerator, MeshText}; 13 | 14 | const LETTER_Y_ANGLE_STEP: f32 = 0.08; 15 | 16 | pub fn update_text( 17 | mut commands: Commands, 18 | mut event_reader: EventReader, 19 | mut camera_query: Query<&mut Transform, With>, 20 | mut current_text: ResMut, 21 | mut meshes: ResMut>, 22 | mut materials: ResMut>, 23 | mut selected_glyph: ResMut, 24 | ) { 25 | for event in event_reader.read() { 26 | let transform_step: Transform = 27 | Transform::from_rotation(Quat::from_rotation_y(LETTER_Y_ANGLE_STEP)); 28 | 29 | let font_data = include_bytes!("../../assets/fonts/FiraMono-Regular.ttf"); 30 | let mut generator = MeshGenerator::new(font_data); 31 | 32 | let mut transform = Transform::from_xyz(0.0, 0.0, 6.0); 33 | let mut new_glyph_entites = Vec::new(); 34 | 35 | let camera_rot = 36 | Quat::from_rotation_y(LETTER_Y_ANGLE_STEP * current_text.text.len() as f32 * 0.5); 37 | let camera_pos = camera_rot * Vec3::new(0.0, 7., 14.0); 38 | let mut camera_transform = camera_query.single_mut(); 39 | *camera_transform = 40 | Transform::from_translation(camera_pos).looking_at(CAMERA_LOOK_AT, Vec3::Y); 41 | 42 | for (i, ((existing_glyph, event_glyph), existing_glyph_entity)) in current_text 43 | .text 44 | .chars() 45 | .zip(event.text.chars()) 46 | .zip(current_text.glyph_entities.iter().cloned()) 47 | .enumerate() 48 | { 49 | if existing_glyph != event_glyph { 50 | let new_glyph_entity = spawn_letter( 51 | event_glyph, 52 | i, 53 | transform, 54 | &mut commands, 55 | &mut meshes, 56 | &mut materials, 57 | &mut generator, 58 | ); 59 | 60 | new_glyph_entites.push(new_glyph_entity); 61 | commands.entity(existing_glyph_entity).despawn(); 62 | } else { 63 | new_glyph_entites.push(existing_glyph_entity); 64 | } 65 | 66 | transform = transform_step * transform; 67 | } 68 | 69 | let diff = current_text.text.len() as i32 - event.text.len() as i32; 70 | 71 | if diff > 0 { 72 | for entity in current_text.glyph_entities.iter().skip(event.text.len()) { 73 | commands.entity(*entity).despawn(); 74 | } 75 | } else if diff < 0 { 76 | let mut i = current_text.text.len(); 77 | for glyph in event.text.chars().skip(i) { 78 | let glyph_transform = if let SelectedGlyph::Some { index, .. } = *selected_glyph { 79 | if index == i { 80 | let mut t = transform.clone(); 81 | t.translation.y = 0.5; 82 | t 83 | } else { 84 | transform 85 | } 86 | } else { 87 | transform 88 | }; 89 | 90 | let new_glyph_entity = spawn_letter( 91 | glyph, 92 | i, 93 | glyph_transform, 94 | &mut commands, 95 | &mut meshes, 96 | &mut materials, 97 | &mut generator, 98 | ); 99 | new_glyph_entites.push(new_glyph_entity); 100 | 101 | if let SelectedGlyph::Some { index, entity } = &mut *selected_glyph { 102 | if *index == i { 103 | *entity = new_glyph_entity; 104 | } 105 | } 106 | 107 | transform = transform_step * transform; 108 | i += 1; 109 | } 110 | } 111 | 112 | current_text.glyph_entities = new_glyph_entites; 113 | current_text.text = event.text.clone(); 114 | } 115 | } 116 | 117 | fn spawn_letter( 118 | glyph: char, 119 | glyph_index: usize, 120 | glyph_transform: Transform, 121 | commands: &mut Commands, 122 | meshes: &mut ResMut>, 123 | materials: &mut ResMut>, 124 | text_generator: &mut MeshGenerator, 125 | ) -> Entity { 126 | let transform = Mat4::from_scale(Vec3::new(1.0, 1.0, 0.2)).to_cols_array(); 127 | let text_mesh: MeshText = text_generator 128 | .generate_glyph(glyph, false, Some(&transform)) 129 | .unwrap(); 130 | 131 | let vertices = text_mesh.vertices; 132 | let positions: Vec<[f32; 3]> = vertices.chunks(3).map(|c| [c[0], c[1], c[2]]).collect(); 133 | let uvs = vec![[0.0, 0.0]; positions.len()]; 134 | 135 | let mut mesh = Mesh::new( 136 | PrimitiveTopology::TriangleList, 137 | RenderAssetUsages::default(), 138 | ); 139 | mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); 140 | mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); 141 | mesh.compute_flat_normals(); 142 | 143 | // text 144 | let glyph_width = text_mesh.bbox.size().x; 145 | let entity = commands 146 | // use this bundle to change the rotation pivot to the center 147 | .spawn(( 148 | Mesh3d(meshes.add(mesh)), 149 | MeshMaterial3d(materials.add(StandardMaterial { 150 | base_color: Color::from(GREEN_100), 151 | perceptual_roughness: 0.1, 152 | reflectance: 1.0, 153 | ..default() 154 | })), 155 | // transform mesh so that it is in the center 156 | glyph_transform * Transform::from_translation(Vec3::new(-glyph_width * 0.5, 0.0, 0.0)), 157 | CharIndex::new(glyph_index), 158 | )) 159 | .observe(on_char_click) 160 | .id(); 161 | 162 | entity 163 | } 164 | 165 | fn on_char_click( 166 | trigger: Trigger>, 167 | index_query: Query<&CharIndex>, 168 | mut transform_query: Query<&mut Transform>, 169 | mut event_writer: EventWriter, 170 | mut selected_glyph: ResMut, 171 | ) { 172 | let entity = trigger.entity(); 173 | 174 | if let Ok(index) = index_query.get(entity) { 175 | let index = **index; 176 | 177 | if let SelectedGlyph::Some { entity, .. } = *selected_glyph { 178 | if let Ok(mut transform) = transform_query.get_mut(entity) { 179 | transform.translation.y = 0.0; 180 | } 181 | } 182 | 183 | *selected_glyph = SelectedGlyph::Some { entity, index }; 184 | 185 | if let Ok(mut transform) = transform_query.get_mut(entity) { 186 | transform.translation.y = 0.5; 187 | } 188 | 189 | event_writer.send(ClickEvent { char_index: index }); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /examples/unidir_events/src/events.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | 3 | #[derive(Event, Debug)] 4 | pub struct TextEvent { 5 | pub text: String, 6 | } 7 | 8 | #[derive(Event, Debug, Copy, Clone)] 9 | pub struct ClickEvent { 10 | pub char_index: usize, 11 | } 12 | -------------------------------------------------------------------------------- /examples/unidir_events/src/leptos_app.rs: -------------------------------------------------------------------------------- 1 | use crate::bevy_app::init_bevy_app; 2 | use crate::events::{ClickEvent, TextEvent}; 3 | use crate::{RENDER_HEIGHT, RENDER_WIDTH}; 4 | use leptos::prelude::Set; 5 | use leptos::prelude::*; 6 | use leptos_bevy_canvas::prelude::*; 7 | use leptos_use::use_debounce_fn; 8 | 9 | #[derive(Copy, Clone)] 10 | pub enum EventDirection { 11 | None, 12 | LeptosToBevy, 13 | BevyToLeptos, 14 | } 15 | 16 | #[component] 17 | pub fn App() -> impl IntoView { 18 | let (text_event_sender, text_receiver) = event_l2b::(); 19 | let (click_event_receiver, click_event_sender) = event_b2l::(); 20 | 21 | let (text, set_text) = signal(String::new()); 22 | let (event_str, set_event_str) = signal(String::new()); 23 | let (event_direction, set_event_direction) = signal(EventDirection::None); 24 | 25 | let on_input = move |text: String| { 26 | set_text.set(text.clone()); 27 | 28 | let text_event = TextEvent { text }; 29 | 30 | set_event_str.set(format!("{:#?}", text_event)); 31 | set_event_direction.set(EventDirection::LeptosToBevy); 32 | 33 | text_event_sender.send(text_event).ok(); 34 | }; 35 | 36 | Effect::new(move || { 37 | if let Some(event) = click_event_receiver.get() { 38 | set_event_str.set(format!("{:#?}", event)); 39 | set_event_direction.set(EventDirection::BevyToLeptos); 40 | } 41 | }); 42 | 43 | view! { 44 |
45 | 46 |

Bevy

47 |
52 | 58 |
59 | 60 | 61 | 62 | 63 | 64 |

Leptos

65 | 66 | 67 | 68 |
69 | } 70 | } 71 | 72 | #[component] 73 | pub fn TextDisplay( 74 | text: ReadSignal, 75 | click_event_receiver: LeptosEventReceiver, 76 | ) -> impl IntoView { 77 | view! { 78 |
79 | Preview 80 |
81 |
82 | >() } 84 | key=|(i, _)| *i 85 | children=move |(i, c)| { 86 | let class = move || { 87 | let class = if let Some(event) = click_event_receiver.get() { 88 | if event.char_index == i { "top-[-5px]" } else { "top-0" } 89 | } else { 90 | "top-0" 91 | }; 92 | 93 | format!( 94 | "relative inline-block transition-all duration-200 ease-out {class}", 95 | ) 96 | }; 97 | 98 | view! { {c} } 99 | } 100 | /> 101 |
102 | } 103 | } 104 | 105 | #[component] 106 | pub fn EventDisplay( 107 | event_str: ReadSignal, 108 | event_direction: ReadSignal, 109 | ) -> impl IntoView { 110 | let (event_display_class, set_event_display_class) = signal("opacity-0".to_string()); 111 | 112 | let reset_event_display_class = move || { 113 | set_event_display_class 114 | .set("opacity-30 transition-opacity duration-1000 ease-in".to_string()) 115 | }; 116 | let debounced_reset_event_display_class = use_debounce_fn(reset_event_display_class, 500.0); 117 | let activate_event_display = move || { 118 | set_event_display_class.set("opacity-100".to_string()); 119 | debounced_reset_event_display_class(); 120 | }; 121 | 122 | Effect::watch( 123 | move || event_str.track(), 124 | move |_, _, _| { 125 | activate_event_display(); 126 | }, 127 | false, 128 | ); 129 | 130 | view! { 131 |
132 | 133 |
139 |                 {event_str}
140 |             
141 |
142 | } 143 | } 144 | 145 | #[component] 146 | pub fn EventDirectionIndicator(event_direction: ReadSignal) -> impl IntoView { 147 | let color = Signal::derive(move || match event_direction.get() { 148 | EventDirection::LeptosToBevy => "rgb(59, 130, 246)", 149 | EventDirection::BevyToLeptos => "rgb(239, 68, 68)", 150 | EventDirection::None => "transparent", 151 | }); 152 | 153 | let transform = Signal::derive(move || match event_direction.get() { 154 | EventDirection::LeptosToBevy => "scale(1, 1)", 155 | EventDirection::BevyToLeptos => "scale(-1, 1)", 156 | EventDirection::None => "scale(1, 1)", 157 | }); 158 | 159 | // svg arrow 160 | view! { 161 | 162 | 163 | 164 | 165 | 166 | 167 | } 168 | } 169 | 170 | #[component] 171 | pub fn Frame(class: &'static str, children: Children) -> impl IntoView { 172 | view! {
{children()}
} 173 | } 174 | 175 | #[component] 176 | pub fn TextInput(#[prop(into)] on_input: Callback<(String,)>) -> impl IntoView { 177 | let (value, set_value) = signal(String::new()); 178 | 179 | let on_input = move |evt| { 180 | let text = event_target_value(&evt).replace(" ", ""); 181 | set_value.set(text.clone()); 182 | on_input.run((text,)); 183 | }; 184 | 185 | view! { 186 |
187 | 190 | 198 |
199 | } 200 | } 201 | -------------------------------------------------------------------------------- /examples/unidir_events/src/main.rs: -------------------------------------------------------------------------------- 1 | mod bevy_app; 2 | mod events; 3 | mod leptos_app; 4 | 5 | use crate::leptos_app::App; 6 | use leptos::prelude::mount_to_body; 7 | 8 | pub const RENDER_WIDTH: f32 = 600.0; 9 | pub const RENDER_HEIGHT: f32 = 500.0; 10 | 11 | fn main() { 12 | console_error_panic_hook::set_once(); 13 | 14 | mount_to_body(App); 15 | } 16 | -------------------------------------------------------------------------------- /examples/unidir_events/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: { 4 | files: ["*.html", "./src/**/*.rs", "../../src/docs/**/*.rs"], 5 | }, 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [ 10 | require('@tailwindcss/forms'), 11 | ], 12 | } -------------------------------------------------------------------------------- /src/app_extension.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::{BevyQueryDuplex, QueryDataOwned}; 2 | use crate::systems::*; 3 | use crate::traits::{HasReceiver, HasSender}; 4 | use bevy::ecs::query::QueryFilter; 5 | use bevy::prelude::*; 6 | 7 | /// Adds synchronization methods to the Bevy app 8 | pub trait LeptosBevyApp { 9 | /// Imports an event from Leptos into the Bevy app. Takes the Bevy event receiver as argument. 10 | fn import_event_from_leptos(&mut self, bevy_rx: R) -> &mut Self 11 | where 12 | E: Event, 13 | R: HasReceiver + Resource; 14 | 15 | /// Exports an event from Bevy to Leptos. Takes the Bevy event sender as argument. 16 | fn export_event_to_leptos(&mut self, bevy_tx: S) -> &mut Self 17 | where 18 | E: Event + Clone, 19 | S: HasSender + Resource; 20 | 21 | /// Adds duplex event handling between Bevy and Leptos. Takes the Bevy event receiver/sender as argument. 22 | fn add_duplex_leptos_event(&mut self, bevy_duplex: D) -> &mut Self 23 | where 24 | E: Event + Clone, 25 | D: HasReceiver + HasSender + Resource; 26 | 27 | /// Adds resource syncing between Bevy and Leptos. Takes the Bevy resource receiver/sender as argument. 28 | fn sync_leptos_signal_with_resource(&mut self, bevy_duplex: D) -> &mut Self 29 | where 30 | R: Resource + Clone, 31 | D: HasReceiver + HasSender + Resource; 32 | 33 | fn sync_leptos_signal_with_query(&mut self, duplex: BevyQueryDuplex) -> &mut Self 34 | where 35 | for<'a> D: QueryDataOwned<'a> + Send + Sync + 'static, 36 | F: QueryFilter + 'static; 37 | } 38 | 39 | impl LeptosBevyApp for App { 40 | fn import_event_from_leptos(&mut self, bevy_rx: R) -> &mut Self 41 | where 42 | E: Event, 43 | R: HasReceiver + Resource, 44 | { 45 | self.insert_resource(bevy_rx) 46 | .add_event::() 47 | .init_resource::>() 48 | .add_systems( 49 | PreUpdate, 50 | import_and_send_leptos_events::.in_set(ImportLeptosEventSet), 51 | ) 52 | } 53 | 54 | fn export_event_to_leptos(&mut self, bevy_tx: R) -> &mut Self 55 | where 56 | E: Event + Clone, 57 | R: HasSender + Resource, 58 | { 59 | self.insert_resource(bevy_tx) 60 | .add_event::() 61 | .init_resource::>() 62 | .add_systems( 63 | PostUpdate, 64 | read_and_export_leptos_events::.in_set(ExportLeptosEventSet), 65 | ) 66 | } 67 | 68 | fn add_duplex_leptos_event(&mut self, bevy_duplex: D) -> &mut Self 69 | where 70 | E: Event + Clone, 71 | D: HasReceiver + HasSender + Resource, 72 | { 73 | self.insert_resource(bevy_duplex) 74 | .add_event::() 75 | .add_systems( 76 | PreUpdate, 77 | import_and_send_leptos_events::.in_set(ImportLeptosEventSet), 78 | ) 79 | .add_systems( 80 | PostUpdate, 81 | read_and_export_leptos_events::.in_set(ExportLeptosEventSet), 82 | ) 83 | } 84 | 85 | fn sync_leptos_signal_with_resource(&mut self, bevy_duplex: D) -> &mut Self 86 | where 87 | R: Resource + Clone, 88 | D: HasReceiver + HasSender + Resource, 89 | { 90 | for event in bevy_duplex.rx().try_iter() { 91 | self.insert_resource(event); 92 | } 93 | 94 | self.insert_resource(bevy_duplex).add_systems( 95 | Update, 96 | sync_signal_resource::.in_set(SyncSignalResourceSet), 97 | ) 98 | } 99 | 100 | fn sync_leptos_signal_with_query(&mut self, duplex: BevyQueryDuplex) -> &mut Self 101 | where 102 | for<'a> D: QueryDataOwned<'a> + Send + Sync + 'static, 103 | F: QueryFilter + 'static, 104 | { 105 | self.insert_resource(duplex.duplex) 106 | .add_systems(Update, sync_query::.in_set(SyncQuerySet)) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/events/bevy/macros.rs: -------------------------------------------------------------------------------- 1 | macro_rules! impl_has_receiver { 2 | ($name:ident) => { 3 | impl HasReceiver for $name { 4 | fn rx(&self) -> &crossbeam_channel::Receiver { 5 | &self.rx 6 | } 7 | } 8 | }; 9 | } 10 | 11 | macro_rules! impl_has_sender { 12 | ($name:ident) => { 13 | impl HasSender for $name { 14 | fn tx(&self) -> &crossbeam_channel::Sender { 15 | &self.tx 16 | } 17 | } 18 | }; 19 | } 20 | 21 | pub(super) use impl_has_receiver; 22 | pub(super) use impl_has_sender; 23 | -------------------------------------------------------------------------------- /src/events/bevy/mod.rs: -------------------------------------------------------------------------------- 1 | mod macros; 2 | 3 | use crate::traits::{HasReceiver, HasSender}; 4 | use bevy::prelude::*; 5 | use crossbeam_channel::{Receiver, Sender}; 6 | 7 | use crate::events::bevy::macros::{impl_has_receiver, impl_has_sender}; 8 | 9 | /// This is passed to Bevy to receive events from the Leptos app. 10 | #[derive(Resource)] 11 | pub struct BevyEventReceiver { 12 | rx: Receiver, 13 | } 14 | 15 | impl Clone for BevyEventReceiver { 16 | fn clone(&self) -> Self { 17 | Self { 18 | rx: self.rx.clone(), 19 | } 20 | } 21 | } 22 | 23 | impl std::fmt::Debug for BevyEventReceiver { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | let mut s = f.debug_struct("BevyEventReceiver"); 26 | s.field("rx", &self.rx); 27 | s.finish() 28 | } 29 | } 30 | 31 | impl BevyEventReceiver { 32 | #[inline] 33 | pub fn new(rx: Receiver) -> Self { 34 | Self { rx } 35 | } 36 | } 37 | 38 | impl_has_receiver!(BevyEventReceiver); 39 | 40 | /// This is passed to Bevy to send events to the Leptos app. 41 | #[derive(Resource)] 42 | pub struct BevyEventSender { 43 | tx: Sender, 44 | } 45 | 46 | impl Clone for BevyEventSender { 47 | fn clone(&self) -> Self { 48 | Self { 49 | tx: self.tx.clone(), 50 | } 51 | } 52 | } 53 | 54 | impl std::fmt::Debug for BevyEventSender { 55 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 56 | let mut s = f.debug_struct("BevyEventSender"); 57 | s.field("tx", &self.tx); 58 | s.finish() 59 | } 60 | } 61 | 62 | impl BevyEventSender { 63 | #[inline] 64 | pub fn new(tx: Sender) -> Self { 65 | Self { tx } 66 | } 67 | } 68 | 69 | impl_has_sender!(BevyEventSender); 70 | 71 | /// This is passed to Bevy to send and receive events in both directions. 72 | #[derive(Resource)] 73 | pub struct BevyEventDuplex { 74 | tx: Sender, 75 | rx: Receiver, 76 | } 77 | 78 | impl Clone for BevyEventDuplex { 79 | fn clone(&self) -> Self { 80 | Self { 81 | tx: self.tx.clone(), 82 | rx: self.rx.clone(), 83 | } 84 | } 85 | } 86 | 87 | impl std::fmt::Debug for BevyEventDuplex { 88 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 89 | let mut s = f.debug_struct("BevyEventDuplex"); 90 | s.field("tx", &self.tx); 91 | s.field("rx", &self.rx); 92 | s.finish() 93 | } 94 | } 95 | 96 | impl BevyEventDuplex { 97 | #[inline] 98 | pub fn new(rx: Receiver, tx: Sender) -> Self { 99 | Self { tx, rx } 100 | } 101 | } 102 | 103 | impl_has_receiver!(BevyEventDuplex); 104 | impl_has_sender!(BevyEventDuplex); 105 | -------------------------------------------------------------------------------- /src/events/leptos/macros.rs: -------------------------------------------------------------------------------- 1 | macro_rules! impl_read_signal { 2 | ($name:ident) => { 3 | impl DefinedAt for $name 4 | where 5 | E: Send + Sync + 'static, 6 | { 7 | fn defined_at(&self) -> Option<&'static Location<'static>> { 8 | self.rx_signal.defined_at() 9 | } 10 | } 11 | 12 | impl IsDisposed for $name 13 | where 14 | E: Send + Sync + 'static, 15 | { 16 | fn is_disposed(&self) -> bool { 17 | self.rx_signal.is_disposed() 18 | } 19 | } 20 | 21 | impl ReadUntracked for $name 22 | where 23 | E: Send + Sync + 'static, 24 | { 25 | type Value = ReadGuard, Plain>>; 26 | 27 | fn try_read_untracked(&self) -> Option { 28 | self.rx_signal.try_read_untracked() 29 | } 30 | } 31 | 32 | impl Track for $name 33 | where 34 | E: Send + Sync + 'static, 35 | { 36 | fn track(&self) { 37 | self.rx_signal.track() 38 | } 39 | } 40 | }; 41 | } 42 | 43 | pub(super) use impl_read_signal; 44 | -------------------------------------------------------------------------------- /src/events/leptos/mod.rs: -------------------------------------------------------------------------------- 1 | mod macros; 2 | mod traits; 3 | 4 | use crate::events::leptos::macros::impl_read_signal; 5 | use crossbeam_channel::{Receiver, Sender}; 6 | use leptos::prelude::guards::{Plain, ReadGuard}; 7 | use leptos::prelude::*; 8 | use std::panic::Location; 9 | 10 | pub use self::traits::*; 11 | 12 | /// This is a Leptos event sender that can be used to send events to Bevy. 13 | /// It provides a `send` method to do this. 14 | #[derive(Copy)] 15 | pub struct LeptosEventSender 16 | where 17 | E: Send + Sync + 'static, 18 | { 19 | tx: StoredValue>, 20 | } 21 | 22 | impl Clone for LeptosEventSender 23 | where 24 | E: Send + Sync + 'static, 25 | { 26 | fn clone(&self) -> Self { 27 | Self { tx: self.tx } 28 | } 29 | } 30 | 31 | impl LeptosChannelEventSender for LeptosEventSender 32 | where 33 | E: Send + Sync + 'static, 34 | { 35 | type Event = E; 36 | 37 | fn tx(&self) -> StoredValue> { 38 | self.tx 39 | } 40 | } 41 | 42 | impl LeptosEventSender 43 | where 44 | E: Send + Sync + 'static, 45 | { 46 | pub fn new(tx: Sender) -> Self { 47 | Self { 48 | tx: StoredValue::new(tx), 49 | } 50 | } 51 | } 52 | 53 | /// This is a Leptos event receiver that can be used to receive events from Bevy. 54 | /// This can be used just like a normal Leptos `Signal` to read the latest event. 55 | #[derive(Copy)] 56 | pub struct LeptosEventReceiver 57 | where 58 | E: Send + Sync + 'static, 59 | { 60 | rx: StoredValue>, 61 | rx_signal: RwSignal>, 62 | } 63 | 64 | impl Clone for LeptosEventReceiver 65 | where 66 | E: Send + Sync + 'static, 67 | { 68 | fn clone(&self) -> Self { 69 | Self { 70 | rx: self.rx, 71 | rx_signal: self.rx_signal, 72 | } 73 | } 74 | } 75 | 76 | impl_read_signal!(LeptosEventReceiver); 77 | 78 | impl LeptosEventReceiver 79 | where 80 | E: Send + Sync + 'static, 81 | { 82 | #[inline] 83 | pub fn new(rx: Receiver, signal: RwSignal>) -> Self { 84 | Self { 85 | rx: StoredValue::new(rx), 86 | rx_signal: signal, 87 | } 88 | } 89 | } 90 | 91 | /// Combines the functionality of `LeptosEventSender` and `LeptosEventReceiver`. 92 | #[derive(Copy)] 93 | pub struct LeptosEventDuplex 94 | where 95 | E: Send + Sync + 'static, 96 | { 97 | tx: StoredValue>, 98 | rx: StoredValue>, 99 | rx_signal: RwSignal>, 100 | } 101 | 102 | impl Clone for LeptosEventDuplex 103 | where 104 | E: Send + Sync + 'static, 105 | { 106 | fn clone(&self) -> Self { 107 | Self { 108 | tx: self.tx, 109 | rx: self.rx, 110 | rx_signal: self.rx_signal, 111 | } 112 | } 113 | } 114 | 115 | impl LeptosChannelEventSender for LeptosEventDuplex 116 | where 117 | E: Send + Sync + 'static, 118 | { 119 | type Event = E; 120 | 121 | #[inline] 122 | fn tx(&self) -> StoredValue> { 123 | self.tx 124 | } 125 | } 126 | 127 | impl_read_signal!(LeptosEventDuplex); 128 | 129 | impl LeptosEventDuplex 130 | where 131 | E: Send + Sync + 'static, 132 | { 133 | #[inline] 134 | pub fn new(rx: Receiver, rx_signal: RwSignal>, tx: Sender) -> Self { 135 | Self { 136 | tx: StoredValue::new(tx), 137 | rx: StoredValue::new(rx), 138 | rx_signal, 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/events/leptos/traits.rs: -------------------------------------------------------------------------------- 1 | use crossbeam_channel::{SendError, Sender}; 2 | use leptos::prelude::*; 3 | 4 | /// This is a trait that is implemented by a Leptos event sender. 5 | pub trait LeptosChannelEventSender { 6 | type Event: Send + Sync + 'static; 7 | 8 | fn tx(&self) -> StoredValue>; 9 | 10 | /// Call this to send an event to the Bevy app. 11 | #[inline] 12 | fn send(&self, event: Self::Event) -> Result<(), SendError> { 13 | self.tx().with_value(|tx| tx.send(event)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/events/mod.rs: -------------------------------------------------------------------------------- 1 | mod bevy; 2 | mod leptos; 3 | 4 | pub use crate::events::bevy::*; 5 | pub use crate::events::leptos::*; 6 | use crate::utils::init_rw_signal_from_receiver; 7 | 8 | /// Creates a pair of a `LeptosEventSender` and a `BevyEventReceiver`. 9 | /// 10 | /// The `LeptosEventSender` can be used in the Leptos app to send events to Bevy. 11 | /// The `BevyEventReceiver` has to be passed to the Bevy app to receive these events in the form 12 | /// of normal Bevy events. 13 | pub fn event_l2b() -> (LeptosEventSender, BevyEventReceiver) 14 | where 15 | E: Send + Sync + 'static, 16 | { 17 | let (tx, rx) = crossbeam_channel::bounded(50); 18 | 19 | (LeptosEventSender::new(tx), BevyEventReceiver::new(rx)) 20 | } 21 | 22 | /// Creates a pair of a `LeptosEventReceiver` and a `BevyEventSender`. 23 | /// 24 | /// The `LeptosEventReceiver` can be used in the Leptos app like a normal `Signal` to read the 25 | /// latest event that was received from Bevy. 26 | /// The `BevyEventSender` has to be passed to the Bevy app so whenever you write the specified 27 | /// event in the Bevy app with an event writer it will be sent to the Leptos signal. 28 | pub fn event_b2l() -> (LeptosEventReceiver, BevyEventSender) 29 | where 30 | E: Send + Sync + 'static, 31 | { 32 | let (tx, rx) = crossbeam_channel::bounded(50); 33 | 34 | let signal = init_rw_signal_from_receiver(&rx); 35 | 36 | ( 37 | LeptosEventReceiver::new(rx, signal), 38 | BevyEventSender::new(tx), 39 | ) 40 | } 41 | 42 | /// Combines the functionality of `event_l2b` and `event_b2l` to send and receive events in 43 | /// both directions. 44 | pub fn event_duplex() -> (LeptosEventDuplex, BevyEventDuplex) 45 | where 46 | E: Send + Sync + 'static, 47 | { 48 | let (tx_l2b, rx_l2b) = crossbeam_channel::bounded(50); 49 | let (tx_b2l, rx_b2l) = crossbeam_channel::bounded(50); 50 | 51 | let signal = init_rw_signal_from_receiver(&rx_b2l); 52 | 53 | ( 54 | LeptosEventDuplex::new(rx_b2l, signal, tx_l2b), 55 | BevyEventDuplex::new(rx_l2b, tx_b2l), 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/leptos_component.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use leptos::prelude::*; 3 | 4 | /// Embeds a Bevy app in a Leptos component. It will add an HTML canvas element and start 5 | /// running the Bevy app inside it. 6 | #[component] 7 | pub fn BevyCanvas( 8 | /// This function is be called to initialize and return the Bevy app. 9 | init: impl FnOnce() -> App + 'static, 10 | /// Optional canvas id. Defaults to `bevy_canvas`. 11 | #[prop(into, default = "bevy_canvas".to_string())] 12 | canvas_id: String, 13 | ) -> impl IntoView { 14 | request_animation_frame(move || { 15 | let mut app = init(); 16 | app.run(); 17 | }); 18 | 19 | view! { } 20 | } 21 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Embed an idiomatic Bevy app inside your Leptos app. 2 | //! 3 | //! [Send and Receive Events ![Events Demo](https://media.githubusercontent.com/media/Synphonyte/leptos-bevy-canvas/refs/heads/main/docs/unidir_events.webp)](https://github.com/Synphonyte/leptos-bevy-canvas/tree/main/examples/unidir_events) 4 | //! 5 | //! [Sync Bevy Queries ![Query Sync Demo](https://media.githubusercontent.com/media/Synphonyte/leptos-bevy-canvas/refs/heads/main/docs/synced_bevy_query.webp)](https://github.com/Synphonyte/leptos-bevy-canvas/tree/main/examples/synced_bevy_query) 6 | //! 7 | //! # Features 8 | //! 9 | //! - **Easy to use** - Simply embed your Bevy app inside your Leptos app with the 10 | //! [`BevyCanvas`](fn@crate::prelude::BevyCanvas) component. 11 | //! - **Idiomatic** - This crate doesn't want you to do anything differently in the way you write 12 | //! your Bevy app or your Leptos app. It just gives you the tools for them to communicate. 13 | //! - **Events** - Send events in either or both directions between your Bevy app and your Leptos app. 14 | //! - **Resource signals** - Synchronize Bevy `Resource`s with `RwSignal`s in your Leptos app. 15 | //! - **Query signals** - Synchronize Bevy `Query`s with `RwSignal`s in your Leptos app. 16 | //! 17 | //! # Example 18 | //! 19 | //! ``` 20 | //! use bevy::prelude::*; 21 | //! use leptos::prelude::*; 22 | //! use leptos_bevy_canvas::prelude::*; 23 | //! 24 | //! #[derive(Event)] 25 | //! pub struct TextEvent { 26 | //! pub text: String, 27 | //! } 28 | //! 29 | //! #[component] 30 | //! pub fn App() -> impl IntoView { 31 | //! // This initializes a sender for the Leptos app and 32 | //! // a receiver for the Bevy app 33 | //! let (text_event_sender, bevy_text_receiver) = event_l2b::(); 34 | //! 35 | //! let on_input = move |evt| { 36 | //! // send the event over to Bevy 37 | //! text_event_sender 38 | //! .send(TextEvent { text: event_target_value(&evt) }) 39 | //! .ok(); 40 | //! }; 41 | //! 42 | //! view! { 43 | //! 44 | //! 45 | //! 55 | //! } 56 | //! } 57 | //! 58 | //! // In Bevy it ends up just as a normal event 59 | //! pub fn set_text( 60 | //! mut event_reader: EventReader, 61 | //! ) { 62 | //! for event in event_reader.read() { 63 | //! // do something with the event 64 | //! } 65 | //! } 66 | //! 67 | //! // This initializes a normal Bevy app 68 | //! fn init_bevy_app( text_receiver: BevyEventReceiver) -> App { 69 | //! let mut app = App::new(); 70 | //! app 71 | //! .add_plugins( 72 | //! DefaultPlugins.set(WindowPlugin { 73 | //! primary_window: Some(Window { 74 | //! // "#bevy_canvas" is the default and can be 75 | //! // changed in the component 76 | //! canvas: Some("#bevy_canvas".into()), 77 | //! ..default() 78 | //! }), 79 | //! ..default() 80 | //! }), 81 | //! ) 82 | //! // import the event here into Bevy 83 | //! .import_event_from_leptos(text_receiver) 84 | //! .add_systems(Update, set_text); 85 | //! 86 | //! app 87 | //! } 88 | //! ``` 89 | //! 90 | //! Please check the examples to see how to synchronize a `Resource` or a `Query`. 91 | //! 92 | //! # Compatibility 93 | //! 94 | //! | Crate version | Compatible Leptos version | Compatible Bevy version | 95 | //! |---------------|---------------------------|-------------------------| 96 | //! | 0.1, 0.2 | 0.7 | 0.15 | 97 | 98 | mod app_extension; 99 | mod events; 100 | mod leptos_component; 101 | mod queries; 102 | mod signal_synced; 103 | pub mod systems; 104 | pub mod traits; 105 | mod utils; 106 | 107 | pub mod prelude { 108 | pub use crate::app_extension::*; 109 | pub use crate::events::*; 110 | pub use crate::leptos_component::*; 111 | pub use crate::queries::*; 112 | pub use crate::signal_synced::*; 113 | } 114 | -------------------------------------------------------------------------------- /src/queries.rs: -------------------------------------------------------------------------------- 1 | use crate::events::BevyEventDuplex; 2 | use crate::signal_synced::{signal_synced, RwSignalSynced}; 3 | use bevy::ecs::query::{QueryData, QueryFilter, WorldQuery}; 4 | use bevy::prelude::*; 5 | use bevy::utils::all_tuples; 6 | use paste::paste; 7 | use std::marker::PhantomData; 8 | 9 | /// `RwSignal` like synchronization for bevy queries. 10 | /// 11 | /// Creates a pair of a `RwSignalSynced` and a `BevyQueryDuplex` for a bevy query that is 12 | /// evaluated as `.get_single_mut()`. 13 | /// 14 | /// ## Example 15 | /// 16 | /// ``` 17 | /// # use bevy::prelude::*; 18 | /// # use leptos_bevy_canvas::prelude::single_query_signal; 19 | /// # 20 | /// # #[derive(Component)] 21 | /// # struct Selected; 22 | /// 23 | /// let (selected, selected_query_duplex) = single_query_signal::<(Transform,), With>(); 24 | /// ``` 25 | pub fn single_query_signal() -> (RwSignalSynced>, BevyQueryDuplex) 26 | where 27 | for<'a> D: QueryDataOwned<'a> + Clone + Send + Sync + 'static, 28 | F: QueryFilter, 29 | { 30 | let (signal, duplex) = signal_synced(None); 31 | 32 | ( 33 | signal, 34 | BevyQueryDuplex { 35 | duplex, 36 | marker: PhantomData, 37 | }, 38 | ) 39 | } 40 | 41 | pub trait QueryDataOwned<'q> { 42 | type Qdata: QueryData + WorldQuery; 43 | 44 | fn from_query_data<'a>(data: &::Item<'a>) -> Self; 45 | 46 | fn set_query_data<'a>(&self, data: &mut ::Item<'a>); 47 | 48 | fn is_changed<'a>(data: &::Item<'a>) -> bool; 49 | } 50 | 51 | macro_rules! impl_as_query_data { 52 | ($(#[$meta:meta])* $($name:ident),*) => { 53 | $(#[$meta])* 54 | impl<'q, $($name: bevy::prelude::Component + Clone),*> QueryDataOwned<'q> for ($($name,)*) { 55 | type Qdata = ($(&'q mut $name,)*); 56 | 57 | fn from_query_data<'a>(data: &::Item<'a>) -> Self { 58 | paste! { 59 | let ($([<$name:lower>],)*) = data; 60 | ($( 61 | (**[<$name:lower>]).clone(), 62 | )*) 63 | } 64 | } 65 | 66 | fn set_query_data<'a>(&self, data: &mut ::Item<'a>) { 67 | paste! { 68 | let ($([<$name:lower>],)*) = data; 69 | let ($([<$name:lower _self>],)*) = self; 70 | 71 | $( 72 | **[<$name:lower>] = (*[<$name:lower _self>]).clone(); 73 | )* 74 | } 75 | } 76 | 77 | fn is_changed<'a>(data: &::Item<'a>) -> bool { 78 | paste! { 79 | let ($([<$name:lower>],)*) = data; 80 | $( 81 | if [<$name:lower>].is_changed() { 82 | return true; 83 | } 84 | )* 85 | false 86 | } 87 | } 88 | } 89 | }; 90 | } 91 | 92 | all_tuples!(impl_as_query_data, 1, 15, T); 93 | 94 | pub struct BevyQueryDuplex 95 | where 96 | for<'a> D: QueryDataOwned<'a>, 97 | F: QueryFilter, 98 | { 99 | pub(crate) duplex: BevyEventDuplex>, 100 | marker: PhantomData, 101 | } 102 | 103 | impl Clone for BevyQueryDuplex 104 | where 105 | for<'a> D: QueryDataOwned<'a>, 106 | F: QueryFilter, 107 | { 108 | fn clone(&self) -> Self { 109 | Self { 110 | duplex: self.duplex.clone(), 111 | marker: PhantomData, 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/signal_synced.rs: -------------------------------------------------------------------------------- 1 | use crate::events::BevyEventDuplex; 2 | use crossbeam_channel::Sender; 3 | use leptos::prelude::guards::{Plain, ReadGuard}; 4 | use leptos::prelude::*; 5 | use std::ops::DerefMut; 6 | use std::panic::Location; 7 | 8 | /// This is basically identical to a Leptos `RwSignal` but is automatically synced with a Bevy 9 | /// type like a `Resource` or a `Query`. 10 | pub struct RwSignalSynced { 11 | rw_signal: RwSignal, 12 | tx: StoredValue>, 13 | } 14 | 15 | impl Clone for RwSignalSynced { 16 | fn clone(&self) -> Self { 17 | *self 18 | } 19 | } 20 | 21 | impl Copy for RwSignalSynced {} 22 | 23 | impl DefinedAt for RwSignalSynced { 24 | fn defined_at(&self) -> Option<&'static Location<'static>> { 25 | self.rw_signal.defined_at() 26 | } 27 | } 28 | 29 | impl IsDisposed for RwSignalSynced 30 | where 31 | T: 'static, 32 | { 33 | fn is_disposed(&self) -> bool { 34 | self.rw_signal.is_disposed() 35 | } 36 | } 37 | 38 | impl ReadUntracked for RwSignalSynced 39 | where 40 | T: 'static, 41 | RwSignal: ReadUntracked>>, 42 | { 43 | type Value = ReadGuard>; 44 | 45 | fn try_read_untracked(&self) -> Option { 46 | self.rw_signal.try_read_untracked() 47 | } 48 | } 49 | 50 | impl Track for RwSignalSynced 51 | where 52 | RwSignal: Track, 53 | { 54 | fn track(&self) { 55 | self.rw_signal.track(); 56 | } 57 | } 58 | 59 | impl Notify for RwSignalSynced 60 | where 61 | RwSignal: Notify, 62 | { 63 | fn notify(&self) { 64 | self.rw_signal.notify(); 65 | } 66 | } 67 | 68 | impl Write for RwSignalSynced 69 | where 70 | T: Send + Clone + 'static, 71 | RwSignal: Write + GetUntracked, 72 | { 73 | type Value = T; 74 | 75 | fn try_write(&self) -> Option> { 76 | let inner_guard = self.rw_signal.try_write()?; 77 | 78 | request_animation_frame({ 79 | let rw_signal = self.rw_signal; 80 | let tx = self.tx; 81 | 82 | move || { 83 | tx.with_value(|tx| { 84 | tx.send(rw_signal.get_untracked()) 85 | .expect("Could not send value") 86 | }); 87 | } 88 | }); 89 | 90 | Some(inner_guard) 91 | } 92 | 93 | fn try_write_untracked(&self) -> Option> { 94 | let mut guard = self.try_write()?; 95 | guard.untrack(); 96 | Some(guard) 97 | } 98 | } 99 | 100 | // TODO : make sync_resource out of this with an `Into` as input? 101 | 102 | /// Creates a pair of a `RwSignalSynced` and a `BevyEventDuplex`. 103 | /// 104 | /// The first can be used just like a `RwSignal` in Leptos. The `BevyEventDuplex` that has to 105 | /// be passed into the Bevy app where it will be used to sync the signal with a Bevy `Resource` or 106 | /// a `Query`. 107 | pub fn signal_synced(initial_value: T) -> (RwSignalSynced, BevyEventDuplex) 108 | where 109 | T: Send + Sync + Clone + 'static, 110 | { 111 | let (tx_l2b, rx_l2b) = crossbeam_channel::bounded(50); 112 | let (tx_b2l, rx_b2l) = crossbeam_channel::bounded(50); 113 | 114 | tx_l2b 115 | .send(initial_value.clone()) 116 | .expect("Could not send initial value"); 117 | 118 | let signal = RwSignal::new(initial_value); 119 | 120 | #[cfg(target_arch = "wasm32")] 121 | { 122 | leptos_use::use_raf_fn({ 123 | let rx = rx_b2l.clone(); 124 | 125 | move |_| { 126 | for event in rx.try_iter() { 127 | signal.set(event); 128 | } 129 | } 130 | }); 131 | } 132 | 133 | #[cfg(not(target_arch = "wasm32"))] 134 | { 135 | let _ = rx_b2l; 136 | } 137 | 138 | ( 139 | RwSignalSynced { 140 | rw_signal: signal, 141 | tx: StoredValue::new(tx_l2b), 142 | }, 143 | BevyEventDuplex::new(rx_l2b, tx_b2l), 144 | ) 145 | } 146 | -------------------------------------------------------------------------------- /src/systems.rs: -------------------------------------------------------------------------------- 1 | use crate::events::BevyEventDuplex; 2 | use crate::prelude::QueryDataOwned; 3 | use crate::traits::{HasReceiver, HasSender}; 4 | use bevy::ecs::event::EventId; 5 | use bevy::ecs::query::QueryFilter; 6 | use bevy::prelude::*; 7 | 8 | #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone, Copy)] 9 | pub struct SyncSignalResourceSet; 10 | 11 | #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone, Copy)] 12 | pub struct ImportLeptosEventSet; 13 | 14 | #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone, Copy)] 15 | pub struct ExportLeptosEventSet; 16 | 17 | #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone, Copy)] 18 | pub struct SyncQuerySet; 19 | 20 | /// Keeps track of what Leptos event have been imported into Bevy to prevent infinite loops. 21 | #[derive(Resource, Deref, DerefMut)] 22 | pub struct ImportedEventIds(Vec>); 23 | 24 | impl Default for ImportedEventIds { 25 | fn default() -> Self { 26 | Self(Vec::with_capacity(4)) 27 | } 28 | } 29 | 30 | /// Imports an event from Leptos and writes it as a Bevy event. 31 | pub fn import_and_send_leptos_events( 32 | rx: Res, 33 | mut imported_event_ids: ResMut>, 34 | mut event_writer: EventWriter, 35 | ) where 36 | R: HasReceiver + Resource, 37 | E: Event, 38 | { 39 | imported_event_ids.clear(); 40 | 41 | for event in rx.rx().try_iter() { 42 | let event_id = event_writer.send(event); 43 | imported_event_ids.push(event_id); 44 | } 45 | } 46 | 47 | /// Exports an event from Bevy to Leptos. 48 | pub fn read_and_export_leptos_events( 49 | tx: Res, 50 | imported_event_ids: Res>, 51 | mut event_reader: EventReader, 52 | ) where 53 | S: HasSender + Resource, 54 | E: Event + Clone, 55 | { 56 | for (event, id) in event_reader.read_with_id() { 57 | if !imported_event_ids.contains(&id) { 58 | tx.tx().send(event.clone()).unwrap(); 59 | } 60 | } 61 | } 62 | 63 | /// Takes care of synchronizing a resource between Bevy and a Leptos signal 64 | pub fn sync_signal_resource(mut resource: ResMut, sync: Res) 65 | where 66 | R: Resource + Clone, 67 | D: HasReceiver + HasSender + Resource, 68 | { 69 | if resource.is_changed() && !resource.is_added() { 70 | sync.tx().send(resource.clone()).unwrap(); 71 | } 72 | 73 | for event in sync.rx().try_iter() { 74 | *resource = event; 75 | } 76 | } 77 | 78 | /// Synchronizes a Bevy query's `.get_single_mut()` with a Leptos signal. 79 | pub fn sync_query( 80 | duplex: Res>>, 81 | mut query: Query<::Qdata, F>, 82 | mut prev_some: Local, 83 | ) where 84 | for<'a> D: QueryDataOwned<'a> + Send + Sync + 'static, 85 | F: QueryFilter, 86 | { 87 | let mut item = query.get_single_mut().ok(); 88 | 89 | let changed = if let Some(item) = &item { 90 | !*prev_some || D::is_changed(item) 91 | } else { 92 | *prev_some 93 | }; 94 | 95 | *prev_some = item.is_some(); 96 | 97 | if changed { 98 | let item = item.map(|item| D::from_query_data(&item)); 99 | duplex.tx().send(item).unwrap(); 100 | } else { 101 | for event in duplex.rx().try_iter() { 102 | if let (Some(event), Some(item)) = (event, &mut item) { 103 | event.set_query_data(item); 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/traits.rs: -------------------------------------------------------------------------------- 1 | use crossbeam_channel::{Receiver, Sender}; 2 | 3 | pub trait HasReceiver { 4 | fn rx(&self) -> &Receiver; 5 | } 6 | 7 | pub trait HasSender { 8 | fn tx(&self) -> &Sender; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use crossbeam_channel::Receiver; 2 | use leptos::prelude::*; 3 | use leptos_use::use_raf_fn; 4 | 5 | pub(crate) fn init_rw_signal_from_receiver(rx: &Receiver) -> RwSignal> 6 | where 7 | E: Send + Sync + 'static, 8 | { 9 | let signal = RwSignal::new(None); 10 | 11 | use_raf_fn({ 12 | let rx = rx.clone(); 13 | 14 | move |_| { 15 | for event in rx.try_iter() { 16 | signal.set(Some(event)); 17 | } 18 | } 19 | }); 20 | 21 | signal 22 | } 23 | --------------------------------------------------------------------------------