Repository: StaffEngineer/velo Branch: main Commit: 8489b7e99471 Files: 92 Total size: 479.8 KB Directory structure: gitextract_nb0mso7g/ ├── .cargo/ │ └── config.toml ├── .github/ │ └── workflows/ │ ├── deploy-wasm.yml │ ├── pr.yml │ └── release.yml ├── .gitignore ├── 128x128.icns ├── Cargo.toml ├── Cross.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── Readme.md ├── assets/ │ └── shaders/ │ ├── grid.wgsl │ └── shadows.wgsl ├── code_of_conduct.md ├── contributing.md ├── crates/ │ └── bevy_markdown/ │ ├── Cargo.toml │ ├── LICENSE-APACHE │ ├── LICENSE-MIT │ ├── Readme.md │ └── src/ │ ├── lib.rs │ └── snapshots/ │ ├── bevy_markdown__tests__test_render_break.snap │ ├── bevy_markdown__tests__test_render_break_after_link.snap │ ├── bevy_markdown__tests__test_render_code.snap │ ├── bevy_markdown__tests__test_render_nested_ordered_list.snap │ ├── bevy_markdown__tests__test_render_nested_unordered_list.snap │ ├── bevy_markdown__tests__test_render_ordered_list.snap │ ├── bevy_markdown__tests__test_render_text_style.snap │ ├── bevy_markdown__tests__test_render_text_style_complicated.snap │ ├── bevy_markdown__tests__test_render_text_style_header.snap │ └── bevy_markdown__tests__test_render_unordered_list.snap ├── docs/ │ └── architecture.md ├── justfile ├── security.md └── src/ ├── canvas/ │ ├── arrow/ │ │ ├── components.rs │ │ ├── events.rs │ │ ├── mod.rs │ │ ├── systems.rs │ │ └── utils.rs │ ├── grid/ │ │ ├── mod.rs │ │ └── systems.rs │ ├── mod.rs │ └── shadows/ │ ├── mod.rs │ └── systems.rs ├── components.rs ├── lib.rs ├── macros.rs ├── main.rs ├── resources.rs ├── systems.rs ├── themes.rs ├── ui_plugin/ │ ├── mod.rs │ ├── systems/ │ │ ├── active_editor_changed.rs │ │ ├── button_handlers.rs │ │ ├── canvas_click.rs │ │ ├── clickable_links.rs │ │ ├── create_new_node.rs │ │ ├── doc_list.rs │ │ ├── drawing.rs │ │ ├── effects.rs │ │ ├── entity_to_edit_changed.rs │ │ ├── init_layout/ │ │ │ ├── add_arrow.rs │ │ │ ├── add_color.rs │ │ │ ├── add_effect.rs │ │ │ ├── add_front_back.rs │ │ │ ├── add_list.rs │ │ │ ├── add_menu_button.rs │ │ │ ├── add_pencil.rs │ │ │ ├── add_search_box.rs │ │ │ ├── add_text.rs │ │ │ ├── add_text_pos.rs │ │ │ ├── add_two_points_draw.rs │ │ │ ├── add_visibility.rs │ │ │ ├── init_layout.rs │ │ │ └── node_manipulation.rs │ │ ├── interactive_sprites.rs │ │ ├── keyboard.rs │ │ ├── load.rs │ │ ├── modal.rs │ │ ├── resize_node.rs │ │ ├── resize_window.rs │ │ ├── save.rs │ │ ├── search.rs │ │ ├── set_focused_entity.rs │ │ ├── tabs.rs │ │ └── update_rectangle_position.rs │ └── ui_helpers/ │ ├── add_list_item.rs │ ├── add_tab.rs │ ├── components.rs │ ├── spawn_modal.rs │ ├── spawn_node.rs │ └── ui_helpers.rs └── utils.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cargo/config.toml ================================================ [target.wasm32-unknown-unknown] runner = "wasm-server-runner" ================================================ FILE: .github/workflows/deploy-wasm.yml ================================================ name: Build and Deploy WebAssembly to Web Branch on: push: branches: [main] jobs: build_and_deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Install Rust and wasm toolchain uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable target: wasm32-unknown-unknown - name: Cache uses: Swatinem/rust-cache@v2 - name: Install wasm-bindgen run: | mkdir wasm-bindgen curl -sSL "https://github.com/rustwasm/wasm-bindgen/releases/download/0.2.86/wasm-bindgen-0.2.86-x86_64-unknown-linux-musl.tar.gz" | tar xvz -C ./wasm-bindgen wasm-bindgen-0.2.86-x86_64-unknown-linux-musl --strip=1 echo `pwd`/wasm-bindgen >> $GITHUB_PATH - name: Install wasm-opt run: | mkdir binaryen curl -sSL https://github.com/WebAssembly/binaryen/releases/download/version_111/binaryen-version_111-x86_64-linux.tar.gz | tar xvz -C ./binaryen binaryen-version_111 --strip=1 echo `pwd`/binaryen/bin >> $GITHUB_PATH - name : Build Wasm run: RUSTFLAGS=--cfg=web_sys_unstable_apis cargo build --release --target wasm32-unknown-unknown - name: Generate JavaScript bindings run: wasm-bindgen --target web --no-typescript --out-dir out target/wasm32-unknown-unknown/release/velo.wasm - name: Optimize Wasm run : wasm-opt -Os out/velo_bg.wasm -o out/velo_bg.wasm - name: Copy WebAssembly files to web branch run: | git config --global user.email "" git config --global user.name "GitHub Actions" git fetch --all git checkout -b web origin/web -f cp -r out/* . rm -rf binaryen wasm-bindgen target out git add . git commit -m "Build and Deploy WebAssembly to Web Branch" || true git push origin web ================================================ FILE: .github/workflows/pr.yml ================================================ --- on: pull_request: null name: pr checks concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: check-default: name: Check default runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: stable target: wasm32-unknown-unknown - name: Update run: sudo apt-get update - name: Deps run: sudo apt-get install g++ pkg-config libx11-dev libasound2-dev libudev-dev - name: Cache uses: Swatinem/rust-cache@v2 - name: Check run: cargo check --all-targets check-wasm: name: Check wasm runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: stable target: wasm32-unknown-unknown - name: Cache uses: Swatinem/rust-cache@v2 - name: Check run: RUSTFLAGS=--cfg=web_sys_unstable_apis cargo check --target wasm32-unknown-unknown --all-targets lints: name: Lints runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: stable components: rustfmt, clippy - name: Update run: sudo apt-get update - name: Deps run: sudo apt-get install g++ pkg-config libx11-dev libasound2-dev libudev-dev - name: Cache uses: Swatinem/rust-cache@v2 - name: Fmt run: cargo fmt --all -- --check - name: Clippy run: cargo clippy -- -A clippy::type_complexity -A clippy::too_many_arguments -D warnings test-velo: name: Test velo runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: stable - name: Cache uses: Swatinem/rust-cache@v2 - name: Test velo run: cargo test test-markdown: name: Test markdown runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: stable - name: Cache uses: Swatinem/rust-cache@v2 - name: Test bevy-markdown run: cargo test -p bevy_markdown --lib coverage: runs-on: ubuntu-latest env: CARGO_TERM_COLOR: always steps: - name: Checkout uses: actions/checkout@v3 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: stable - name: Cache uses: Swatinem/rust-cache@v2 - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - name: Generate code coverage run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos files: lcov.info fail_ci_if_error: true ================================================ FILE: .github/workflows/release.yml ================================================ name: release on: workflow_dispatch: inputs: version: description: "Version" required: true default: "TEST-0.0.0" type: string push: tags: - "[0-9]+.[0-9]+.[0-9]+" jobs: create-release: name: create-release runs-on: ubuntu-latest env: # Set to force version number, e.g., when no tag exists. VELO_VERSION: ${{ inputs.version }} outputs: upload_url: ${{ steps.release.outputs.upload_url }} velo_version: ${{ env.VELO_VERSION }} steps: - name: Get the release version from the tag shell: bash if: env.VELO_VERSION == '' run: | # Apparently, this is the right way to get a tag name. Really? # # See: https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 echo "VELO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV echo "version is: ${{ env.VELO_VERSION }}" - name: Create GitHub release id: release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ env.VELO_VERSION }} release_name: ${{ env.VELO_VERSION }} draft: true build-release: name: build-release needs: ['create-release'] runs-on: ${{ matrix.os }} continue-on-error: true env: # For some builds, we use cross to test on 32-bit and big-endian # systems. CARGO: cargo # When CARGO is set to CROSS, this is set to `--target matrix.target`. TARGET_FLAGS: "" # When CARGO is set to CROSS, TARGET_DIR includes matrix.target. TARGET_DIR: ./target # Emit backtraces on panics. RUST_BACKTRACE: 1 strategy: matrix: include: - build: linux os: ubuntu-20.04 rust: stable target: x86_64-unknown-linux-gnu - build: linux-arm os: ubuntu-20.04 rust: stable target: arm-unknown-linux-gnueabihf - build: macos os: macos-latest rust: stable target: x86_64-apple-darwin - build: win-msvc os: windows-2019 rust: stable target: x86_64-pc-windows-msvc - build: win-gnu os: windows-2019 rust: stable-x86_64-gnu target: x86_64-pc-windows-gnu - build: win32-msvc os: windows-2019 rust: stable target: i686-pc-windows-msvc steps: - name: Checkout repository uses: actions/checkout@v2 with: fetch-depth: 1 - name: Remove .cargo uses: JesseTG/rm@v1.0.0 with: path: .cargo - name: Cache cargo folder uses: actions/cache@v3 with: key: ${{ matrix.build }} path: | ~/.cargo ~/.rustup - name: Install Rust uses: actions-rs/toolchain@v1 with: toolchain: ${{ matrix.rust }} profile: minimal override: true target: ${{ matrix.target }} - name: Use Cross shell: bash run: | cargo install cross --vers 0.2.1 echo "CARGO=cross" >> $GITHUB_ENV echo "TARGET_FLAGS=--target ${{ matrix.target }}" >> $GITHUB_ENV echo "TARGET_DIR=./target/${{ matrix.target }}" >> $GITHUB_ENV - name: Show command used for Cargo run: | echo "cargo command is: ${{ env.CARGO }}" echo "target flag is: ${{ env.TARGET_FLAGS }}" echo "target dir is: ${{ env.TARGET_DIR }}" - name: Build release binary env: BEVY_ASSET_PATH: /project/assets run: ${{ env.CARGO }} build --verbose --release ${{ env.TARGET_FLAGS }} - name: Strip release binary (linux and macos) if: matrix.build == 'linux' || matrix.build == 'macos' run: strip "target/${{ matrix.target }}/release/velo" - name: Strip release binary (arm) if: matrix.build == 'linux-arm' run: | docker run --rm -v \ "$PWD/target:/target:Z" \ rustembedded/cross:arm-unknown-linux-gnueabihf \ arm-linux-gnueabihf-strip \ /target/arm-unknown-linux-gnueabihf/release/velo - name: Build archive shell: bash run: | staging="velo-${{ needs.create-release.outputs.velo_version }}-${{ matrix.target }}" mkdir -p "$staging" cp {Readme.md,LICENSE-APACHE,LICENSE-MIT} "$staging/" if [ "${{ matrix.os }}" = "windows-2019" ]; then cp "target/${{ matrix.target }}/release/velo.exe" "$staging/" 7z a "$staging.zip" "$staging" echo "ASSET=$staging.zip" >> $GITHUB_ENV else cp "target/${{ matrix.target }}/release/velo" "$staging/" tar czf "$staging.tar.gz" "$staging" echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV fi - name: Upload release archive uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: ${{ env.ASSET }} asset_name: ${{ env.ASSET }} asset_content_type: application/octet-stream ================================================ FILE: .gitignore ================================================ /target .idea .vscode velo.json ichart.json data out ================================================ FILE: Cargo.toml ================================================ [package] name = "velo" license = "MIT OR Apache-2.0" description = "App for brainstorming & sharing ideas 🦀 Learning Project" repository = "https://github.com/StaffEngineer/velo.git" readme = "Readme.md" version = "0.9.3" edition = "2021" exclude = ["assets/fonts/*", "velo.gif", "velo.png"] # Enable max optimizations for dependencies, but not for our code: [profile.dev.package."*"] opt-level = 3 [profile.release] opt-level = 'z' [workspace] members = [ "crates/bevy_markdown", ] [dependencies] bevy = { version = "0.11", default-features = false, features = [ "bevy_asset", "bevy_core_pipeline", "bevy_render", "bevy_scene", "bevy_sprite", "bevy_text", "bevy_ui", "bevy_winit", "png", "x11", ] } base64 = "0.21.0" serde_json = "1.0.94" uuid = { version = "1.3.0", default-features = false, features = ["v4", "js"] } serde = { version = "1.0", features = ["derive"] } linkify = "0.9.0" ehttp = "0.1.0" async-channel = "1.8" image = { version = "0.24.5", default-features = false, features = ["ico"] } bevy_markdown = { path = "crates/bevy_markdown" } bevy_cosmic_edit = { version = "0.9.2" } bevy_embedded_assets = { version = "0.8" } cosmic-text = { version = "0.9" } bevy_prototype_lyon = { version = "0.9" } bevy_pancam = { version = "0.9" } bevy_pkv = { version = "0.8.0", default-features = true } rand = "0.8.5" getrandom = { version = "0.2.10", features = ["js"] } [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1.7" web-sys = { version = "0.3.61", default-features = false, features = ["Window", "Location"] } wasm-bindgen = "0.2.86" js-sys = "0.3.61" url = "2.3.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] bevy_hanabi = { version = "0.7" } arboard = "3.2.0" open = "4.0.1" toml = "0.7.3" tantivy = "0.19.2" directories = "5.0" env_logger = "0.10.0" [dev-dependencies] tempfile = "3.5.0" [package.metadata.bundle] name = "velo" identifier = "com.rust.velo" icon = ["128x128.icns"] # [patch.crates-io] # bevy = { path = "../bevy" } # bevy_ecs = { path = "../bevy/crates/bevy_ecs" } # [patch."https://github.com/bevyengine/bevy"] # bevy = { path = "../bevy" } ================================================ FILE: Cross.toml ================================================ [build.env] passthrough = [ "BEVY_ASSET_PATH", ] ================================================ FILE: LICENSE-APACHE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ================================================ FILE: LICENSE-MIT ================================================ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Readme.md ================================================ # Velo 🚵‍♀️🚵 ![Rust](https://img.shields.io/badge/Rust-lang-000000.svg?style=flat&logo=rust)[![codecov](https://codecov.io/gh/StaffEngineer/velo/branch/main/graph/badge.svg?token=QGEKLM6ZDF)](https://codecov.io/gh/StaffEngineer/velo) ![velo](./velo.gif) ![alt text](velo.png "Velo") ## Demo This app is primarily designed for native desktop platforms, and its WebAssembly (wasm) target has a limited feature set. wasm target is best suited for quick document sharing, currently, only WebGPU backend enabled for the demo: [](https://staffengineer.github.io/velo?document=https://gist.githubusercontent.com/StaffEngineer/bf7d94759abbd7aa722330e5fe4f0bd5/raw/e817be0ba700e94be472d435638d762b9deadf33/velo.json) ## What\'s implemented: - support rectangle/circle/paperlike notes - add/remove note - note resizing - note repositioning - wrapped text inside notes - paste screenshot from clipboard [native target only 🖥️] - connect notes with arrows - make app snapshot in memory and load from it (MacOs: Command + s\[l\]) - save app state to database and load from it - change background color of notes - move note to front/back - positioning text inside note - multiple documents/tabs support - load app state from url - ability to create sharable url of the document using \"Share Document\" button (**.velo.toml** should be created in user's home directory containing GitHub access token with \"gist\" scope) [native target only 🖥️]: ```toml github_access_token = "" ``` - initial markdown support - italic/bold text style - links - syntax highlighting - headings (proper headings support was temporarily removed) - inline code - ordered/unordered lists - particles effect [native target only 🖥️] - filter documents by text in notes (fuzzy search) [native target only 🖥️] - highlight notes containing searched text [native target only 🖥️] - ligature/emoji rendering support [emoji native target only 🖥️] - dark/light theme support (app restart is required for now) - infinite canvas with zooming (right click to move camera, mouse wheel to zoom) - undo/redo for text editing [native target only 🖥️] - drawing mode (click on pencil icon to enable it) - draw line, arrow, rhombus or rectangle by choosing 2 points - hide/show children notes for selected note - navigation to random note ## Installation [Archives of precompiled binaries for *velo* are available for Windows, macOS and Linux.](https://github.com/StaffEngineer/velo/releases/latest) ### Compiling from Source If you want to compile from source you can use ```sh cargo install --path . ``` **ATTENTION** If you have set your cargo target directory in `.cargo/config.toml` you must provide the fullpath to the assets directory like this ```sh BEVY_ASSET_PATH=$(realpath assets) cargo install --path . ``` ## Run Native: ```sh cargo r --release ``` Wasm: ```sh cargo install wasm-server-runner RUSTFLAGS=--cfg=web_sys_unstable_apis cargo r --release --target wasm32-unknown-unknown ``` To create app bundle with icon (tested only on MacOS): ```sh cargo install cargo-bundle cargo bundle ``` ## Pre-commit actions ```sh cargo fmt cargo clippy -- -A clippy::type_complexity -A clippy::too_many_arguments ``` ## Basic usage - click on rectangle icon to create rectangle note - double-click to select note - start typing to add text to selected note - resize note by dragging its corners - click on canvas to deselect note - move note by dragging it (only unselected note can be dragged to allow mouse text selection for selected note) - click on little arrow connector icon to connect notes, arrow connector icons are placed on each side of note - for native target there is search box that allows to filter documents by text in notes (fuzzy search) - for wasm target there is url query parameter `?document=` to load document from url - click save icon to save document to database on native platform or to localStorage on wasm target - click on drawing pencil to enable drawing mode ## Troubleshooting If the application fails to start, you can try resolving the issue by removing velo data folder. This problem may occur due to changes in the data schema between different versions of the application. - MacOS: `/Users//Library/Application Support/velo` - Windows: `C:\Users\\AppData\Roaming\velo` - Linux: `/home//.config/velo` - wasm: `localStorage.clear()` ## License All code in this repository dual-licensed under either: MIT License or http://opensource.org/licenses/MIT Apache License, Version 2.0 or http://www.apache.org/licenses/LICENSE-2.0 Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. ## Contributing Contributions are always welcome! Please adhere to this project\'s code of conduct. ❤️ ================================================ FILE: assets/shaders/grid.wgsl ================================================ #import bevy_sprite::mesh2d_vertex_output MeshVertexOutput struct CustomGridMaterial { line_color: vec4, grid_size: vec2, cell_size: vec2, major: f32, }; @group(1) @binding(0) var material: CustomGridMaterial; fn grid(point: vec2, cell_size: vec2, thickness: f32) -> f32 { let x = (abs(fract(point.x / cell_size.x)) - thickness) * cell_size.x; let y = (abs(fract(point.y / cell_size.y)) - thickness) * cell_size.y; return min(x, y); } fn origin(point: vec2, thickness: f32) -> f32 { return min(abs(point.x), abs(point.y)) - thickness; } @fragment fn fragment( mesh: MeshVertexOutput, ) -> @location(0) vec4 { let line_color: vec4 = material.line_color; let grid_size: vec2 = material.grid_size; let cell_size: vec2 = material.cell_size; let major: f32 = material.major; let point = (mesh.uv - vec2(0.5)) * grid_size; let t = grid(point, cell_size, 0.05); let u = grid(point, cell_size * major, 0.2 / major); let g = min(t, u); let alpha = 1.0 - smoothstep(0.0, fwidth(g), g); return vec4(line_color.rgb, alpha * line_color.a); } ================================================ FILE: assets/shaders/shadows.wgsl ================================================ //! https://www.w3.org/TR/WGSL/#builtin-functions // #import bevy_sprite::mesh2d_vertex_output MeshVertexOutput // taken from https://github.com/bevyengine/bevy/blob/264195ed772e1fc964c34b6f1e64a476805e1766/crates/bevy_sprite/src/mesh2d/mesh2d_vertex_output.wgsl struct MeshVertexOutput { // this is `clip position` when the struct is used as a vertex stage output // and `frag coord` when used as a fragment stage input @builtin(position) position: vec4, @location(0) world_position: vec4, @location(1) world_normal: vec3, @location(2) uv: vec2, // #ifdef VERTEX_TANGENTS // @location(3) world_tangent: vec4, // #endif // #ifdef VERTEX_COLORS // @location(4) color: vec4, // #endif } struct CustomShadowMaterial { color: vec4, flat_size: vec2, edge_size: vec2, }; @group(1) @binding(0) var material: CustomShadowMaterial; @fragment fn fragment( mesh: MeshVertexOutput, ) -> @location(0) vec4 { let color = material.color; let flat_size = material.flat_size; let edge_size = material.edge_size; let full_size = flat_size + edge_size; // the size matters because we want the shadow blurred edge to be in physical pixels // but uv is relative to the mesh size // we ensure, outside, in Bevy-land, that // the mesh size is the "shadow-casting object"'s size plus the blurred edge // let's imagine that we want 100px of blurred edge, for an 800px object, so that's 1000px // if mesh size = 1000px, then that's 10% // or more generally, the blurred edge size in 0..1 scale is the blurred edge size in pixels / total mesh size in pixels // here, edge_x/edge_y is the blurred edge size in the 0..1 scale let edge = edge_size / full_size; let ax = smoothstep(0.0, 1.0, symmetric_top(mesh.uv.x) / edge.x / 2.0); let ay = smoothstep(0.0, 1.0, symmetric_top(mesh.uv.y) / edge.y / 2.0); // `min(ax, ay)` gives sharp corners // `ax * ay` gives rounded corners // blending the two seems to give the nicest-looking results var a = mix(ax * ay, min(ax, ay), 0.3); return vec4(color.rgb, a * color.a); } // `v` is a value in the range between 0..0.5..1, and we want it to be a value between 1..0..1 // e.g. 0.2 becomes 0.6, 0.5 becomes 0, 0.6 becomes 0.2 fn symmetric_bottom(v: f32) -> f32 { return 2.0 * abs(v - 0.5); } // `v` is a value in the range between 0..0.5..1, and we want it to be a value between 0..1..0 // e.g. 0.2 becomes 0.4, 0.5 becomes 1, 0.6 becomes 0.8 fn symmetric_top(v: f32) -> f32 { return invert(symmetric_bottom(v)); } // converts a value `v` from 0..1 to 1..0 // e.g. 0.3 becomes 0.7 fn invert(v: f32) -> f32 { return 1.0 - v; } ================================================ FILE: code_of_conduct.md ================================================ # Contributor Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: contributing.md ================================================ # Contributing to the Velo We welcome contributions from the community to help improve this project. Whether you're interested in fixing bugs, adding new features, or improving documentation, there are many ways to get involved. ## How to Contribute Here are some steps to get started with contributing to this project: * Fork the repository and clone it to your local machine * Create a new branch for your changes * Make your changes and test them thoroughly * Commit your changes with a descriptive commit message * Push your changes to your fork and submit a pull request We appreciate contributions of any size, from small bug fixes to major new features. If you're unsure about a change you'd like to make, feel free to open an issue first to discuss it with the maintainers. ================================================ FILE: crates/bevy_markdown/Cargo.toml ================================================ [package] name = "bevy_markdown" version = "0.2.0" license = "MIT OR Apache-2.0" description = "Bevy markdown renderer" repository = "https://github.com/StaffEngineer/velo/tree/main/crates/bevy_markdown" edition = "2021" keywords = ["bevy"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] markdown = "1.0.0-alpha.9" syntect = { version = "5.0.0", default-features = false, features = ["default-fancy"] } cosmic-text = { version = "0.9" } [dev-dependencies] insta = "1.29.0" ================================================ FILE: crates/bevy_markdown/LICENSE-APACHE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ================================================ FILE: crates/bevy_markdown/LICENSE-MIT ================================================ MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. MIT License Copyright (c) 2017 Tristan Hume, Keith Hall, Google Inc and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: crates/bevy_markdown/Readme.md ================================================ # bevy_markdown Built for `bevy_cosmic_edit` plugin. Features: - [x] bold text style - [x] italic text style - [x] links - [ ] inline images - [ ] headings (proper headings support was temporarily removed) - [x] inline code - [x] code block with syntax highlighting - [x] ordered/unordered lists - [ ] quotes - [ ] tables - [ ] strikethrough text - [ ] checkboxes ⚠️ *Warning: This plugin is currently in early development, and its API is subject to change.* ================================================ FILE: crates/bevy_markdown/src/lib.rs ================================================ use std::vec; use cosmic_text::{AttrsOwned, Color, Weight}; use syntect::easy::HighlightLines; use syntect::highlighting::{FontStyle, ThemeSet}; use syntect::parsing::SyntaxSet; use syntect::util::LinesWithEndings; pub struct BevyMarkdownTheme { pub code_theme: String, pub code_default_lang: String, pub link: cosmic_text::Color, pub inline_code: cosmic_text::Color, } #[derive(Clone, Debug, Default)] pub struct TextSpanMetadata { pub link: Option, } #[inline] pub fn default() -> T { std::default::Default::default() } #[derive(Default)] pub struct TextSpan { pub text: String, pub font_size: Option, pub weigth: Option, pub style: Option, pub color: Option, pub metadata: Option, } pub struct BevyMarkdown { pub markdown_theme: BevyMarkdownTheme, pub text: String, pub attrs: AttrsOwned, } #[repr(u8)] #[derive(Clone)] enum InlineStyleType { Strong = 0x01, Emphasis = 0x02, StrongEmphasis = 0x03, // StrikeThrough = 0x04, // StrikeBold = 0x05, // StrikeItalic = 0x06, // StrikeBoldItalic = 0x07, None = 0x00, } pub fn get_header_font_size(val: u8) -> f32 { match val { 1 => 30.0, 2 => 27.0, 3 => 24.0, 4 => 21.0, 5 => 18.0, 6 => 15.0, _ => 15.0, } } impl InlineStyleType { #[inline] pub fn from_u8(style_code: u8) -> Self { match style_code { 0x01 => InlineStyleType::Strong, 0x02 => InlineStyleType::Emphasis, 0x03 => InlineStyleType::StrongEmphasis, // 0x04 => InlineStyleType::StrikeThrough, // 0x05 => InlineStyleType::StrikeBold, // 0x06 => InlineStyleType::StrikeItalic, // 0x07 => InlineStyleType::StrikeBoldItalic, _ => InlineStyleType::None, } } } #[derive(Debug)] pub enum BevyMarkdownError { Transform { info: String }, Parsing { info: String }, } pub fn get_bullet_for_indentation_level(level: u8) -> &'static str { let level = level % 3; if level == 0 { " • " } else if level == 1 { " ◦ " } else { " ▪ " } } pub fn handle_block_styling( node: &markdown::mdast::Node, bevy_markdown: &BevyMarkdown, text_spans: &mut Vec, errors: &mut Vec, ) -> Result<(), Vec> { match node { markdown::mdast::Node::Heading(header) => { text_spans.push(TextSpan { text: "\n".to_string(), ..default() }); header.children.iter().for_each(|child| { let _ = handle_inline_styling( child, bevy_markdown, text_spans, errors, InlineStyleType::Strong as u8, None, Some(get_header_font_size(header.depth)), &None, ); }); text_spans.push(TextSpan { text: "\n".to_string(), ..default() }); } markdown::mdast::Node::Paragraph(paragraph) => { paragraph.children.iter().for_each(|child| match child { markdown::mdast::Node::Break(_) => { text_spans.push(TextSpan { text: "\n".to_string(), ..default() }); } markdown::mdast::Node::Text(text) => { text_spans.push(TextSpan { text: text.value.to_string(), ..default() }); } markdown::mdast::Node::Strong(_) | markdown::mdast::Node::Emphasis(_) | markdown::mdast::Node::InlineCode(_) | markdown::mdast::Node::Delete(_) | markdown::mdast::Node::Link(_) => { let _ = handle_inline_styling( child, bevy_markdown, text_spans, errors, InlineStyleType::None as u8, None, None, &None, ); } node => errors.push(BevyMarkdownError::Transform { info: format!("{:?} node is not implemented for paragraph", node), }), }); } _ => errors.push(BevyMarkdownError::Transform { info: "nesting is not implemented".to_string(), }), } Ok(()) } pub fn handle_inline_styling( node: &markdown::mdast::Node, bevy_markdown: &BevyMarkdown, text_spans: &mut Vec, errors: &mut Vec, applied_style: u8, force_color: Option, force_size: Option, force_data: &Option, ) -> Result<(), Vec> { match node { markdown::mdast::Node::InlineCode(code) => { let mut text_span = TextSpan { text: code.value.clone(), color: Some(bevy_markdown.markdown_theme.inline_code), font_size: force_size, ..default() }; if let Some(link) = force_data { text_span.metadata = Some(TextSpanMetadata { link: Some(link.clone()), }) } text_spans.push(text_span); } markdown::mdast::Node::Emphasis(emphasis) => emphasis.children.iter().for_each(|child| { let _ = handle_inline_styling( child, bevy_markdown, text_spans, errors, applied_style | InlineStyleType::Emphasis as u8, force_color, force_size, force_data, ); }), markdown::mdast::Node::Strong(strong) => strong.children.iter().for_each(|child| { let _ = handle_inline_styling( child, bevy_markdown, text_spans, errors, applied_style | InlineStyleType::Strong as u8, force_color, force_size, force_data, ); }), markdown::mdast::Node::Text(text) => { let mut text_span = TextSpan { text: text.value.clone(), font_size: force_size, ..default() }; if let Some(color) = force_color { text_span.color = Some(color) } if let Some(link) = force_data { text_span.metadata = Some(TextSpanMetadata { link: Some(link.clone()), }) } match InlineStyleType::from_u8(applied_style) { InlineStyleType::Strong => { text_span.weigth = Some(Weight::BOLD); } InlineStyleType::Emphasis => { text_span.style = Some(cosmic_text::Style::Italic); } InlineStyleType::StrongEmphasis => { text_span.weigth = Some(Weight::BOLD); text_span.style = Some(cosmic_text::Style::Italic); } _ => {} } text_spans.push(text_span); } markdown::mdast::Node::Link(link) => link.children.iter().for_each(|child| { let _ = handle_inline_styling( child, bevy_markdown, text_spans, errors, applied_style, Some(bevy_markdown.markdown_theme.link), force_size, &Some(link.url.clone()), ); }), _ => { errors.push(BevyMarkdownError::Transform { info: "nesting is not implemented".to_string(), }); } } Ok(()) } fn handle_list_recursive( list: &markdown::mdast::List, bevy_markdown: &BevyMarkdown, text_spans: &mut Vec, errors: &mut Vec, indentation_level: u8, ) -> Result<(), Vec> { text_spans.push(TextSpan { text: "\n".to_string(), ..default() }); let mut list_index = list.start; list.children .clone() .into_iter() .for_each(|node| match node { markdown::mdast::Node::ListItem(item) => { for _ in 0..indentation_level { text_spans.push(TextSpan { text: " ".to_string(), ..default() }); } let indent_char = if list.ordered { let index = list_index.unwrap(); list_index = Some(index + 1); format!(" {}. ", index) } else { get_bullet_for_indentation_level(indentation_level).to_string() }; text_spans.push(TextSpan { text: indent_char, ..default() }); item.children.into_iter().for_each(|child| match child { markdown::mdast::Node::Paragraph(paragraph) => { paragraph.children.iter().for_each(|child| { let _ = handle_inline_styling( child, bevy_markdown, text_spans, errors, InlineStyleType::None as u8, None, None, &None, ); }) } markdown::mdast::Node::List(inner_list) => { let _ = handle_list_recursive( &inner_list, bevy_markdown, text_spans, errors, indentation_level + 1, ); } node => errors.push(BevyMarkdownError::Transform { info: format!("{:?} node is not implemented for list item", node), }), }); text_spans.push(TextSpan { text: "\n".to_string(), ..default() }); } _ => { errors.push(BevyMarkdownError::Transform { info: "invalid list children".to_string(), }); } }); Ok(()) } #[derive(Debug)] pub struct BevyMarkdownLines { pub lines: Vec>, pub span_metadata: Vec, } pub fn generate_markdown_lines( bevy_markdown: BevyMarkdown, ) -> Result> { let node = markdown::to_mdast(bevy_markdown.text.as_str(), &markdown::ParseOptions::gfm()); let ps = SyntaxSet::load_defaults_newlines(); let ts = ThemeSet::load_defaults(); match node { Ok(node) => { let mut text_spans = Vec::new(); let mut errors = Vec::new(); match node { markdown::mdast::Node::Root(root) => { root.children.iter().for_each(|child| match child { markdown::mdast::Node::Code(code) => { let default_lang = bevy_markdown.markdown_theme.code_default_lang.clone(); let lang = code.lang.as_ref().unwrap_or(&default_lang); let syntax = [ ps.find_syntax_by_name(lang.as_str()), ps.find_syntax_by_extension(lang.as_str()), ] .iter() .find(|&o| o.is_some()) .unwrap() .unwrap(); let mut h = HighlightLines::new( syntax, &ts.themes[&bevy_markdown.markdown_theme.code_theme.clone()], ); text_spans.push(TextSpan { text: "\n\n".to_string(), ..default() }); for line in LinesWithEndings::from(code.value.as_str()) { let ranges: Vec<(syntect::highlighting::Style, &str)> = h.highlight_line(line, &ps).unwrap(); for &(style, text) in ranges.iter() { let mut text_span = TextSpan { text: text.to_string(), ..default() }; match style.font_style { FontStyle::BOLD => text_span.weigth = Some(Weight::BOLD), FontStyle::ITALIC => { text_span.style = Some(cosmic_text::Style::Italic) } FontStyle::UNDERLINE => { text_span.weigth = Some(Weight::BOLD); text_span.style = Some(cosmic_text::Style::Italic); } _ => text_span.weigth = Some(Weight::SEMIBOLD), }; let color = style.foreground; text_span.color = Some(cosmic_text::Color::rgb(color.r, color.g, color.b)); text_spans.push(text_span); } } text_spans.push(TextSpan { text: "\n".to_string(), ..default() }); } markdown::mdast::Node::Heading(_) | markdown::mdast::Node::Paragraph(_) => { let _ = handle_block_styling( child, &bevy_markdown, &mut text_spans, &mut errors, ); } markdown::mdast::Node::List(list) => { let _ = handle_list_recursive( list, &bevy_markdown, &mut text_spans, &mut errors, 0, ); } node => errors.push(BevyMarkdownError::Transform { info: format!("{:?} node is not implemented for root", node), }), }); } node => errors.push(BevyMarkdownError::Transform { info: format!("unexpected node: {:?}", node), }), } if !errors.is_empty() { Err(errors) } else { let mut spans_meta = vec![]; let mut lines: Vec> = vec![vec![]]; for (i, span) in text_spans.iter().enumerate() { let mut attrs = bevy_markdown.attrs.as_attrs(); // if cosmic-text implements attrs.size add it here if let Some(color) = span.color { attrs = attrs.color(color) } if let Some(style) = span.style { attrs = attrs.style(style) } if let Some(weight) = span.weigth { attrs = attrs.weight(weight) } attrs = attrs.metadata(i); if let Some(metadata) = span.metadata.clone() { spans_meta.push(metadata); } else { spans_meta.push(TextSpanMetadata { link: None }); }; let mut temp = String::new(); for ch in span.text.chars() { if ch == '\n' { if !temp.is_empty() { lines .last_mut() .unwrap() .push((temp.clone(), AttrsOwned::new(attrs))); temp.clear(); } lines.push(Vec::new()); } else { temp.push(ch); } } if !temp.is_empty() { lines .last_mut() .unwrap() .push((temp, AttrsOwned::new(attrs))); } } Ok(BevyMarkdownLines { lines, span_metadata: spans_meta, }) } } Err(e) => Err(vec![BevyMarkdownError::Parsing { info: e.to_string(), }]), } } #[cfg(test)] mod tests { use cosmic_text::Attrs; use crate::*; fn test_bevymarkdown(input: String, test_name: String) { let markdown_theme = BevyMarkdownTheme { code_theme: "Solarized (light)".to_string(), code_default_lang: "rs".to_string(), link: Color::rgb(10, 10, 10), inline_code: Color::rgb(100, 100, 100), }; insta::assert_debug_snapshot!( test_name.clone(), generate_markdown_lines(BevyMarkdown { markdown_theme, text: input.clone(), attrs: AttrsOwned::new(Attrs::new()), }) ); } #[test] pub fn test_render_text_complicated() { let input = "**bold1** normal text **Italic* and then italic again* [Inner links **can be styled*too***](https://google.com) "; test_bevymarkdown( input.to_string(), "test_render_text_style_complicated".to_string(), ); } #[test] pub fn test_render_text_with_header() { let input = "# Header 1 ## Header 2 ### Header 3 #### Some header 4 *as well* italicised ##### another header [*redirecting to google*](https://google.com) "; test_bevymarkdown( input.to_string(), "test_render_text_style_header".to_string(), ); } #[test] pub fn test_render_text_style() { let input = "**bold1** __bold2__ *italic1* _italic2_ Hello world [link](https://example.com) "; test_bevymarkdown(input.to_string(), "test_render_text_style".to_string()); } #[test] pub fn test_render_code() { let input = "My rust code: ```rs fn main() { println!(\"Hello world\"); } ``` " .to_string(); test_bevymarkdown(input.to_string(), "test_render_code".to_string()); } #[test] pub fn test_render_break() { let input = "Hello world hello world " .to_string(); test_bevymarkdown(input.to_string(), "test_render_break".to_string()); } #[test] pub fn test_render_break_after_link() { let input = "(link)[https://example.com] hello world " .to_string(); test_bevymarkdown( input.to_string(), "test_render_break_after_link".to_string(), ); } #[test] pub fn test_render_unordered_list() { let input = " - Import a HTML file and watch it magically convert to Markdown - Drag and drop images (requires your Dropbox account be linked) - Import and save files from GitHub, Dropbox, Google Drive and One Drive - Drag and drop markdown and HTML files into Dillinger - Export documents as Markdown, HTML and PDF " .to_string(); test_bevymarkdown(input, "test_render_unordered_list".to_string()) } #[test] pub fn test_render_ordered_list() { let input = " 1. Import a HTML file and watch it magically convert to Markdown 2. Drag and drop images (requires your Dropbox account be linked) 3. Import and save files from GitHub, Dropbox, Google Drive and One Drive " .to_string(); test_bevymarkdown(input, "test_render_ordered_list".to_string()) } #[test] pub fn test_render_nested_unordered_list() { let input = " - Import a HTML file and watch it magically convert to Markdown - Drag and drop images (requires your Dropbox account be linked) - Import and save files from GitHub, Dropbox, Google Drive and One Drive - Drag and drop markdown and HTML files into Dillinger - Export documents as Markdown, HTML and PDF " .to_string(); test_bevymarkdown(input, "test_render_nested_unordered_list".to_string()) } #[test] pub fn test_render_nested_ordered_list() { let input = " 1. Import a HTML file and watch it magically convert to Markdown 2. Drag and drop images (requires your Dropbox account be linked) 1. Import and save files from GitHub, Dropbox, Google Drive and One Drive 2. Drag and drop images (requires your Dropbox account be linked) 3. Drag and drop images (requires your Dropbox account be linked) " .to_string(); test_bevymarkdown(input, "test_render_nested_ordered_list".to_string()) } } ================================================ FILE: crates/bevy_markdown/src/snapshots/bevy_markdown__tests__test_render_break.snap ================================================ --- source: crates/bevy_markdown/src/lib.rs expression: "generate_markdown_lines(BevyMarkdown {\n markdown_theme,\n text: input.clone(),\n attrs: AttrsOwned::new(Attrs::new()),\n })" --- Ok( BevyMarkdownLines { lines: [ [ ( "Hello world", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 0, }, ), ], [ ( "hello world", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 2, }, ), ], ], span_metadata: [ TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, ], }, ) ================================================ FILE: crates/bevy_markdown/src/snapshots/bevy_markdown__tests__test_render_break_after_link.snap ================================================ --- source: crates/bevy_markdown/src/lib.rs expression: "generate_markdown_lines(BevyMarkdown {\n markdown_theme,\n text: input.clone(),\n attrs: AttrsOwned::new(Attrs::new()),\n })" --- Ok( BevyMarkdownLines { lines: [ [ ( "(link)[", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 0, }, ), ( "https://example.com", AttrsOwned { color_opt: Some( Color( 4278848010, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 1, }, ), ( "]", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 2, }, ), ( "hello world", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 3, }, ), ], ], span_metadata: [ TextSpanMetadata { link: None, }, TextSpanMetadata { link: Some( "https://example.com", ), }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, ], }, ) ================================================ FILE: crates/bevy_markdown/src/snapshots/bevy_markdown__tests__test_render_code.snap ================================================ --- source: crates/bevy_markdown/src/lib.rs expression: "generate_markdown_lines(BevyMarkdown {\n markdown_theme,\n text: input.clone(),\n attrs: AttrsOwned::new(Attrs::new()),\n })" --- Ok( BevyMarkdownLines { lines: [ [ ( "My rust code:", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 0, }, ), ], [], [ ( "fn", AttrsOwned { color_opt: Some( Color( 4280716242, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 2, }, ), ( " ", AttrsOwned { color_opt: Some( Color( 4284840835, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 3, }, ), ( "main", AttrsOwned { color_opt: Some( Color( 4290087168, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 4, }, ), ( "(", AttrsOwned { color_opt: Some( Color( 4284840835, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 5, }, ), ( ")", AttrsOwned { color_opt: Some( Color( 4284840835, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 6, }, ), ( " ", AttrsOwned { color_opt: Some( Color( 4284840835, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 7, }, ), ( "{", AttrsOwned { color_opt: Some( Color( 4284840835, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 8, }, ), ], [ ( " ", AttrsOwned { color_opt: Some( Color( 4284840835, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 10, }, ), ( "println!", AttrsOwned { color_opt: Some( Color( 4286945536, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 11, }, ), ( "(", AttrsOwned { color_opt: Some( Color( 4284840835, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 12, }, ), ( "\"", AttrsOwned { color_opt: Some( Color( 4286813334, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 13, }, ), ( "Hello world", AttrsOwned { color_opt: Some( Color( 4280983960, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 14, }, ), ( "\"", AttrsOwned { color_opt: Some( Color( 4286813334, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 15, }, ), ( ")", AttrsOwned { color_opt: Some( Color( 4284840835, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 16, }, ), ( ";", AttrsOwned { color_opt: Some( Color( 4284840835, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 17, }, ), ], [ ( "}", AttrsOwned { color_opt: Some( Color( 4284840835, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 600, ), metadata: 19, }, ), ], [], ], span_metadata: [ TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, ], }, ) ================================================ FILE: crates/bevy_markdown/src/snapshots/bevy_markdown__tests__test_render_nested_ordered_list.snap ================================================ --- source: crates/bevy_markdown/src/lib.rs expression: "generate_markdown_lines(BevyMarkdown {\n markdown_theme,\n text: input.clone(),\n attrs: AttrsOwned::new(Attrs::new()),\n })" --- Ok( BevyMarkdownLines { lines: [ [], [ ( " 1. ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 1, }, ), ( "Import a HTML file and watch it magically convert to Markdown", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 2, }, ), ], [ ( " 2. ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 4, }, ), ( "Drag and drop images (requires your Dropbox account be linked)", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 5, }, ), ], [ ( " ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 7, }, ), ( " 1. ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 8, }, ), ( "Import and save files from GitHub, Dropbox, Google Drive and One Drive", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 9, }, ), ], [ ( " ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 11, }, ), ( " 2. ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 12, }, ), ( "Drag and drop images (requires your Dropbox account be linked)", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 13, }, ), ], [], [ ( " 3. ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 16, }, ), ( "Drag and drop images (requires your Dropbox account be linked)", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 17, }, ), ], [], ], span_metadata: [ TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, ], }, ) ================================================ FILE: crates/bevy_markdown/src/snapshots/bevy_markdown__tests__test_render_nested_unordered_list.snap ================================================ --- source: crates/bevy_markdown/src/lib.rs expression: "generate_markdown_lines(BevyMarkdown {\n markdown_theme,\n text: input.clone(),\n attrs: AttrsOwned::new(Attrs::new()),\n })" --- Ok( BevyMarkdownLines { lines: [ [], [ ( " • ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 1, }, ), ( "Import a HTML file and watch it magically convert to Markdown", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 2, }, ), ], [ ( " ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 4, }, ), ( " ◦ ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 5, }, ), ( "Drag and drop images (requires your Dropbox account be linked)", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 6, }, ), ], [], [ ( " • ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 9, }, ), ( "Import and save files from GitHub, Dropbox, Google Drive and One Drive", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 10, }, ), ], [ ( " ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 12, }, ), ( " ◦ ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 13, }, ), ( "Drag and drop markdown and HTML files into Dillinger", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 14, }, ), ], [], [ ( " • ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 17, }, ), ( "Export documents as Markdown, HTML and PDF", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 18, }, ), ], [], ], span_metadata: [ TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, ], }, ) ================================================ FILE: crates/bevy_markdown/src/snapshots/bevy_markdown__tests__test_render_ordered_list.snap ================================================ --- source: crates/bevy_markdown/src/lib.rs expression: "generate_markdown_lines(BevyMarkdown {\n markdown_theme,\n text: input.clone(),\n attrs: AttrsOwned::new(Attrs::new()),\n })" --- Ok( BevyMarkdownLines { lines: [ [], [ ( " 1. ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 1, }, ), ( "Import a HTML file and watch it magically convert to Markdown", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 2, }, ), ], [ ( " 2. ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 4, }, ), ( "Drag and drop images (requires your Dropbox account be linked)", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 5, }, ), ], [ ( " 3. ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 7, }, ), ( "Import and save files from GitHub, Dropbox, Google Drive and One Drive", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 8, }, ), ], [], ], span_metadata: [ TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, ], }, ) ================================================ FILE: crates/bevy_markdown/src/snapshots/bevy_markdown__tests__test_render_text_style.snap ================================================ --- source: crates/bevy_markdown/src/lib.rs expression: "generate_markdown_lines(BevyMarkdown {\n markdown_theme,\n text: input.clone(),\n attrs: AttrsOwned::new(Attrs::new()),\n })" --- Ok( BevyMarkdownLines { lines: [ [ ( "bold1", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 700, ), metadata: 0, }, ), ], [ ( "bold2", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 700, ), metadata: 2, }, ), ], [ ( "italic1", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Italic, weight: Weight( 400, ), metadata: 4, }, ), ], [ ( "italic2", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Italic, weight: Weight( 400, ), metadata: 6, }, ), ], [ ( "Hello world", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 7, }, ), ], [ ( "link", AttrsOwned { color_opt: Some( Color( 4278848010, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 8, }, ), ], ], span_metadata: [ TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: Some( "https://example.com", ), }, ], }, ) ================================================ FILE: crates/bevy_markdown/src/snapshots/bevy_markdown__tests__test_render_text_style_complicated.snap ================================================ --- source: crates/bevy_markdown/src/lib.rs expression: "generate_markdown_lines(BevyMarkdown {\n markdown_theme,\n text: input.clone(),\n attrs: AttrsOwned::new(Attrs::new()),\n })" --- Ok( BevyMarkdownLines { lines: [ [ ( "bold1", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 700, ), metadata: 0, }, ), ( " normal text", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 1, }, ), ], [ ( "Italic", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Italic, weight: Weight( 400, ), metadata: 2, }, ), ( " and then italic again", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Italic, weight: Weight( 400, ), metadata: 3, }, ), ], [ ( "Inner links ", AttrsOwned { color_opt: Some( Color( 4278848010, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 5, }, ), ( "can be styled", AttrsOwned { color_opt: Some( Color( 4278848010, ), ), family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 700, ), metadata: 6, }, ), ( "too", AttrsOwned { color_opt: Some( Color( 4278848010, ), ), family_owned: SansSerif, stretch: Normal, style: Italic, weight: Weight( 700, ), metadata: 7, }, ), ], ], span_metadata: [ TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: Some( "https://google.com", ), }, TextSpanMetadata { link: Some( "https://google.com", ), }, TextSpanMetadata { link: Some( "https://google.com", ), }, ], }, ) ================================================ FILE: crates/bevy_markdown/src/snapshots/bevy_markdown__tests__test_render_text_style_header.snap ================================================ --- source: crates/bevy_markdown/src/lib.rs expression: "generate_markdown_lines(BevyMarkdown {\n markdown_theme,\n text: input.clone(),\n attrs: AttrsOwned::new(Attrs::new()),\n })" --- Ok( BevyMarkdownLines { lines: [ [], [ ( "Header 1", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 700, ), metadata: 1, }, ), ], [], [ ( "Header 2", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 700, ), metadata: 4, }, ), ], [], [ ( "Header 3", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 700, ), metadata: 7, }, ), ], [], [ ( "Some header 4 ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 700, ), metadata: 10, }, ), ( "as well", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Italic, weight: Weight( 700, ), metadata: 11, }, ), ( " italicised", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 700, ), metadata: 12, }, ), ], [], [ ( "another header ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 700, ), metadata: 15, }, ), ( "redirecting to google", AttrsOwned { color_opt: Some( Color( 4278848010, ), ), family_owned: SansSerif, stretch: Normal, style: Italic, weight: Weight( 700, ), metadata: 16, }, ), ], [], ], span_metadata: [ TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: Some( "https://google.com", ), }, TextSpanMetadata { link: None, }, ], }, ) ================================================ FILE: crates/bevy_markdown/src/snapshots/bevy_markdown__tests__test_render_unordered_list.snap ================================================ --- source: crates/bevy_markdown/src/lib.rs expression: "generate_markdown_lines(BevyMarkdown {\n markdown_theme,\n text: input.clone(),\n attrs: AttrsOwned::new(Attrs::new()),\n })" --- Ok( BevyMarkdownLines { lines: [ [], [ ( " • ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 1, }, ), ( "Import a HTML file and watch it magically convert to Markdown", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 2, }, ), ], [ ( " • ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 4, }, ), ( "Drag and drop images (requires your Dropbox account be linked)", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 5, }, ), ], [ ( " • ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 7, }, ), ( "Import and save files from GitHub, Dropbox, Google Drive and One Drive", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 8, }, ), ], [ ( " • ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 10, }, ), ( "Drag and drop markdown and HTML files into Dillinger", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 11, }, ), ], [ ( " • ", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 13, }, ), ( "Export documents as Markdown, HTML and PDF", AttrsOwned { color_opt: None, family_owned: SansSerif, stretch: Normal, style: Normal, weight: Weight( 400, ), metadata: 14, }, ), ], [], ], span_metadata: [ TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, TextSpanMetadata { link: None, }, ], }, ) ================================================ FILE: docs/architecture.md ================================================ **ATTENTION** _This doc may be outdated_ # PreStartup _Runs once at the start of the app_ ## Systems - [VeloPlugin/setup_velo_theme](#setup_velo_theme) # Startup _Runs once at the start of the app_ ## Systems - (NATIVE-ONLY): [UiPlugin/read_native_config](#read_native_config) - (NATIVE-ONLY): [UiPlugin/init_search_index](#init_search_index) - (WASM-ONLY): [UiPlugin/load_from_url](#load_from_url) - [UiPlugin/init_layout](#init_layout) - [VeloPlugin/setup_camera](#setup_camera) - [VeloPlugin/setup_background](#setup_background) ## Ordering - (NATIVE-ONLY): [UiPlugin/read_native_config](#read_native_config) --> [UiPlugin/init_layout](#init_layout) - (NATIVE-ONLY): [UiPlugin/init_search_index](#init_search_index) --> [UiPlugin/init_layout](#init_layout) - (WASM-ONLY): [UiPlugin/load_from_url](#load_from_url) --> [UiPlugin/init_layout](#init_layout) # PreUpdate ## Systems - [ArrowPlugin/create_arrow_start](#create_arrow_start) - [ArrowPlugin/create_arrow_end](#create_arrow_end) - [ArrowPlugin/redraw_arrows](#redraw_arrows) # Update ## Systems - [PreStartup](#prestartup) - [Systems](#systems) - [Startup](#startup) - [Systems](#systems-1) - [Ordering](#ordering) - [PreUpdate](#preupdate) - [Systems](#systems-2) - [Update](#update) - [Systems](#systems-3) - [Ordering](#ordering-1) - [PostUpdate](#postupdate) - [Systems](#systems-4) - [All systems](#all-systems) - [`active_editor_changed`](#active_editor_changed) - [`add_tab_handler`](#add_tab_handler) - [`button_generic_handler`](#button_generic_handler) - [`cancel_modal`](#cancel_modal) - [`canvas_click`](#canvas_click) - [`change_arrow_type`](#change_arrow_type) - [`change_color_pallete`](#change_color_pallete) - [`change_text_pos`](#change_text_pos) - [`change_theme`](#change_theme) - [`clickable_links`](#clickable_links) - [`confirm_modal`](#confirm_modal) - [`cosmic_edit_bevy_events`](#cosmic_edit_bevy_events) - [`cosmic_edit_redraw_buffer`](#cosmic_edit_redraw_buffer) - [`cosmic_edit_redraw_buffer_ui`](#cosmic_edit_redraw_buffer_ui) - [`cosmic_edit_set_redraw`](#cosmic_edit_set_redraw) - [`create_arrow_end`](#create_arrow_end) - [`create_arrow_start`](#create_arrow_start) - [`create_new_node`](#create_new_node) - [`delete_doc_handler`](#delete_doc_handler) - [`delete_tab_handler`](#delete_tab_handler) - [`doc_list_del_button_update`](#doc_list_del_button_update) - [`doc_list_ui_changed`](#doc_list_ui_changed) - [`entity_to_edit_changed`](#entity_to_edit_changed) - [`export_to_file`](#export_to_file) - [`import_from_file`](#import_from_file) - [`import_from_url`](#import_from_url) - [`init_layout`](#init_layout) - [`init_search_index`](#init_search_index) - [`interactive_sprite`](#interactive_sprite) - [`keyboard_input_system`](#keyboard_input_system) - [`list_item_click`](#list_item_click) - [`load_doc`](#load_doc) - [`load_doc_handler`](#load_doc_handler) - [`load_from_url`](#load_from_url) - [`load_tab`](#load_tab) - [`mouse_scroll_list`](#mouse_scroll_list) - [`new_doc_handler`](#new_doc_handler) - [`on_scale_factor_change`](#on_scale_factor_change) - [`particles_effect`](#particles_effect) - [`read_native_config`](#read_native_config) - [`rec_button_handlers`](#rec_button_handlers) - [`redraw_arrows`](#redraw_arrows) - [`remove_load_doc_request`](#remove_load_doc_request) - [`remove_load_tab_request`](#remove_load_tab_request) - [`remove_save_doc_request`](#remove_save_doc_request) - [`remove_save_tab_request`](#remove_save_tab_request) - [`rename_doc_handler`](#rename_doc_handler) - [`rename_tab_handler`](#rename_tab_handler) - [`resize_entity_end`](#resize_entity_end) - [`resize_entity_run`](#resize_entity_run) - [`resize_entity_start`](#resize_entity_start) - [`resize_notificator`](#resize_notificator) - [`save_doc`](#save_doc) - [`save_doc_handler`](#save_doc_handler) - [`save_tab`](#save_tab) - [`save_to_store`](#save_to_store) - [`search_box_click`](#search_box_click) - [`search_box_text_changed`](#search_box_text_changed) - [`select_tab_handler`](#select_tab_handler) - [`set_focused_entity`](#set_focused_entity) - [`set_window_property`](#set_window_property) - [`setup_background`](#setup_background) - [`setup_camera`](#setup_camera) - [`setup_velo_theme`](#setup_velo_theme) - [`shared_doc_handler`](#shared_doc_handler) - [`should_load_doc`](#should_load_doc) - [`should_load_tab`](#should_load_tab) - [`should_save_doc`](#should_save_doc) - [`should_save_tab`](#should_save_tab) - [`update_rectangle_position`](#update_rectangle_position) ## Ordering - [CosmicEditPlugin/cosmic_edit_redraw_buffer_ui](#cosmic_edit_redraw_buffer_ui) --> [CosmicEditPlugin/cosmic_edit_set_redraw](#cosmic_edit_set_redraw) - [CosmicEditPlugin/cosmic_edit_redraw_buffer_ui](#cosmic_edit_redraw_buffer_ui) --> [CosmicEditPlugin/on_scale_factor_change](#on_scale_factor_change) - [CosmicEditPlugin/cosmic_edit_redraw_buffer](#cosmic_edit_redraw_buffer) --> [CosmicEditPlugin/on_scale_factor_change](#on_scale_factor_change) - [UiPlugin/save_doc](#save_doc) --> [UiPlugin/remove_save_doc_request](#remove_save_doc_request) - [UiPlugin/save_doc](#save_doc) --> [UiPlugin/remove_save_tab_request](#remove_save_tab_request) - [UiPlugin/save_doc](#save_doc) --> [UiPlugin/remove_load_doc_request](#remove_load_doc_request) - [UiPlugin/save_doc](#save_doc) --> [UiPlugin/remove_load_tab_request](#remove_load_tab_request) - [UiPlugin/keyboard_input_system](#keyboard_input_system) --> [CosmicEditPlugin/cosmic_edit_bevy_events](#cosmic_edit_bevy_events) - [UiPlugin/doc_list_del_button_update](#doc_list_del_button_update) --> [UiPlugin/doc_list_ui_changed](#doc_list_ui_changed) - [UiPlugin/save_tab](#save_tab) --> [UiPlugin/save_to_store](#save_to_store) - [UiPlugin/interactive_sprite](#interactive_sprite) --> [UiPlugin/canvas_click](#canvas_click) - [UiPlugin/set_focused_entity](#set_focused_entity) --> [UiPlugin/clickable_links](#clickable_links) - [UiPlugin/entity_to_edit_changed](#entity_to_edit_changed) --> [UiPlugin/save_doc](#save_doc) - [UiPlugin/entity_to_edit_changed](#entity_to_edit_changed) --> [UiPlugin/save_doc](#save_doc) - [UiPlugin/entity_to_edit_changed](#entity_to_edit_changed) --> [UiPlugin/load_tab](#load_tab) - [UiPlugin/entity_to_edit_changed](#entity_to_edit_changed) --> [UiPlugin/load_doc](#load_doc) - [UiPlugin/entity_to_edit_changed](#entity_to_edit_changed) --> [UiPlugin/rec_button_handlers](#rec_button_handlers) - [UiPlugin/entity_to_edit_changed](#entity_to_edit_changed) --> [UiPlugin/create_new_node](#create_new_node) # PostUpdate ## Systems - [UiPlugin/resize_notificator](#resize_notificator) # All systems ## `active_editor_changed` ```rs pub fn active_editor_changed( active_editor: ResMut, mut previous_editor: Local>, mut cosmic_edit_query: Query<&mut CosmicEdit, With>, mut cosmic_fonts: ResMut>, ) {} ``` ## `add_tab_handler` ```rs pub fn add_tab_handler( mut commands: Commands, mut interaction_query: Query<&Interaction, (Changed, With)>, mut app_state: ResMut, ) {} ``` ## `button_generic_handler` ```rs pub fn button_generic_handler( _commands: Commands, mut generic_button_query: Query< (&Interaction, &mut BackgroundColor, Entity), (Changed, With), >, mut windows: Query<&mut Window, With>, mut tooltips_query: Query<(&mut Style, &Parent), With>, ) {} ``` ## `cancel_modal` ```rs pub fn cancel_modal( mut commands: Commands, mut interaction_query: Query< (&Interaction, &ModalCancel), (Changed, With), >, mut state: ResMut, query: Query<(Entity, &ModalTop), With>, ) {} ``` ## `canvas_click` ```rs pub fn canvas_click( interaction_query: Query<&Interaction, (Changed, With)>, mut ui_state: ResMut, mut windows: Query<&mut Window, With>, mut node_interaction_events: EventReader, raw_text: Query>, ) {} ``` ## `change_arrow_type` ```rs pub fn change_arrow_type( mut interaction_query: Query< (&Interaction, &ArrowMode), (Changed, With), >, mut state: ResMut, ) {} ``` ## `change_color_pallete` ```rs pub fn change_color_pallete( mut interaction_query: Query< (&Interaction, &ChangeColor), (Changed, With), >, mut velo_border: Query<(&mut Fill, &VeloBorder), With>, ui_state: Res, ) {} ``` ## `change_text_pos` ```rs pub fn change_text_pos( mut interaction_query: Query< (&Interaction, &TextPosMode), (Changed, With), >, state: Res, mut raw_text_node_query: Query<(&RawText, &mut CosmicEdit), With>, ) {} ``` ## `change_theme` ```rs pub fn change_theme( mut pkv: ResMut, mut change_theme_button: Query<&Interaction, (Changed, With)>, mut change_theme_label: Query<&mut Text, (With, Without)>, mut tooltip_label: Query<&mut Text, (With, Without)>, ) {} ``` ## `clickable_links` ```rs pub fn clickable_links( mut windows: Query<&mut Window, With>, mut markdown_text_query: Query< (&GlobalTransform, &mut CosmicEdit, &BevyMarkdownView), With, >, mut node_interaction_events: EventReader, ui_state: Res, ) {} ``` ## `confirm_modal` ```rs pub fn confirm_modal( mut commands: Commands, mut interaction_query: Query< (&Interaction, &ModalConfirm), (Changed, With), >, mut app_state: ResMut, mut ui_state: ResMut, query_top: Query<(Entity, &ModalTop), With>, mut tab_query_container: Query<(Entity, &TabContainer), With>, mut pkv: ResMut, input: Res>, mut query_path: Query<(&CosmicEdit, &EditableText), With>, comm_channels: Res, ) {} ``` ## `cosmic_edit_bevy_events` ```rs pub fn cosmic_edit_bevy_events( windows: Query<&Window, With>, active_editor: Res, keys: Res>, mut char_evr: EventReader, buttons: Res>, mut cosmic_edit_query: Query<(&mut CosmicEdit, &GlobalTransform, Entity), With>, mut is_deleting: Local, mut font_system_assets: ResMut>, mut scroll_evr: EventReader, ) {} ``` ## `cosmic_edit_redraw_buffer` ```rs fn cosmic_edit_redraw_buffer( windows: Query<&Window, With>, mut images: ResMut>, mut swash_cache_state: ResMut, mut cosmic_edit_query: Query< (&mut CosmicEdit, &mut Handle, &mut Visibility), With, >, mut font_system_assets: ResMut>, ) {} ``` ## `cosmic_edit_redraw_buffer_ui` ```rs fn cosmic_edit_redraw_buffer_ui( windows: Query<&Window, With>, mut images: ResMut>, mut swash_cache_state: ResMut, mut cosmic_edit_query: Query< (&mut CosmicEdit, &mut UiImage, &Node, &mut Visibility), With, >, mut font_system_assets: ResMut>, ) {} ``` ## `cosmic_edit_set_redraw` ```rs fn cosmic_edit_set_redraw(mut cosmic_edit_query: Query<&mut CosmicEdit, Added>) {} ``` ## `create_arrow_end` ```rs pub fn create_arrow_end( mut commands: Commands, mut events: EventReader, arrow_markers: Query<(&ArrowConnect, &GlobalTransform), With>, theme: Res, ) {} ``` ## `create_arrow_start` ```rs pub fn create_arrow_start( mut node_interaction_events: EventReader, arrow_connect_query: Query<&ArrowConnect, With>, mut state: ResMut, mut create_arrow: EventWriter, mut windows: Query<&mut Window, With>, ) {} ``` ## `create_new_node` ```rs pub fn create_new_node( mut commands: Commands, mut events: EventReader, mut ui_state: ResMut, app_state: Res, mut windows: Query<&mut Window, With>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, mut z_index_local: Local, mut shaders: ResMut>, ) {} ``` ## `delete_doc_handler` ```rs pub fn delete_doc_handler( mut commands: Commands, mut delete_doc_query: Query<&Interaction, (Changed, With)>, mut ui_state: ResMut, mut app_state: ResMut, main_panel_query: Query>, windows: Query<&Window, With>, pkv: Res, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, ) {} ``` ## `delete_tab_handler` ```rs pub fn delete_tab_handler( mut commands: Commands, mut interaction_query: Query<&Interaction, (Changed, With)>, mut app_state: ResMut, mut ui_state: ResMut, main_panel_query: Query>, windows: Query<&Window, With>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, ) {} ``` ## `doc_list_del_button_update` ```rs pub fn doc_list_del_button_update( app_state: Res, mut delete_doc: Query<(&mut Visibility, &DeleteDoc), With>, mut event_reader: EventReader, ) {} ``` ## `doc_list_ui_changed` ```rs pub fn doc_list_ui_changed( mut commands: Commands, app_state: Res, mut last_doc_list: Local>, mut doc_list_query: Query>, asset_server: Res, pkv: Res, mut query_container: Query>, mut event_writer: EventWriter, theme: Res, mut cosmic_fonts: ResMut>, font_system_state: ResMut, windows: Query<&mut Window, With>, ) {} ``` ## `entity_to_edit_changed` ```rs pub fn entity_to_edit_changed( ui_state: Res, app_state: Res, theme: Res, mut last_entity_to_edit: Local>, mut velo_border: Query<(&mut Stroke, &VeloBorder), With>, mut raw_text_node_query: Query<(Entity, &mut RawText, &mut CosmicEdit), With>, mut commands: Commands, mut cosmic_fonts: ResMut>, ) {} ``` ## `export_to_file` ```rs pub fn export_to_file( mut commands: Commands, mut query: Query<&Interaction, (Changed, With)>, mut ui_state: ResMut, main_panel_query: Query>, windows: Query<&Window, With>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, ) {} ``` ## `import_from_file` ```rs pub fn import_from_file( mut commands: Commands, mut query: Query<&Interaction, (Changed, With)>, mut ui_state: ResMut, main_panel_query: Query>, windows: Query<&Window, With>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, ) {} ``` ## `import_from_url` ```rs pub fn import_from_url( mut commands: Commands, mut query: Query<&Interaction, (Changed, With)>, mut ui_state: ResMut, main_panel_query: Query>, windows: Query<&Window, With>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, ) {} ``` ## `init_layout` ```rs pub fn init_layout( mut commands: Commands, mut app_state: ResMut, asset_server: Res, mut pkv: ResMut, mut cosmic_fonts: ResMut>, windows: Query<&Window, With>, mut fonts: ResMut>, theme: Res, ) {} ``` ## `init_search_index` ```rs pub fn init_search_index(mut app_state: ResMut) {} ``` ## `interactive_sprite` ```rs pub fn interactive_sprite( cursor_moved_events: EventReader, windows: Query<&Window, With>, buttons: Res>, res_images: Res>, mut sprite_query: Query< (&Sprite, &Handle, &GlobalTransform, Entity), With, >, camera_q: Query<(&Camera, &GlobalTransform), With>, mut node_interaction_events: EventWriter, mut double_click: Local<(Duration, Option)>, mut holding_state: Local, ) {} ``` ## `keyboard_input_system` ```rs pub fn keyboard_input_system( mut commands: Commands, mut images: ResMut>, mut app_state: ResMut, mut ui_state: ResMut, mut events: EventWriter, input: Res>, windows: Query<&Window, With>, mut editable_text_query: Query<(&EditableText, &mut CosmicEdit), With>, theme: Res, ) {} ``` ## `list_item_click` ```rs pub fn list_item_click( mut interaction_query: Query< (&Interaction, &DocListItemButton), (Changed, With), >, mut state: ResMut, mut commands: Commands, ) {} ``` ## `load_doc` ```rs pub fn load_doc( request: Res, mut app_state: ResMut, mut commands: Commands, mut bottom_panel: Query>, mut pkv: ResMut, asset_server: Res, mut tabs_query: Query>, mut delete_doc: Query<(&mut Visibility, &DeleteDoc), With>, theme: Res, mut cosmic_fonts: ResMut>, font_system_state: ResMut, windows: Query<&Window, With>, ) {} ``` ## `load_doc_handler` ```rs pub fn load_doc_handler( mut commands: Commands, mut app_state: ResMut, comm_channels: Res, pkv: Res, ) {} ``` ## `load_from_url` ```rs fn load_from_url(mut commands: Commands) {} ``` ## `load_tab` ```rs pub fn load_tab( old_nodes: Query>, mut old_arrows: Query>, request: Res, mut app_state: ResMut, mut ui_state: ResMut, mut commands: Commands, mut res_images: ResMut>, mut create_arrow: EventWriter, mut delete_tab: Query<(&mut Visibility, &DeleteTab), (With, Without)>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, mut windows: Query<&mut Window, With>, mut shaders: ResMut>, theme: Res, ) {} ``` ## `mouse_scroll_list` ```rs pub fn mouse_scroll_list( mut mouse_wheel_events: EventReader, mut query_list: Query<(&mut ScrollingList, &mut Style, &Parent, &Node)>, query_node: Query<&Node>, ) {} ``` ## `new_doc_handler` ```rs pub fn new_doc_handler( mut commands: Commands, mut new_doc_query: Query<&Interaction, (Changed, With)>, mut app_state: ResMut, ) {} ``` ## `on_scale_factor_change` ```rs fn on_scale_factor_change( mut scale_factor_changed: EventReader, mut cosmic_edit_query: Query<&mut CosmicEdit, With>, mut cosmic_fonts: ResMut>, ) {} ``` ## `particles_effect` ```rs pub fn particles_effect( mut query: Query<&Interaction, (Changed, With)>, mut commands: Commands, mut effects: ResMut>, mut effects_camera: Query<&mut Camera, With>, mut effects_query: Query<(&Name, Entity)>, mut shadow_query: Query<&mut Transform, With>, ) {} ``` ## `read_native_config` ```rs fn read_native_config(mut app_state: ResMut) {} ``` ## `rec_button_handlers` ```rs pub fn rec_button_handlers( mut commands: Commands, mut events: EventWriter, mut interaction_query: Query< (&Interaction, &ButtonAction), (Changed, With), >, mut raw_text_query: Query<(&mut CosmicEdit, &RawText, &Parent), With>, border_query: Query<&Parent, With>, mut velo_node_query: Query<(Entity, &VeloNode, &mut Transform), With>, mut arrows: Query<(Entity, &ArrowMeta, &mut Visibility), (With, Without)>, mut state: ResMut, mut app_state: ResMut, theme: Res, ) {} ``` ## `redraw_arrows` ```rs pub fn redraw_arrows( mut redraw_arrow: EventReader, mut arrow_query: Query<(&mut Path, &mut ArrowMeta), With>, arrow_markers: Query<(&ArrowConnect, &GlobalTransform), With>, ) {} ``` ## `remove_load_doc_request` ```rs pub fn remove_load_doc_request(world: &mut World) {} ``` ## `remove_load_tab_request` ```rs pub fn remove_load_tab_request(world: &mut World) {} ``` ## `remove_save_doc_request` ```rs pub fn remove_save_doc_request(world: &mut World) {} ``` ## `remove_save_tab_request` ```rs pub fn remove_save_tab_request(world: &mut World) {} ``` ## `rename_doc_handler` ```rs pub fn rename_doc_handler( mut commands: Commands, mut rename_doc_query: Query< (&Interaction, &DocListItemButton, Entity, &mut CosmicEdit), (Changed, With), >, mut ui_state: ResMut, mut double_click: Local<(Duration, Option)>, theme: Res, ) {} ``` ## `rename_tab_handler` ```rs pub fn rename_tab_handler( mut commands: Commands, mut interaction_query: Query< (&Interaction, &TabButton, Entity, &mut CosmicEdit), (Changed, With), >, mut ui_state: ResMut, mut app_state: ResMut, mut double_click: Local<(Duration, Option)>, theme: Res, ) {} ``` ## `resize_entity_end` ```rs pub fn resize_entity_end( mut commands: Commands, mut shaders: ResMut>, theme: ResMut, mut ui_state: ResMut, mut node_interaction_events: EventReader, raw_text_query: Query<(&Parent, &RawText, &CosmicEdit), With>, border_query: Query<(&Parent, &VeloBorder), With>, velo_node_query: Query>, ) {} ``` ## `resize_entity_run` ```rs pub fn resize_entity_run( mut commands: Commands, ui_state: ResMut, mut cursor_moved_events: EventReader, mut events: EventWriter, mut resize_marker_query: Query< (&ResizeMarker, &Parent, &mut Transform), (With, Without, Without), >, mut arrow_connector_query: Query< (&ArrowConnect, &mut Transform), (With, Without, Without), >, mut raw_text_query: Query<(&Parent, &RawText, &mut CosmicEdit, &mut Sprite), With>, mut border_query: Query<(&Parent, &VeloBorder, &mut Path), With>, mut velo_node_query: Query< (&mut Transform, &Children), (With, Without, Without), >, shadow_query: Query>, camera_q: Query<(&Camera, &GlobalTransform), With>, ) {} ``` ## `resize_entity_start` ```rs pub fn resize_entity_start( mut ui_state: ResMut, mut node_interaction_events: EventReader, mut windows: Query<&mut Window, With>, resize_marker_query: Query<(&ResizeMarker, &Parent, &mut Transform), With>, velo_node_query: Query<&VeloNode, With>, ) {} ``` ## `resize_notificator` ```rs pub fn resize_notificator( mut commands: Commands, resize_event: Res>, app_state: Res, mut tabs: Query<&mut CosmicEdit, (With, Without)>, mut docs: Query<&mut CosmicEdit, (With, Without)>, ) {} ``` ## `save_doc` ```rs pub fn save_doc( request: Res, mut app_state: ResMut, mut pkv: ResMut, mut commands: Commands, mut events: EventWriter, ) {} ``` ## `save_doc_handler` ```rs pub fn save_doc_handler( mut commands: Commands, mut save_doc_query: Query<&Interaction, (Changed, With)>, state: Res, ) {} ``` ## `save_tab` ```rs pub fn save_tab( images: Res>, arrows: Query<&ArrowMeta, With>, request: Res, mut app_state: ResMut, raw_text_query: Query<(&RawText, &CosmicEdit, &Parent), With>, border_query: Query<(&Parent, &VeloBorder, &Fill), With>, velo_node_query: Query<&Transform, With>, ) {} ``` ## `save_to_store` ```rs pub fn save_to_store( mut pkv: ResMut, mut app_state: ResMut, mut events: EventReader, ) {} ``` ## `search_box_click` ```rs pub fn search_box_click( mut commands: Commands, mut interaction_query: Query< (&Interaction, &SearchButton), (Changed, With), >, mut search_query: Query<(&SearchText, Entity), With>, mut state: ResMut, mut windows: Query<&mut Window, With>, ) {} ``` ## `search_box_text_changed` ```rs pub fn search_box_text_changed( text_query: Query<&CosmicEdit, With>, mut velo_border: Query<(&mut Stroke, &VeloBorder), With>, mut previous_search_text: Local, mut app_state: ResMut, pkv: Res, theme: Res, ) {} ``` ## `select_tab_handler` ```rs pub fn select_tab_handler( mut commands: Commands, mut interaction_query: Query< (&Interaction, &TabButton), (Changed, With), >, mut state: ResMut, ) {} ``` ## `set_focused_entity` ```rs pub fn set_focused_entity( mut windows: Query<&mut Window, With>, mut node_interaction_events: EventReader, mut ui_state: ResMut, velo: Query<&RawText, With>, ) {} ``` ## `set_window_property` ```rs pub fn set_window_property(mut app_state: ResMut, mut pkv: ResMut) {} ``` ## `setup_background` ```rs pub fn setup_background(mut commands: Commands, asset_server: Res, theme: Res) {} ``` ## `setup_camera` ```rs pub fn setup_camera(mut commands: Commands) {} ``` ## `setup_velo_theme` ```rs pub fn setup_velo_theme(mut commands: Commands, pkv: Res) {} ``` ## `shared_doc_handler` ```rs pub fn shared_doc_handler( mut app_state: ResMut, mut query: Query<&Interaction, (Changed, With)>, mut pkv: ResMut, ) {} ``` ## `should_load_doc` ```rs pub fn should_load_doc(request: Option>) -> bool {} ``` ## `should_load_tab` ```rs pub fn should_load_tab(request: Option>) -> bool {} ``` ## `should_save_doc` ```rs pub fn should_save_doc(request: Option>) -> bool {} ``` ## `should_save_tab` ```rs pub fn should_save_tab(request: Option>) -> bool {} ``` ## `update_rectangle_position` ```rs pub fn update_rectangle_position( mut cursor_moved_events: EventReader, raw_text_query: Query<(&RawText, &Parent), With>, border_query: Query<&Parent, With>, mut velo_node_query: Query<&mut Transform, With>, mut events: EventWriter, camera_q: Query<(&Camera, &GlobalTransform), With>, ui_state: Res, ) {} ``` ================================================ FILE: justfile ================================================ # Run native native: cargo run --release # Run wasm wasm: cargo install wasm-server-runner RUSTFLAGS=--cfg=web_sys_unstable_apis cargo run --release --target wasm32-unknown-unknown # Create app bundle with icon (tested only on MacOS) bundle: cargo build --release cargo install cargo-bundle cargo bundle # Lints to be used before commit lint: cargo fmt cargo clippy -- -A clippy::type_complexity -A clippy::too_many_arguments ================================================ FILE: security.md ================================================ # Security Policy ## Introduction This security policy outlines the expectations and procedures for reporting and resolving vulnerabilities in our GitHub repository. The security of our code is of utmost importance to us, and we appreciate the help of the community in identifying and reporting potential security vulnerabilities. ## Reporting a Vulnerability If you believe you have discovered a vulnerability in our repository, please report it to us immediately by opening a new issue on our GitHub repository. When reporting a vulnerability, please include as much detail as possible, including a clear description of the issue, steps to reproduce the vulnerability, and any relevant code snippets or screenshots. ## Vulnerability Assessment We will review the reported vulnerability and assess the potential impact to our codebase and systems. If the vulnerability is confirmed, we will determine the severity of the issue and prioritize it accordingly. ## Fixing the Vulnerability Once we have determined the severity of the vulnerability, we will work to address it as quickly as possible. We may contact you for additional information or assistance in reproducing the vulnerability, and we will keep you updated on our progress in fixing the issue. ## Disclosure Once the vulnerability has been fixed, we will publicly disclose the vulnerability and our response to it. ## Vulnerability Declined In some cases, we may determine that a reported vulnerability is not a security issue or does not pose a significant risk to our systems. If we decline to address a reported vulnerability, we will explain our decision and provide our rationale for not taking action. ## Conclusion We appreciate your assistance in helping to ensure the security of our codebase and systems. By working together, we can maintain a safe and secure environment for our users and customers. ================================================ FILE: src/canvas/arrow/components.rs ================================================ use crate::utils::ReflectableUuid; use bevy::prelude::*; use serde::{Deserialize, Serialize}; #[derive( Component, Copy, Clone, Debug, Eq, PartialEq, Hash, Reflect, Default, Serialize, Deserialize, )] #[reflect(Component)] pub struct ArrowMeta { pub visible: bool, pub arrow_type: ArrowType, pub start: ArrowConnect, pub end: ArrowConnect, } #[derive( Component, Copy, Clone, Debug, Eq, PartialEq, Hash, Reflect, Default, Serialize, Deserialize, )] #[reflect(Component)] pub struct ArrowConnect { pub id: ReflectableUuid, pub pos: ArrowConnectPos, } #[derive(Component)] pub struct ArrowMode { pub arrow_type: ArrowType, } #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Reflect, Default, Serialize, Deserialize)] pub enum ArrowConnectPos { #[default] Top, Bottom, Left, Right, } #[derive(Serialize, Deserialize, Default, Copy, Clone, Reflect, Debug, Eq, PartialEq, Hash)] pub enum ArrowType { Line, Arrow, DoubleArrow, ParallelLine, #[default] ParallelArrow, ParallelDoubleArrow, } ================================================ FILE: src/canvas/arrow/events.rs ================================================ use bevy::prelude::Event; use super::components::{ArrowConnect, ArrowType}; use crate::utils::ReflectableUuid; #[derive(Event)] pub struct RedrawArrow { pub id: ReflectableUuid, } #[derive(Event, Eq, PartialEq, Hash, Debug, Clone, Copy)] pub struct CreateArrow { pub visible: bool, pub arrow_type: ArrowType, pub start: ArrowConnect, pub end: ArrowConnect, } ================================================ FILE: src/canvas/arrow/mod.rs ================================================ pub mod components; pub mod events; mod systems; mod utils; use bevy::{ app::{App, Plugin}, prelude::PreUpdate, }; use bevy_prototype_lyon::prelude::ShapePlugin; use systems::*; pub struct ArrowPlugin; impl Plugin for ArrowPlugin { fn build(&self, app: &mut App) { app.add_plugins(ShapePlugin).add_systems( PreUpdate, // due to CreateArrow event (create_arrow_start, create_arrow_end, redraw_arrows), ); } } ================================================ FILE: src/canvas/arrow/systems.rs ================================================ use bevy::{prelude::*, window::PrimaryWindow}; use super::components::{ArrowConnect, ArrowMeta}; use super::events::{CreateArrow, RedrawArrow}; use super::utils::{build_arrow, create_arrow}; use crate::themes::Theme; use crate::ui_plugin::ui_helpers::VeloNode; use crate::ui_plugin::{NodeInteraction, UiState}; use bevy_prototype_lyon::prelude::Path; pub fn create_arrow_start( mut node_interaction_events: EventReader, arrow_connect_query: Query<&ArrowConnect, With>, mut state: ResMut, mut create_arrow: EventWriter, mut windows: Query<&mut Window, With>, ) { let mut primary_window = windows.single_mut(); for event in node_interaction_events.iter() { if let Ok(arrow_connect) = arrow_connect_query.get(event.entity) { match event.node_interaction_type { crate::ui_plugin::NodeInteractionType::Hover => { primary_window.cursor.icon = CursorIcon::Crosshair; } crate::ui_plugin::NodeInteractionType::LeftClick => { match state.arrow_to_draw_start { Some(start_arrow) => { if start_arrow.id == arrow_connect.id { continue; } state.arrow_to_draw_start = None; create_arrow.send(CreateArrow { visible: true, start: start_arrow, end: *arrow_connect, arrow_type: state.arrow_type, }); } None => { state.arrow_to_draw_start = Some(*arrow_connect); } } } crate::ui_plugin::NodeInteractionType::LeftDoubleClick => {} crate::ui_plugin::NodeInteractionType::LeftMouseHoldAndDrag => {} crate::ui_plugin::NodeInteractionType::RightClick => {} crate::ui_plugin::NodeInteractionType::LeftMouseRelease => {} } } } } pub fn create_arrow_end( mut commands: Commands, mut events: EventReader, arrow_markers: Query<(&ArrowConnect, &GlobalTransform), With>, velo_nodes: Query<(&Transform, &VeloNode), With>, theme: Res, ) { for event in events.iter() { let mut start = None; let mut end = None; for (arrow_connect, global_transform) in &mut arrow_markers.iter() { if *arrow_connect == event.start { start = Some(global_transform.affine().translation.truncate()); } if *arrow_connect == event.end { end = Some(global_transform.affine().translation.truncate()); } if let (Some(start), Some(end)) = (start, end) { let mut max_z = 0.1; for (transform, velo_node) in velo_nodes.iter() { if velo_node.id == event.start.id { max_z = f32::max(max_z, transform.translation.z); } if velo_node.id == event.end.id { max_z = f32::max(max_z, transform.translation.z); } } create_arrow( &mut commands, &theme, start, end, max_z, ArrowMeta { visible: event.visible, start: event.start, end: event.end, arrow_type: event.arrow_type, }, ); break; } } } } pub fn redraw_arrows( mut redraw_arrow: EventReader, mut arrow_query: Query<(&mut Path, &mut ArrowMeta), With>, arrow_markers: Query<(&ArrowConnect, &GlobalTransform), With>, ) { for event in redraw_arrow.iter() { for (mut path, mut arrow) in arrow_query.iter_mut() { if arrow.start.id == event.id || arrow.end.id == event.id { let (arrow_hold_vec, arrow_move_vec): (Vec<_>, Vec<_>) = arrow_markers .iter() .filter(|(x, _)| x.id == arrow.end.id || x.id == arrow.start.id) .map(|(ac, gt)| (ac, gt.affine().translation.truncate())) .partition(|(x, _)| x.id == arrow.end.id); let arrow_pos = arrow_hold_vec .iter() .flat_map(move |x| std::iter::repeat(*x).zip(arrow_move_vec.clone())) .min_by_key(|(arrow_hold, arrow_move)| { arrow_hold.1.distance(arrow_move.1) as u32 }); if let Some((start_pos, end_pos)) = arrow_pos { let ((start_pos, start), (end_pos, end)) = if start_pos.0.id == arrow.start.id { (start_pos, end_pos) } else { (end_pos, start_pos) }; arrow.start = *start_pos; arrow.end = *end_pos; *path = build_arrow(start, end, *arrow); } } } } } ================================================ FILE: src/canvas/arrow/utils.rs ================================================ use std::f32::consts::PI; use bevy::prelude::*; use bevy_prototype_lyon::{ prelude::{GeometryBuilder, Path, ShapeBundle, Stroke}, shapes, }; use crate::themes::Theme; use super::components::{ArrowConnectPos, ArrowMeta, ArrowType}; pub fn create_arrow( commands: &mut Commands, theme: &Res, start: Vec2, end: Vec2, z: f32, arrow_meta: ArrowMeta, ) { let arrow_path = build_arrow(start, end, arrow_meta); let visibility = if arrow_meta.visible { Visibility::Visible } else { Visibility::Hidden }; commands.spawn(( ShapeBundle { visibility, transform: Transform::from_xyz(0.0, 0.0, z), path: arrow_path, ..default() }, arrow_meta, Stroke::new(theme.arrow, 1.5), )); } fn parallel_arrow_mid(start: Vec2, end: Vec2, arrow_meta: ArrowMeta) -> (Vec2, Vec2) { let mid = (start + end) / 2.0; use ArrowConnectPos::*; match (arrow_meta.start.pos, arrow_meta.end.pos) { (Top, Bottom) | (Bottom, Top) => (Vec2::new(start.x, mid.y), Vec2::new(end.x, mid.y)), (Left, Right) | (Right, Left) => (Vec2::new(mid.x, start.y), Vec2::new(mid.x, end.y)), (Bottom, Left) | (Top, Right) | (Top, Left) | (Bottom, Right) => { (Vec2::new(start.x, end.y), Vec2::new(start.x, end.y)) } (Left, Bottom) | (Right, Top) | (Left, Top) | (Right, Bottom) => { (Vec2::new(end.x, start.y), Vec2::new(end.x, start.y)) } (_, _) => (mid, mid), } } fn arrow_head(point: Vec2, pos: ArrowConnectPos) -> shapes::Polygon { let headlen: f32 = 10.0; use ArrowConnectPos::*; let angle = match pos { Top => PI / 2., Bottom => -PI / 2., Right => 0., Left => PI, }; let points = vec![ point + Vec2::from_angle(angle - PI / 6.) * headlen, point, point + Vec2::from_angle(angle + PI / 6.) * headlen, ]; shapes::Polygon { points, closed: false, } } pub fn build_arrow(start: Vec2, end: Vec2, arrow_meta: ArrowMeta) -> Path { match arrow_meta.arrow_type { ArrowType::Line => { let main = shapes::Line(start, end); GeometryBuilder::build_as(&main) } ArrowType::Arrow => { let dt = end.x - start.x; let dy = end.y - start.y; let angle = dy.atan2(dt); let headlen = 10.0; GeometryBuilder::new() .add(&shapes::Line(start, end)) .add(&shapes::Line( end, end - headlen * Vec2::from_angle(angle + PI / 6.), )) .add(&shapes::Line( end, end - headlen * Vec2::from_angle(angle - PI / 6.), )) .build() } ArrowType::DoubleArrow => { let headlen = 10.0; let dt = end.x - start.x; let dy = end.y - start.y; let angle = dy.atan2(dt); GeometryBuilder::new() .add(&shapes::Line( start, start + headlen * Vec2::from_angle(angle + PI / 6.), )) .add(&shapes::Line( start, start + headlen * Vec2::from_angle(angle - PI / 6.), )) .add(&shapes::Line(start, end)) .add(&shapes::Line( end, end - headlen * Vec2::from_angle(angle + PI / 6.), )) .add(&shapes::Line( end, end - headlen * Vec2::from_angle(angle - PI / 6.), )) .build() } ArrowType::ParallelLine => { let mid_point = parallel_arrow_mid(start, end, arrow_meta); GeometryBuilder::new() .add(&shapes::Line(start, mid_point.0)) .add(&shapes::Line(mid_point.0, mid_point.1)) .add(&shapes::Line(mid_point.1, end)) .build() } ArrowType::ParallelArrow => { let head_pos = arrow_meta.end.pos; let mid_point = parallel_arrow_mid(start, end, arrow_meta); GeometryBuilder::new() .add(&shapes::Line(start, mid_point.0)) .add(&shapes::Line(mid_point.0, mid_point.1)) .add(&shapes::Line(mid_point.1, end)) .add(&arrow_head(end, head_pos)) .build() } ArrowType::ParallelDoubleArrow => { let head_pos = arrow_meta.end.pos; let tail_pos = arrow_meta.start.pos; let mid_point = parallel_arrow_mid(start, end, arrow_meta); GeometryBuilder::new() .add(&arrow_head(start, tail_pos)) .add(&shapes::Line(start, mid_point.0)) .add(&shapes::Line(mid_point.0, mid_point.1)) .add(&shapes::Line(mid_point.1, end)) .add(&arrow_head(end, head_pos)) .build() } } } ================================================ FILE: src/canvas/grid/mod.rs ================================================ use bevy::{ prelude::*, reflect::{TypePath, TypeUuid}, render::render_resource::{AsBindGroup, ShaderRef}, sprite::{Material2d, Material2dPlugin}, transform::TransformSystem, }; pub mod systems; use systems::*; pub struct GridPlugin; #[derive(Resource, Default)] pub struct CanvasInserted(pub bool); impl Plugin for GridPlugin { fn build(&self, app: &mut App) { app.add_plugins(Material2dPlugin::::default()) .add_systems(Startup, grid) .add_systems( PostUpdate, (update_grid, grid_follows_camera) .chain() .after(TransformSystem::TransformPropagate), ); } } #[derive(AsBindGroup, TypeUuid, TypePath, Debug, Clone)] #[uuid = "CC3772B3-5282-4F1F-92B5-4F2D864B4441"] pub struct CustomGridMaterial { #[uniform(0)] line_color: Color, #[uniform(0)] grid_size: Vec2, #[uniform(0)] cell_size: Vec2, #[uniform(0)] major: f32, } impl Material2d for CustomGridMaterial { fn fragment_shader() -> ShaderRef { "shaders/grid.wgsl".into() } } ================================================ FILE: src/canvas/grid/systems.rs ================================================ use crate::{components::MainCamera, themes::Theme}; use super::CustomGridMaterial; use bevy::{ prelude::*, sprite::{MaterialMesh2dBundle, Mesh2dHandle}, }; const CELL_SIZE: f32 = 12.0; #[derive(Component)] pub struct Grid; pub fn grid( mut commands: Commands, mut materials: ResMut>, mut meshes: ResMut>, theme: Res, ) { let max_size = 1_000_000.; let size = Vec2::new(max_size, max_size); let mesh = Mesh::from(shape::Quad { size, flip: false }); commands .spawn(MaterialMesh2dBundle { mesh: meshes.add(mesh).into(), transform: Transform { translation: Vec3::new(0.0, 0.0, 0.0), ..Default::default() }, material: materials.add(CustomGridMaterial { line_color: theme.canvas_bg_line_color, grid_size: size, cell_size: Vec2::splat(CELL_SIZE), major: 8.0, }), ..Default::default() }) .insert(Grid); } pub fn update_grid( camera: Query< (&Camera, &GlobalTransform, &OrthographicProjection), (Changed, With), >, grid: Query<(&Handle, &Mesh2dHandle)>, mut materials: ResMut>, mut meshes: ResMut>, ) { for (camera, camera_transform, projection) in camera.iter() { for (grid_handle, mesh_handle) in grid.iter() { let current_zoom = projection.scale; let ndc = Vec3::ONE; let world_coords = camera.ndc_to_world(camera_transform, ndc).unwrap(); let corner_offset = world_coords - camera_transform.translation(); let rect = corner_offset.truncate() * 2.0; let side = rect.max_element(); let size = Vec2::splat(side) * 2.0; if let Some(mesh) = meshes.get_mut(&mesh_handle.0) { *mesh = shape::Quad::new(size).into(); } if let Some(material) = materials.get_mut(grid_handle) { let exponent = current_zoom.log(material.major); let exponent = exponent.ceil(); let grid_scale = material.major.powf(exponent); material.cell_size = Vec2::splat(CELL_SIZE) * grid_scale; material.grid_size = size; } } } } pub fn grid_follows_camera( camera: Query< &GlobalTransform, ( With, With, Or<(Changed, Changed)>, ), >, mut grid: Query<(&mut Transform, &Handle)>, materials: Res>, ) { for (mut transform, material_handle) in grid.iter_mut() { if let Some(material) = materials.get(material_handle) { for camera in camera.iter() { let major_grid_translation = material.cell_size * material.major; let camera_major_grid_translation = (camera.translation().truncate() / major_grid_translation).trunc(); let truncated_camera_major_grid_translation = camera_major_grid_translation * major_grid_translation; transform.translation.x = truncated_camera_major_grid_translation.x; transform.translation.y = truncated_camera_major_grid_translation.y; } } } } ================================================ FILE: src/canvas/mod.rs ================================================ pub mod arrow; pub mod grid; pub mod shadows; use arrow::*; use bevy::app::{App, Plugin}; use grid::*; use shadows::*; pub struct CanvasPlugin; impl Plugin for CanvasPlugin { fn build(&self, app: &mut App) { app.add_plugins((ArrowPlugin, ShadowsPlugin, GridPlugin)); } } ================================================ FILE: src/canvas/shadows/mod.rs ================================================ use bevy::{ prelude::*, reflect::{TypePath, TypeUuid}, render::render_resource::{AsBindGroup, ShaderRef}, sprite::{Material2d, Material2dPlugin}, }; use self::systems::synchronise_shadow_sizes; pub mod systems; pub struct ShadowsPlugin; impl Plugin for ShadowsPlugin { fn build(&self, app: &mut App) { app.add_plugins(Material2dPlugin::::default()) .add_systems(PostUpdate, synchronise_shadow_sizes); } } #[derive(AsBindGroup, TypeUuid, TypePath, Debug, Clone)] #[uuid = "f690fdae-d598-45ab-8225-97e2a3f056e0"] pub struct CustomShadowMaterial { #[uniform(0)] color: Color, #[uniform(0)] flat_size: Vec2, #[uniform(0)] edge_size: Vec2, } impl Material2d for CustomShadowMaterial { fn fragment_shader() -> ShaderRef { "shaders/shadows.wgsl".into() } } ================================================ FILE: src/canvas/shadows/systems.rs ================================================ use bevy::{ prelude::{shape::Quad, *}, sprite::{MaterialMesh2dBundle, Mesh2dHandle}, }; use crate::themes::Theme; use super::CustomShadowMaterial; #[derive(Component)] pub struct Shadow; // Spawn an entity using `CustomMaterial`. pub fn spawn_shadow( commands: &mut Commands, materials: &mut ResMut>, meshes: &mut ResMut>, theme: &Res, flat_size: Vec2, ) -> Entity { let edge_size = 0.09 * flat_size; let full_size = flat_size + edge_size; let mesh: Mesh = Quad::new(full_size).into(); let mesh = Mesh2dHandle(meshes.add(mesh)); let material = materials.add(CustomShadowMaterial { color: theme.shadow, flat_size, edge_size, }); let entity = commands .spawn(MaterialMesh2dBundle { mesh, material, transform: Transform { translation: Vec3::new(-3., -3., 0.0009), ..Default::default() }, ..default() }) .id(); commands .spawn(SpriteBundle { sprite: Sprite { color: Color::WHITE, custom_size: Some(flat_size), ..default() }, ..default() }) .insert(Shadow) .add_child(entity) .id() } pub fn synchronise_shadow_sizes( objects: Query<&Sprite, (Changed, With)>, shadows: Query<(&Parent, &Handle, &Mesh2dHandle)>, mut materials: ResMut>, mut meshes: ResMut>, ) { for (parent, mat, mesh) in shadows.iter() { let Ok(sprite) = objects.get(parent.get()) else { continue; }; let Some(material) = materials.get_mut(mat) else { continue; }; let Some(mesh) = meshes.get_mut(&mesh.0) else { continue; }; let flat_size = sprite.custom_size.unwrap(); let edge_size = material.edge_size; let full_size = flat_size + edge_size; *mesh = Quad::new(full_size).into(); material.flat_size = flat_size; } } ================================================ FILE: src/components.rs ================================================ use crate::utils::ReflectableUuid; use bevy::prelude::*; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; #[derive(Component)] pub struct MainCamera; #[derive(Component)] pub struct EffectsCamera; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Tab { pub is_active: bool, pub id: ReflectableUuid, pub name: String, pub checkpoints: VecDeque, pub z_index: f32, } #[derive(Default, Serialize, Deserialize, Clone, Debug)] pub struct Doc { pub tabs: Vec, pub id: ReflectableUuid, pub name: String, pub tags: Vec, } ================================================ FILE: src/lib.rs ================================================ mod canvas; mod components; mod resources; mod systems; mod themes; mod ui_plugin; mod utils; use bevy::{prelude::*, window::PresentMode}; use bevy_cosmic_edit::CosmicEditPlugin; use bevy_embedded_assets::EmbeddedAssetPlugin; #[cfg(not(target_arch = "wasm32"))] use bevy_hanabi::HanabiPlugin; use bevy_pancam::PanCamPlugin; use bevy_pkv::PkvStore; use canvas::CanvasPlugin; use resources::FontSystemState; use systems::*; use ui_plugin::*; pub static ORG_NAME: &str = ""; pub static APP_NAME: &str = "velo"; pub struct VeloPlugin; impl Plugin for VeloPlugin { fn build(&self, app: &mut App) { app.add_systems(PreStartup, setup_velo_theme) .add_systems(Startup, setup_camera) .add_plugins( DefaultPlugins .set(WindowPlugin { primary_window: Some(Window { title: "Velo".into(), present_mode: PresentMode::AutoVsync, // Tells wasm to resize the window according to the available canvas fit_canvas_to_parent: true, // Tells wasm not to override default event handling, like F5, Ctrl+R etc. prevent_default_event_handling: false, ..default() }), ..default() }) .build() .add_before::(EmbeddedAssetPlugin), ) .add_plugins(CosmicEditPlugin) .add_plugins(CanvasPlugin) .add_plugins(UiPlugin) .add_plugins(PanCamPlugin) .insert_resource(PkvStore::new(ORG_NAME, APP_NAME)) .init_resource::(); #[cfg(not(target_arch = "wasm32"))] app.add_plugins(HanabiPlugin); } } ================================================ FILE: src/macros.rs ================================================ macro_rules! pair_struct { ($obj:ident . $field:ident) => {{ let field_val = $obj.$field; (stringify!($field).to_string(), field_val) }}; } ================================================ FILE: src/main.rs ================================================ use bevy::prelude::*; use velo::VeloPlugin; fn main() { #[cfg(target_arch = "wasm32")] console_error_panic_hook::set_once(); #[cfg(not(target_arch = "wasm32"))] std::env::set_var("RUST_LOG", "warn,velo=info,tantivy=warn"); App::new().add_plugins(VeloPlugin).run(); } ================================================ FILE: src/resources.rs ================================================ use crate::components::Doc; #[cfg(not(target_arch = "wasm32"))] use crate::ui_plugin::SearchIndexState; use crate::utils::ReflectableUuid; use bevy::prelude::*; use bevy_cosmic_edit::CosmicFont; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; #[derive(Resource, Default)] pub struct AppState { pub current_document: Option, pub docs: HashMap, pub github_token: Option, #[cfg(not(target_arch = "wasm32"))] pub search_index: Option, pub doc_list_ui: HashSet, } #[derive(Resource, Debug)] pub struct SaveDocRequest { pub doc_id: ReflectableUuid, pub path: Option, // Save current document to file } #[derive(Resource, Debug)] pub struct SaveTabRequest { pub doc_id: ReflectableUuid, pub tab_id: ReflectableUuid, } #[derive(Resource, Debug)] pub struct LoadDocRequest { pub doc_id: ReflectableUuid, } #[derive(Resource, Debug)] pub struct LoadTabRequest { pub doc_id: ReflectableUuid, pub tab_id: ReflectableUuid, pub drop_last_checkpoint: bool, // Useful for undo functionality } #[derive(Resource, Default)] pub struct FontSystemState(pub Option>); ================================================ FILE: src/systems.rs ================================================ use crate::{ components::{EffectsCamera, MainCamera}, themes::{get_theme_by_name, Theme}, utils::get_theme_key, }; use bevy::{ core_pipeline::clear_color::ClearColorConfig, prelude::*, render::{camera::ScalingMode, view::RenderLayers}, }; use bevy_pancam::PanCam; use bevy_pkv::PkvStore; pub fn setup_velo_theme(mut commands: Commands, pkv: Res) { let theme_key = get_theme_key(&pkv); let theme = get_theme_by_name(&theme_key); commands.insert_resource(theme); } pub fn setup_camera(mut commands: Commands, theme: Res) { let mut main_camera = Camera2dBundle::default(); if let Some(bg_color) = theme.canvas_bg_color { main_camera.camera_2d.clear_color = ClearColorConfig::Custom(bg_color); } else { main_camera.camera_2d.clear_color = ClearColorConfig::Custom(Color::WHITE.with_a(0.1)); } let max_size = theme.max_camera_space; commands.spawn((main_camera, MainCamera)).insert(PanCam { grab_buttons: vec![MouseButton::Right], enabled: true, zoom_to_cursor: false, min_scale: 1., max_scale: None, min_x: Some(-max_size / 2.), max_x: Some(max_size / 2.), min_y: Some(-max_size / 2.), max_y: Some(max_size / 2.), }); let mut effects_camera = Camera2dBundle { camera: Camera { order: 2, is_active: false, ..default() }, camera_2d: Camera2d { clear_color: ClearColorConfig::None, }, ..default() }; effects_camera.projection.scale = 1.0; effects_camera.projection.scaling_mode = ScalingMode::FixedVertical(1.); commands.spawn(( effects_camera, EffectsCamera, RenderLayers::from_layers(&[2]), )); } ================================================ FILE: src/themes.rs ================================================ use bevy::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Resource, Debug, Serialize, Deserialize)] pub struct Theme { pub add_tab_bg: Color, pub arrow_btn_bg: Color, pub arrow_connector_size: f32, pub arrow_connector: Color, pub arrow: Color, pub bottom_panel_bg: Color, pub btn_border: Color, pub canvas_bg_color: Option, pub canvas_bg_line_color: Color, pub celebrate_btn_bg: Color, pub celebrate_btn: Color, pub drawing_pencil_btn_bg: Color, pub drawing_pencil_btn: Color, pub drawing_two_points_btn_bg: Color, pub drawing_two_points_btn: Color, pub add_text_btn_bg: Color, pub add_text_btn: Color, pub drawing_selected: Color, pub clipboard_image_bg: Color, pub code_default_lang: String, pub code_theme: String, pub color_change_1: Color, pub color_change_2: Color, pub color_change_3: Color, pub color_change_4: Color, pub color_change_5: Color, pub del_button: Color, pub doc_list_bg: Color, pub font_name: String, pub font_size: f32, pub font: Color, pub front_back_btn_bg: Color, pub inline_code: Color, pub left_panel_bg: Color, pub line_height: f32, pub link: Color, pub max_camera_space: f32, pub menu_bg: Color, pub menu_btn_bg: Color, pub menu_btn: Color, pub modal_bg: Color, pub modal_text_input_bg: Color, pub new_tab_btn_bg: Color, pub node_bg: Color, pub node_border: Color, pub node_found_color: Color, pub node_height: f32, pub node_manipulation_bg: Color, pub node_manipulation: Color, pub node_shadow: Color, pub node_width: f32, pub ok_cancel_bg: Color, pub paper_node_bg: Color, pub resize_marker_size: f32, pub search_box_bg: Color, pub search_box_border: Color, pub selected_node_border: Color, pub shadow: Color, pub tab_bg: Color, pub text_pos_btn_bg: Color, pub visibility_btn_bg: Color, pub tooltip_bg: Color, pub color_none: Color, } pub fn velo_light() -> Theme { Theme { add_tab_bg: Color::rgb(1., 193.0 / 255.0, 7.0 / 255.0), arrow_btn_bg: Color::rgb(207.0 / 255.0, 216.0 / 255.0, 220.0 / 255.0), arrow_connector_size: 5.0, arrow_connector: Color::NONE, arrow: Color::rgb(63.0 / 255.0, 81.0 / 255.0, 181.0 / 255.0), bottom_panel_bg: Color::rgb(189.0 / 255.0, 189.0 / 255.0, 189.0 / 255.0), btn_border: Color::rgb(0.5, 0.5, 0.5), canvas_bg_color: None, canvas_bg_line_color: Color::rgba(97. / 255., 164. / 255., 1., 0.2), celebrate_btn_bg: Color::rgb(0.9, 0.9, 0.9), celebrate_btn: Color::RED, drawing_pencil_btn_bg: Color::rgb(0.9, 0.9, 0.9), drawing_pencil_btn: Color::RED, drawing_two_points_btn_bg: Color::rgb(0.9, 0.9, 0.9), drawing_two_points_btn: Color::GRAY.with_a(0.9), add_text_btn_bg: Color::rgb(0.9, 0.9, 0.9), add_text_btn: Color::GRAY.with_a(0.9), clipboard_image_bg: Color::WHITE, code_default_lang: "rs".to_string(), code_theme: "Solarized (light)".to_string(), color_change_1: Color::BLACK, color_change_2: Color::rgb(215.0 / 255.0, 204.0 / 255.0, 200.0 / 255.0), color_change_3: Color::rgb(173.0 / 255.0, 216.0 / 255.0, 230.0 / 255.0), color_change_4: Color::rgb(239., 68.0 / 255.0, 68.0 / 255.0), color_change_5: Color::rgb(34.0 / 255.0, 197.0 / 255.0, 94.0 / 255.0), del_button: Color::BLACK, doc_list_bg: Color::rgb(158., 158., 158.), font_name: "Victor Mono".to_string(), font_size: 14., font: Color::rgb(0.0, 0.0, 0.0), front_back_btn_bg: Color::rgb(207.0 / 255.0, 216.0 / 255.0, 220.0 / 255.0), inline_code: Color::GRAY, left_panel_bg: Color::rgb(224.0 / 255.0, 224.0 / 255.0, 224.0 / 255.0), line_height: 18., link: Color::BLUE, menu_bg: Color::rgb(245.0 / 255.0, 245.0 / 255.0, 245.0 / 255.0), menu_btn_bg: Color::rgb(0.0 / 255.0, 150.0 / 255.0, 136.0 / 255.0), menu_btn: Color::BLACK, modal_bg: Color::WHITE, modal_text_input_bg: Color::WHITE, new_tab_btn_bg: Color::rgb(189.0 / 255.0, 189.0 / 255.0, 189.0 / 255.0), node_bg: Color::WHITE, node_border: Color::BLACK.with_a(0.8), node_found_color: Color::TEAL, node_height: 144., node_manipulation_bg: Color::rgb(207.0 / 255.0, 216.0 / 255.0, 220.0 / 255.0), node_manipulation: Color::BLACK, node_shadow: Color::BLACK.with_a(0.5), node_width: 144., ok_cancel_bg: Color::rgb(245.0 / 255.0, 245.0 / 255.0, 245.0 / 255.0), paper_node_bg: Color::rgb(1., 236. / 255., 172. / 255.), resize_marker_size: 10., search_box_bg: Color::WHITE, search_box_border: Color::GRAY.with_a(0.5), selected_node_border: Color::rgba(33.0 / 255.0, 150.0 / 255.0, 243.0 / 255.0, 1.0), shadow: Color::BLACK.with_a(0.5), tab_bg: Color::rgb(0.9, 0.9, 0.9), text_pos_btn_bg: Color::rgb(207.0 / 255.0, 216.0 / 255.0, 220.0 / 255.0), tooltip_bg: Color::rgb(1., 1., 1.), max_camera_space: 1_000_000_000_000_000_000., drawing_selected: Color::BLUE, color_none: Color::NONE, visibility_btn_bg: Color::rgb(207.0 / 255.0, 216.0 / 255.0, 220.0 / 255.0), } } pub fn velo_dark() -> Theme { Theme { add_tab_bg: Color::rgb(0.2, 0.2, 0.2), arrow_btn_bg: Color::rgb(0.9, 0.9, 0.9), arrow_connector_size: 5.0, arrow_connector: Color::NONE, arrow: Color::rgb(0.8, 0.8, 0.8), bottom_panel_bg: Color::rgb(0.1, 0.1, 0.1), btn_border: Color::rgb(0.8, 0.8, 0.8), canvas_bg_color: Some(Color::rgb(0.3, 0.3, 0.3)), canvas_bg_line_color: Color::rgba(1., 0. / 255., 1., 0.2), celebrate_btn_bg: Color::GRAY, celebrate_btn: Color::RED, drawing_pencil_btn_bg: Color::GRAY, drawing_pencil_btn: Color::RED, drawing_two_points_btn_bg: Color::GRAY, drawing_two_points_btn: Color::BLUE, add_text_btn_bg: Color::GRAY, add_text_btn: Color::BLUE, clipboard_image_bg: Color::BLACK, code_default_lang: "rs".to_string(), code_theme: "base16-ocean.dark".to_string(), color_change_1: Color::BLUE, color_change_2: Color::rgb(215.0 / 255.0, 204.0 / 255.0, 200.0 / 255.0), color_change_3: Color::rgb(173.0 / 255.0, 216.0 / 255.0, 230.0 / 255.0), color_change_4: Color::rgb(239., 68.0 / 255.0, 68.0 / 255.0), color_change_5: Color::rgb(34.0 / 255.0, 197.0 / 255.0, 94.0 / 255.0), del_button: Color::WHITE, doc_list_bg: Color::rgb(0.2, 0.2, 0.2), font_name: "Source Code Pro".to_string(), font_size: 14., font: Color::rgb(240. / 255.0, 240. / 255.0, 240. / 255.0), front_back_btn_bg: Color::rgb(0.9, 0.9, 0.9), inline_code: Color::WHITE, left_panel_bg: Color::GRAY, line_height: 18., link: Color::BLUE, menu_bg: Color::GRAY, menu_btn_bg: Color::rgb(0.2, 0.2, 0.2), menu_btn: Color::rgb(240. / 255.0, 240. / 255.0, 240. / 255.0), modal_bg: Color::rgb(0.2, 0.2, 0.2), modal_text_input_bg: Color::rgb(0.2, 0.2, 0.2), new_tab_btn_bg: Color::rgb(0.2, 0.2, 0.2), node_bg: Color::DARK_GRAY, node_border: Color::rgb(0.3, 0.3, 0.3), node_found_color: Color::TEAL, node_height: 144., node_manipulation_bg: Color::rgb(0.2, 0.2, 0.2), node_manipulation: Color::rgb(240. / 255.0, 240. / 255.0, 240. / 255.0), node_shadow: Color::BLUE.with_a(0.5), node_width: 144., ok_cancel_bg: Color::rgb(0.2, 0.2, 0.2), paper_node_bg: Color::rgb(33. / 255., 33. / 255., 70. / 255.), resize_marker_size: 10., search_box_bg: Color::rgb(0.25, 0.25, 0.25), search_box_border: Color::rgb(0.2, 0.2, 0.2), selected_node_border: Color::BLUE, shadow: Color::BLACK.with_a(0.5), tab_bg: Color::rgb(0.2, 0.2, 0.2), text_pos_btn_bg: Color::rgb(0.9, 0.9, 0.9), tooltip_bg: Color::rgb(0.2, 0.2, 0.2), max_camera_space: 1_000_000_000_000_000_000., drawing_selected: Color::BLUE, color_none: Color::NONE, visibility_btn_bg: Color::rgb(0.9, 0.9, 0.9), } } pub fn get_theme_by_name(theme_name: &str) -> Theme { match theme_name { "light" => velo_light(), "dark" => velo_dark(), _ => velo_light(), } } ================================================ FILE: src/ui_plugin/mod.rs ================================================ use async_channel::{Receiver, Sender}; use bevy::prelude::*; use serde::{Deserialize, Serialize}; use crate::resources::AppState; use crate::canvas::arrow::components::{ArrowConnect, ArrowType}; use crate::canvas::arrow::events::{CreateArrow, RedrawArrow}; use crate::utils::ReflectableUuid; use std::path::PathBuf; use uuid::Uuid; #[path = "ui_helpers/ui_helpers.rs"] pub mod ui_helpers; pub use ui_helpers::*; #[path = "systems/save.rs"] mod save_systems; use save_systems::*; #[path = "systems/load.rs"] mod load_systems; use load_systems::*; #[path = "systems/keyboard.rs"] mod keyboard_systems; use keyboard_systems::*; #[path = "systems/modal.rs"] mod modal; use modal::*; #[path = "systems/init_layout/init_layout.rs"] mod init_layout; use init_layout::*; #[path = "systems/resize_node.rs"] mod resize_node; use resize_node::*; #[path = "systems/resize_window.rs"] mod resize_window; use resize_window::*; #[path = "systems/button_handlers.rs"] mod button_handlers; use button_handlers::*; #[path = "systems/tabs.rs"] mod tabs; use tabs::*; #[path = "systems/doc_list.rs"] mod doc_list; use doc_list::*; #[path = "systems/clickable_links.rs"] mod clickable_links; use clickable_links::*; #[path = "systems/interactive_sprites.rs"] mod interactive_sprites; use interactive_sprites::*; #[path = "systems/entity_to_edit_changed.rs"] mod entity_to_edit_changed; use entity_to_edit_changed::*; #[path = "systems/set_focused_entity.rs"] mod set_focused_entity; use set_focused_entity::*; #[path = "systems/update_rectangle_position.rs"] mod update_rectangle_position; use update_rectangle_position::*; #[path = "systems/create_new_node.rs"] mod create_new_node; use create_new_node::*; #[cfg(not(target_arch = "wasm32"))] #[path = "systems/search.rs"] #[cfg(not(target_arch = "wasm32"))] mod search; #[cfg(not(target_arch = "wasm32"))] pub use search::*; #[path = "systems/canvas_click.rs"] mod canvas_click; use canvas_click::*; #[path = "systems/effects.rs"] #[cfg(not(target_arch = "wasm32"))] mod effects; #[cfg(not(target_arch = "wasm32"))] pub use effects::*; #[path = "systems/drawing.rs"] mod drawing; use drawing::*; #[path = "systems/active_editor_changed.rs"] mod active_editor_changed; use active_editor_changed::*; pub struct UiPlugin; #[derive(Event, Default)] pub struct AddRect { pub node: JsonNode, pub image: Option>, } #[derive(Event)] pub struct SaveStore { pub doc_id: ReflectableUuid, pub path: Option, // Save current document to file } #[derive(Debug, PartialEq, Eq)] pub enum NodeInteractionType { Hover, LeftClick, LeftDoubleClick, LeftMouseRelease, LeftMouseHoldAndDrag, RightClick, } #[derive(Event, Debug)] pub struct NodeInteraction { pub entity: Entity, pub node_interaction_type: NodeInteractionType, } #[derive(Event)] pub struct UpdateDeleteDocBtn; #[derive(Resource, Clone)] pub struct CommChannels { pub tx: Sender, pub rx: Receiver, } #[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Reflect, Default, Debug)] pub enum NodeType { #[default] Rect, Paper, Circle, } #[derive(Serialize, Deserialize, Clone, Default)] pub enum TextPos { #[default] Center, TopLeft, } #[derive(Serialize, Deserialize, Default)] pub struct JsonNodeText { pub text: String, pub pos: TextPos, } #[derive(Serialize, Deserialize, Default)] pub struct JsonNode { pub id: Uuid, pub node_type: NodeType, pub x: f32, pub y: f32, pub z: f32, pub width: f32, pub height: f32, pub text: JsonNodeText, pub bg_color: T, pub visible: bool, } #[derive(Serialize, Deserialize)] pub struct DrawingJsonNode { pub x: f32, pub y: f32, pub z: f32, pub id: ReflectableUuid, pub points: Vec, pub drawing_color: T, pub width: f32, } pub const MAX_CHECKPOINTS: i32 = 7; pub const MAX_SAVED_DOCS_IN_MEMORY: i32 = 7; #[derive(Resource, Default)] pub struct UiState { pub modal_id: Option, pub entity_to_edit: Option, pub tab_to_edit: Option, pub doc_to_edit: Option, pub search_box_to_edit: Option, pub arrow_type: ArrowType, pub hold_entity: Option, pub entity_to_resize: Option, pub entity_to_draw: Option, pub entity_to_draw_selected: Option, pub entity_to_draw_hold: Option, pub draw_color_pair: Option<(String, Color)>, pub arrow_to_draw_start: Option, pub drawing_mode: bool, pub drawing_two_points_mode: Option, } impl Plugin for UiPlugin { fn build(&self, app: &mut App) { app.init_resource::(); app.init_resource::(); app.add_event::>(); app.add_event::(); app.add_event::(); app.add_event::(); app.add_event::(); app.add_event::(); #[cfg(not(target_arch = "wasm32"))] app.add_systems( Startup, (read_native_config, init_search_index).before(init_layout), ); #[cfg(target_arch = "wasm32")] app.add_systems(Startup, load_from_url.before(init_layout)); app.add_systems(Startup, init_layout); app.add_systems( Update, ( rec_button_handlers, update_rectangle_position, create_new_node, resize_entity_start, resize_entity_run, resize_entity_end, cancel_modal, confirm_modal, ), ); app.add_systems( Update, (save_doc, remove_save_doc_request) .chain() .distributive_run_if(should_save_doc), ); app.add_systems( Update, (save_tab, remove_save_tab_request) .chain() .distributive_run_if(should_save_tab), ); app.add_systems( Update, (load_doc, remove_load_doc_request) .chain() .distributive_run_if(should_load_doc), ); app.add_systems( Update, (load_tab, remove_load_tab_request) .chain() .distributive_run_if(should_load_tab), ); app.add_systems( Update, ( change_color_pallete, change_arrow_type, change_text_pos, add_tab_handler, delete_tab_handler, rename_tab_handler, mouse_scroll_list, list_item_click, new_doc_handler, rename_doc_handler, delete_doc_handler, save_doc_handler, keyboard_input_system.before(bevy_cosmic_edit::cosmic_edit_bevy_events), ), ); app.add_systems( Update, (doc_list_del_button_update, doc_list_ui_changed).chain(), ); #[cfg(not(target_arch = "wasm32"))] app.add_systems(Update, (search_box_click, search_box_text_changed)); app.add_systems( Update, ( button_generic_handler, select_tab_handler, export_to_file, import_from_file, import_from_url, load_doc_handler, #[cfg(target_arch = "wasm32")] set_window_property, shared_doc_handler, #[cfg(not(target_arch = "wasm32"))] create_particles_effect, #[cfg(not(target_arch = "wasm32"))] update_particles_effect, save_to_store.after(save_tab), canvas_click, active_editor_changed, interactive_node.before(canvas_click), change_theme, enable_drawing_mode, drawing, update_drawing_position, ), ); app.add_systems( Update, (drawing_two_points, enable_two_points_draw_mode).chain(), ); app.add_systems( Update, (set_focus_drawing, entity_to_draw_selected_changed).chain(), ); app.add_systems(Update, (set_focused_entity, clickable_links).chain()); app.add_systems( Update, entity_to_edit_changed .before(save_tab) .before(save_doc) .before(load_tab) .before(load_doc) .before(rec_button_handlers) .before(create_new_node), ); app.add_systems(PostUpdate, resize_notificator); } } #[cfg(target_arch = "wasm32")] fn load_from_url(mut commands: Commands) { let (tx, rx) = async_channel::bounded(1); commands.insert_resource(CommChannels { tx: tx.clone(), rx }); let href = web_sys::window().unwrap().location().href().unwrap(); let url = url::Url::parse(href.as_str()).unwrap(); let query_pairs: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect(); if let Some(url) = query_pairs.get("document") { let pool = bevy::tasks::IoTaskPool::get(); let mut finder = linkify::LinkFinder::new(); finder.kinds(&[linkify::LinkKind::Url]); let links: Vec<_> = finder.links(url).collect(); if links.len() == 1 { let url = links.first().unwrap().as_str().to_owned(); let cc = tx.clone(); let task = pool.spawn(async move { let request = ehttp::Request::get(url); ehttp::fetch(request, move |result| { let json_string = result.unwrap().text().unwrap(); cc.try_send(json_string).unwrap(); }); }); task.detach(); } } } #[cfg(not(target_arch = "wasm32"))] fn read_native_config(mut app_state: ResMut) { use crate::utils::read_config_file; let config = read_config_file().unwrap_or_default(); if let Some(github_token) = &config.github_access_token { app_state.github_token = Some(github_token.clone()); } } ================================================ FILE: src/ui_plugin/systems/active_editor_changed.rs ================================================ use bevy::prelude::*; use bevy_cosmic_edit::{ActiveEditor, CosmicEdit, CosmicFont}; use cosmic_text::{Action, Edit}; pub fn active_editor_changed( active_editor: ResMut, mut previous_editor: Local>, mut cosmic_edit_query: Query<&mut CosmicEdit, With>, mut cosmic_fonts: ResMut>, ) { if active_editor.is_changed() && active_editor.entity != *previous_editor { if let Some(editor) = active_editor.entity { if let Ok(mut cosmic_edit) = cosmic_edit_query.get_mut(editor) { let font_system = cosmic_fonts.get_mut(&cosmic_edit.font_system).unwrap(); cosmic_edit .editor .action(&mut font_system.0, Action::BufferEnd); cosmic_edit .editor .action(&mut font_system.0, Action::Escape); cosmic_edit.editor.buffer_mut().set_redraw(true); } } *previous_editor = active_editor.entity; } } ================================================ FILE: src/ui_plugin/systems/button_handlers.rs ================================================ #![allow(clippy::duplicate_mod)] use std::collections::HashMap; use std::{collections::VecDeque, time::Duration}; use bevy::sprite::collide_aabb::collide; use bevy::{prelude::*, window::PrimaryWindow}; use bevy_cosmic_edit::{CosmicEdit, CosmicEditHistory, CosmicFont}; use bevy_pkv::PkvStore; use bevy_prototype_lyon::prelude::{Fill, Stroke}; use cosmic_text::{Cursor, Edit}; use serde::Serialize; use serde_json::{json, Value}; use uuid::Uuid; use crate::themes::Theme; use crate::{AddRect, JsonNode, JsonNodeText, NodeType, UiState}; use super::ui_helpers::{ spawn_modal, ButtonAction, ChangeColor, ChangeTheme, DeleteDoc, DocListItemButton, DrawPencil, Drawing, GenericButton, NewDoc, RawText, SaveDoc, TextPosMode, Tooltip, TwoPointsDraw, VeloNode, VeloShape, }; use super::{ExportToFile, ImportFromFile, ImportFromUrl, MainPanel, ShareDoc}; use crate::canvas::arrow::components::{ArrowMeta, ArrowMode}; use crate::components::{Doc, MainCamera, Tab}; use crate::resources::{AppState, FontSystemState, LoadDocRequest, SaveDocRequest}; use crate::utils::{ bevy_color_to_cosmic, get_timestamp, load_doc_to_memory, ReflectableUuid, UserPreferences, DARK_THEME_ICON_CODE, LIGHT_THEME_ICON_CODE, }; #[path = "../../macros.rs"] #[macro_use] mod macros; pub fn rec_button_handlers( mut commands: Commands, mut events: EventWriter>, mut interaction_query: Query< (&Interaction, &ButtonAction), (Changed, With), >, mut raw_text_query: Query<(&mut CosmicEdit, &RawText, &Parent), With>, border_query: Query<&Parent, With>, mut velo_node_query: Query< (Entity, &VeloNode, &mut Transform, &mut Visibility), (With, Without), >, mut arrows: Query<(Entity, &ArrowMeta, &mut Visibility), (With, Without)>, mut drawings: Query<(Entity, &Drawing<(String, Color)>), With>>, mut ui_state: ResMut, mut app_state: ResMut, mut camera_proj_query: Query< &mut Transform, ( With, With, Without, ), >, theme: Res, ) { let mut camera_transform = camera_proj_query.single_mut(); let x = camera_transform.translation.x; let y = camera_transform.translation.y; for (interaction, button_action) in &mut interaction_query { match *interaction { Interaction::Pressed => match button_action.button_type { super::ui_helpers::ButtonTypes::AddRec => { events.send(AddRect { node: JsonNode { id: Uuid::new_v4(), node_type: NodeType::Rect, x, y, width: theme.node_width, height: theme.node_height, text: JsonNodeText { text: "".to_string(), pos: crate::TextPos::Center, }, bg_color: pair_struct!(theme.node_bg), ..default() }, image: None, }); } super::ui_helpers::ButtonTypes::AddCircle => { events.send(AddRect { node: JsonNode { id: Uuid::new_v4(), node_type: NodeType::Circle, x, y, width: theme.node_width, height: theme.node_height, text: JsonNodeText { text: "".to_string(), pos: crate::TextPos::Center, }, bg_color: pair_struct!(theme.node_bg), ..default() }, image: None, }); } super::ui_helpers::ButtonTypes::AddPaper => { events.send(AddRect { node: JsonNode { id: Uuid::new_v4(), node_type: NodeType::Paper, x, y, width: theme.node_width, height: theme.node_height, text: JsonNodeText { text: "".to_string(), pos: crate::TextPos::Center, }, bg_color: pair_struct!(theme.paper_node_bg), ..default() }, image: None, }); } super::ui_helpers::ButtonTypes::Del => { if let Some(id) = ui_state.entity_to_draw_selected { ui_state.entity_to_draw_selected = None; for (entity, drawing) in &mut drawings.iter_mut() { if drawing.id == id { commands.entity(entity).despawn_recursive(); } } } if let Some(id) = ui_state.entity_to_edit { commands.insert_resource(bevy_cosmic_edit::ActiveEditor { entity: None }); *ui_state = UiState::default(); for (entity, node, _, _) in velo_node_query.iter() { if node.id == id { commands.entity(entity).despawn_recursive(); } } for (entity, arrow, _) in &mut arrows.iter_mut() { if arrow.start.id == id || arrow.end.id == id { commands.entity(entity).despawn_recursive(); } } } } super::ui_helpers::ButtonTypes::Front => { let current_document = app_state.current_document.unwrap(); let tab = app_state .docs .get_mut(¤t_document) .unwrap() .tabs .iter_mut() .find(|x| x.is_active) .unwrap(); if let Some(id) = ui_state.entity_to_edit { let mut data = None; // fint current z_index for (cosmic_edit, raw_text, parent) in &mut raw_text_query.iter_mut() { if raw_text.id == id { let border = border_query.get(parent.get()).unwrap(); let top = velo_node_query.get_mut(border.get()).unwrap(); let size = Vec2::new(cosmic_edit.width, cosmic_edit.height); let translation = top.2.translation; data = Some((size, translation)); break; } } // find higher z_index if collide for (cosmic_edit, raw_text, parent) in &mut raw_text_query.iter_mut() { if raw_text.id != id { let border = border_query.get(parent.get()).unwrap(); let top = velo_node_query.get_mut(border.get()).unwrap(); let size = Vec2::new(cosmic_edit.width, cosmic_edit.height); let translation = top.2.translation; if let Some((active_size, active_translation)) = data { if collide(translation, size, active_translation, active_size) .is_some() && translation.z > active_translation.z { data = Some((size, translation)); break; } } } } // update z_index for (_, node, mut transform, _) in velo_node_query.iter_mut() { if node.id == id { if let Some((_, translation)) = data { transform.translation.z = (translation.z + 0.03) % f32::MAX; } else { transform.translation.z = (transform.translation.z + 0.03) % f32::MAX; } if tab.z_index < transform.translation.z { tab.z_index = transform.translation.z; } break; } } } } super::ui_helpers::ButtonTypes::Back => { if let Some(id) = ui_state.entity_to_edit { let mut data = None; // fint current z_index for (cosmic_edit, raw_text, parent) in &mut raw_text_query.iter_mut() { if raw_text.id == id { let border = border_query.get(parent.get()).unwrap(); let top = velo_node_query.get_mut(border.get()).unwrap(); let size = Vec2::new(cosmic_edit.width, cosmic_edit.height); let translation = top.2.translation; data = Some((size, translation)); break; } } // find lower z_index if collide for (cosmic_edit, raw_text, parent) in &mut raw_text_query.iter_mut() { if raw_text.id != id { let border = border_query.get(parent.get()).unwrap(); let top = velo_node_query.get_mut(border.get()).unwrap(); let size = Vec2::new(cosmic_edit.width, cosmic_edit.height); let translation = top.2.translation; if let Some((active_size, active_translation)) = data { if collide(translation, size, active_translation, active_size) .is_some() && translation.z < active_translation.z { data = Some((size, translation)); break; } } } } // update z_index for (_, node, mut transform, _) in velo_node_query.iter_mut() { if node.id == id { if let Some((_, translation)) = data { transform.translation.z = f32::max(translation.z - 0.03, 1.); } else { transform.translation.z = f32::max(transform.translation.z - 0.03, 1.); } break; } } } } super::ui_helpers::ButtonTypes::AddText => { events.send(AddRect { node: JsonNode { id: Uuid::new_v4(), node_type: NodeType::Rect, x, y, width: theme.node_width, height: theme.node_height, text: JsonNodeText { text: "".to_string(), pos: crate::TextPos::Center, }, bg_color: pair_struct!(theme.color_none), ..default() }, image: None, }); } super::ui_helpers::ButtonTypes::ShowChildren => { if let Some(id) = ui_state.entity_to_edit { let mut nodes = VecDeque::new(); nodes.push_back(id); while let Some(node_id) = nodes.pop_front() { for (_, arrow_meta, mut visibility) in &mut arrows.iter_mut() { if arrow_meta.start.id == node_id { *visibility = Visibility::Visible; for (_, node, _, mut visibility) in &mut velo_node_query.iter_mut() { if node.id == arrow_meta.end.id { *visibility = Visibility::Visible; break; } } nodes.push_back(arrow_meta.end.id); } } } } } super::ui_helpers::ButtonTypes::HideChildren => { if let Some(id) = ui_state.entity_to_edit { let mut nodes = VecDeque::new(); nodes.push_back(id); while let Some(node_id) = nodes.pop_front() { for (_, arrow_meta, mut visibility) in &mut arrows.iter_mut() { if arrow_meta.start.id == node_id { *visibility = Visibility::Hidden; for (_, node, _, mut visibility) in &mut velo_node_query.iter_mut() { if node.id == arrow_meta.end.id { *visibility = Visibility::Hidden; break; } } nodes.push_back(arrow_meta.end.id); } } } } } super::ui_helpers::ButtonTypes::ShowRandom => { let mut coords = VecDeque::new(); for (_, _, transform, visibility) in velo_node_query.iter() { if *visibility == Visibility::Visible { coords.push_back(transform.translation); } } let len = coords.len(); if len == 0 { return; } let step = rand::distributions::Uniform::new(0, len); let mut rng = rand::thread_rng(); let choice = rand::prelude::Distribution::sample(&step, &mut rng); if let Some(v) = coords.get(choice) { camera_transform.translation.x = v.x; camera_transform.translation.y = v.y; } } }, Interaction::Hovered => {} Interaction::None => {} } } } pub fn change_color_pallete( mut interaction_query: Query< (&Interaction, &ChangeColor), (Changed, With), >, mut velo_border: Query<(&mut Fill, &mut Stroke, &mut VeloShape), With>, mut ui_state: ResMut, ) { for (interaction, change_color) in &mut interaction_query { match *interaction { Interaction::Pressed => { let pair_color = change_color.pair_color.clone(); for (mut fill, mut stroke, mut velo_border) in velo_border.iter_mut() { if Some(velo_border.id) == ui_state.entity_to_edit { fill.color = pair_color.1; if fill.color == Color::NONE { stroke.color = Color::NONE; } velo_border.pair_color = pair_color; return; } } ui_state.draw_color_pair = Some(pair_color); } Interaction::Hovered => {} Interaction::None => {} } } } pub fn change_text_pos( mut interaction_query: Query< (&Interaction, &TextPosMode), (Changed, With), >, state: Res, mut raw_text_node_query: Query<(&RawText, &mut CosmicEdit), With>, ) { for (interaction, text_pos_mode) in &mut interaction_query { match *interaction { Interaction::Pressed => { if let Some(entity_to_edit) = state.entity_to_edit { for (raw_text, mut cosmit_edit) in raw_text_node_query.iter_mut() { if raw_text.id == entity_to_edit { cosmit_edit.text_pos = text_pos_mode.text_pos.clone().into(); cosmit_edit.editor.buffer_mut().set_redraw(true); } } } } Interaction::Hovered => {} Interaction::None => {} } } } pub fn change_arrow_type( mut interaction_query: Query< (&Interaction, &ArrowMode), (Changed, With), >, mut state: ResMut, ) { for (interaction, arrow_mode) in &mut interaction_query { match *interaction { Interaction::Pressed => { state.arrow_type = arrow_mode.arrow_type; } Interaction::Hovered => {} Interaction::None => {} } } } pub fn new_doc_handler( mut commands: Commands, mut new_doc_query: Query<&Interaction, (Changed, With)>, mut app_state: ResMut, ) { for interaction in &mut new_doc_query.iter_mut() { match *interaction { Interaction::Pressed => { let doc_id = ReflectableUuid::generate(); let name = "Untitled".to_string(); let tab_id = ReflectableUuid::generate(); let mut checkpoints = VecDeque::new(); checkpoints.push_back( json!({ "nodes": [], "arrows": [], "images": {}, "drawings": [] }) .to_string(), ); let tabs = vec![Tab { id: tab_id, name: "Tab 1".to_string(), checkpoints, is_active: true, z_index: 1., }]; app_state.docs.insert( doc_id, Doc { id: doc_id, name: name.clone(), tabs, tags: vec![], }, ); commands.insert_resource(SaveDocRequest { doc_id: app_state.current_document.unwrap(), path: None, }); app_state.current_document = Some(doc_id); commands.insert_resource(LoadDocRequest { doc_id }); app_state.doc_list_ui.insert(doc_id); } Interaction::Hovered => {} Interaction::None => {} } } } pub fn rename_doc_handler( mut commands: Commands, mut rename_doc_query: Query< ( &Interaction, &DocListItemButton, Entity, &mut CosmicEdit, &mut CosmicEditHistory, ), (Changed, With), >, mut ui_state: ResMut, mut double_click: Local<(Duration, Option)>, theme: Res, ) { for (interaction, item, entity, mut cosmic_edit, mut _cosmic_edit_history) in &mut rename_doc_query.iter_mut() { match *interaction { Interaction::Pressed => { let now_ms = get_timestamp(); if double_click.1 == Some(item.id) && Duration::from_millis(now_ms as u64) - double_click.0 < Duration::from_millis(500) { *ui_state = UiState::default(); commands.insert_resource(bevy_cosmic_edit::ActiveEditor { entity: Some(entity), }); cosmic_edit.readonly = false; let current_cursor = cosmic_edit.editor.cursor(); let new_cursor = Cursor::new_with_color( current_cursor.line, current_cursor.index, bevy_color_to_cosmic(theme.font), ); cosmic_edit.editor.set_cursor(new_cursor); ui_state.doc_to_edit = Some(item.id); *double_click = (Duration::from_secs(0), None); } else { *double_click = (Duration::from_millis(now_ms as u64), Some(item.id)); } } Interaction::Hovered => {} Interaction::None => {} } } } pub fn delete_doc_handler( mut commands: Commands, mut delete_doc_query: Query<&Interaction, (Changed, With)>, mut ui_state: ResMut, mut app_state: ResMut, main_panel_query: Query>, windows: Query<&Window, With>, pkv: Res, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, ) { let window = windows.single(); for interaction in &mut delete_doc_query.iter_mut() { match *interaction { Interaction::Pressed => { if app_state.docs.len() == 1 { if let Ok(docs) = pkv.get::>("docs") { if docs.len() > 1 { for (id, doc) in docs.iter() { if app_state.docs.len() != 1 { break; } app_state.docs.insert(*id, doc.clone()); } } else { // do not allow deletion if there is less than two docs return; } } else { // do not allow deletion if there is less than two docs return; } } let id = ReflectableUuid::generate(); *ui_state = UiState::default(); commands.insert_resource(bevy_cosmic_edit::ActiveEditor { entity: None }); ui_state.modal_id = Some(id); let entity = spawn_modal( &mut commands, &theme, &mut cosmic_fonts, font_system_state.0.clone().unwrap(), window, id, super::ModalAction::DeleteDocument, ); commands.entity(main_panel_query.single()).add_child(entity); } Interaction::Hovered => {} Interaction::None => {} } } } pub fn save_doc_handler( mut commands: Commands, mut save_doc_query: Query<&Interaction, (Changed, With)>, state: Res, ) { for interaction in &mut save_doc_query.iter_mut() { match *interaction { Interaction::Pressed => { commands.insert_resource(SaveDocRequest { doc_id: state.current_document.unwrap(), path: None, }); } Interaction::Hovered => {} Interaction::None => {} } } } pub fn export_to_file( mut commands: Commands, mut query: Query<&Interaction, (Changed, With)>, mut ui_state: ResMut, main_panel_query: Query>, windows: Query<&Window, With>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, ) { let window = windows.single(); for interaction in &mut query.iter_mut() { match *interaction { Interaction::Pressed => { let id = ReflectableUuid::generate(); *ui_state = UiState::default(); commands.insert_resource(bevy_cosmic_edit::ActiveEditor { entity: None }); ui_state.modal_id = Some(id); let entity = spawn_modal( &mut commands, &theme, &mut cosmic_fonts, font_system_state.0.clone().unwrap(), window, id, super::ModalAction::SaveToFile, ); commands.entity(main_panel_query.single()).add_child(entity); } Interaction::Hovered => {} Interaction::None => {} } } } #[cfg(target_arch = "wasm32")] pub fn set_window_property(mut app_state: ResMut, mut pkv: ResMut) { if let Some(doc_id) = app_state.current_document { load_doc_to_memory(doc_id, &mut app_state, &mut pkv); let current_doc = app_state.docs.get(&doc_id).unwrap().clone(); let value = serde_json::to_string_pretty(¤t_doc).unwrap(); let window = wasm_bindgen::JsValue::from(web_sys::window().unwrap()); let velo_var = wasm_bindgen::JsValue::from("velo"); let state = wasm_bindgen::JsValue::from(value); js_sys::Reflect::set(&window, &velo_var, &state).unwrap(); } } #[derive(Serialize)] struct GistFile { content: String, } #[derive(Serialize)] struct GistCreateRequest { description: String, public: bool, files: std::collections::HashMap, } pub fn shared_doc_handler( mut app_state: ResMut, mut query: Query<&Interaction, (Changed, With)>, mut pkv: ResMut, ) { for interaction in &mut query.iter_mut() { match *interaction { Interaction::Pressed => { if let Some(doc_id) = app_state.current_document { load_doc_to_memory(doc_id, &mut app_state, &mut pkv); let current_doc = app_state.docs.get(&doc_id).unwrap().clone(); let contents = serde_json::to_string_pretty(¤t_doc).unwrap(); let mut files = std::collections::HashMap::new(); let filename = "velo.json"; let file = GistFile { content: contents.to_string(), }; files.insert(filename.to_string(), file); let request = GistCreateRequest { description: "Velo Document".to_string(), public: true, files, }; let mut request = ehttp::Request::post( "https://api.github.com/gists", serde_json::to_string_pretty(&request).unwrap(), ); request.headers.insert( "Accept".to_string(), "application/vnd.github.v3+json".to_string(), ); request.headers.insert( "Authorization".to_string(), format!("token {}", app_state.github_token.as_ref().unwrap()), ); #[cfg(not(target_arch = "wasm32"))] let mut clipboard = arboard::Clipboard::new().unwrap(); ehttp::fetch(request, move |result| { let response = result.unwrap(); if response.ok { let res_json: Value = serde_json::from_str(response.text().unwrap().as_str()).unwrap(); let files: Value = res_json["files"].clone(); let velo = files["velo.json"].clone(); #[cfg(not(target_arch = "wasm32"))] clipboard .set_text(format!( "https://staffengineer.github.io/velo?document={}", velo["raw_url"].to_string().replace('\"', "") )) .unwrap(); } else { error!("Error sharing document: {}", response.status_text); } }); } } Interaction::Hovered => {} Interaction::None => {} } } } pub fn import_from_file( mut commands: Commands, mut query: Query<&Interaction, (Changed, With)>, mut ui_state: ResMut, main_panel_query: Query>, windows: Query<&Window, With>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, ) { let window = windows.single(); for interaction in &mut query.iter_mut() { match *interaction { Interaction::Pressed => { let id = ReflectableUuid::generate(); *ui_state = UiState::default(); commands.insert_resource(bevy_cosmic_edit::ActiveEditor { entity: None }); ui_state.modal_id = Some(id); let entity = spawn_modal( &mut commands, &theme, &mut cosmic_fonts, font_system_state.0.clone().unwrap(), window, id, super::ModalAction::LoadFromFile, ); commands.entity(main_panel_query.single()).add_child(entity); } Interaction::Hovered => {} Interaction::None => {} } } } pub fn import_from_url( mut commands: Commands, mut query: Query<&Interaction, (Changed, With)>, mut ui_state: ResMut, main_panel_query: Query>, windows: Query<&Window, With>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, ) { let window = windows.single(); for interaction in &mut query.iter_mut() { match *interaction { Interaction::Pressed => { let id = ReflectableUuid::generate(); *ui_state = UiState::default(); commands.insert_resource(bevy_cosmic_edit::ActiveEditor { entity: None }); ui_state.modal_id = Some(id); let entity = spawn_modal( &mut commands, &theme, &mut cosmic_fonts, font_system_state.0.clone().unwrap(), window, id, super::ModalAction::LoadFromUrl, ); commands.entity(main_panel_query.single()).add_child(entity); } Interaction::Hovered => {} Interaction::None => {} } } } pub fn button_generic_handler( mut generic_button_query: Query< (&Interaction, Entity), (Changed, With), >, mut windows: Query<&mut Window, With>, mut tooltips_query: Query<(&mut Style, &Parent), With>, ) { let mut primary_window = windows.single_mut(); for (interaction, entity) in &mut generic_button_query.iter_mut() { match *interaction { Interaction::Pressed => {} Interaction::Hovered => { primary_window.cursor.icon = CursorIcon::Hand; for (mut style, parent) in tooltips_query.iter_mut() { if parent.get() == entity { style.display = Display::Flex; } } } Interaction::None => { primary_window.cursor.icon = CursorIcon::Default; for (mut style, parent) in tooltips_query.iter_mut() { if parent.get() == entity { style.display = Display::None; } } } } } } pub fn enable_drawing_mode( mut query: Query<(&Interaction, &Children), (Changed, With)>, mut text_style_query: Query<&mut Text, With>, mut ui_state: ResMut, ) { for (interaction, children) in &mut query.iter_mut() { match *interaction { Interaction::Pressed => { ui_state.drawing_mode = !ui_state.drawing_mode; for child in children.iter() { if let Ok(mut text) = text_style_query.get_mut(*child) { if ui_state.drawing_mode { text.sections[0].style.color = text.sections[0].style.color.with_a(1.) } else { text.sections[0].style.color = text.sections[0].style.color.with_a(0.5) } } } } Interaction::Hovered => {} Interaction::None => {} } } } pub fn enable_two_points_draw_mode( mut query: Query< (&Interaction, &Children, &TwoPointsDraw), (Changed, With), >, mut text_style_query: Query<&mut Text, With>, mut ui_state: ResMut, ) { for (interaction, children, two_point_draw) in &mut query.iter_mut() { match *interaction { Interaction::Pressed => { let two_points_draw_type = two_point_draw.drawing_type.clone(); if ui_state.drawing_two_points_mode == Some(two_points_draw_type.clone()) { ui_state.drawing_two_points_mode = None; } else { ui_state.drawing_two_points_mode = Some(two_points_draw_type.clone()); } for mut text in text_style_query.iter_mut() { text.sections[0].style.color = text.sections[0].style.color.with_a(0.5) } for child in children.iter() { if text_style_query.get_mut(*child).is_ok() && ui_state.drawing_two_points_mode.is_some() { let mut text = text_style_query.get_mut(*child).unwrap(); text.sections[0].style.color = text.sections[0].style.color.with_a(1.); } } } Interaction::Hovered => {} Interaction::None => {} } } } pub fn change_theme( mut pkv: ResMut, mut change_theme_button: Query<&Interaction, (Changed, With)>, mut change_theme_label: Query<&mut Text, (With, Without)>, mut tooltip_label: Query<&mut Text, (With, Without)>, ) { for interaction in &mut change_theme_button.iter_mut() { match *interaction { Interaction::Pressed => { for mut text in &mut change_theme_label.iter_mut() { let icon_code = text.sections[0].value.clone(); if icon_code == DARK_THEME_ICON_CODE { for mut tooltip in &mut tooltip_label.iter_mut() { if tooltip.sections[0].value == "Enable dark theme (restart is required for now)" { tooltip.sections[0].value = "Enable light theme (restart is required for now)".to_string(); break; } } text.sections[0].value = LIGHT_THEME_ICON_CODE.to_string(); let _ = pkv.set( "user_preferences", &UserPreferences { theme_name: Some("dark".to_string()), }, ); } if icon_code == LIGHT_THEME_ICON_CODE { for mut tooltip in &mut tooltip_label.iter_mut() { if tooltip.sections[0].value == "Enable light theme (restart is required for now)" { tooltip.sections[0].value = "Enable dark theme (restart is required for now)".to_string(); break; } } text.sections[0].value = DARK_THEME_ICON_CODE.to_string(); let _ = pkv.set( "user_preferences", &UserPreferences { theme_name: Some("light".to_string()), }, ); } } } Interaction::Hovered => {} Interaction::None => {} } } } ================================================ FILE: src/ui_plugin/systems/canvas_click.rs ================================================ use bevy::{prelude::*, window::PrimaryWindow}; use super::{ ui_helpers::{MainPanel, RawText}, NodeInteraction, UiState, }; pub fn canvas_click( interaction_query: Query<&Interaction, (Changed, With)>, mut ui_state: ResMut, mut windows: Query<&mut Window, With>, mut node_interaction_events: EventReader, raw_text: Query>, ) { let mut primary_window = windows.single_mut(); for interaction in interaction_query.iter() { if *interaction == Interaction::Pressed { for event in node_interaction_events.iter() { if raw_text.get(event.entity).is_ok() { return; } } ui_state.entity_to_edit = None; } if *interaction == Interaction::Hovered { primary_window.cursor.icon = CursorIcon::default(); } } } ================================================ FILE: src/ui_plugin/systems/clickable_links.rs ================================================ use bevy::{prelude::*, window::PrimaryWindow}; use bevy_cosmic_edit::{get_node_cursor_pos, get_x_offset, get_y_offset, CosmicEdit}; use cosmic_text::Edit; use crate::components::MainCamera; use super::{ui_helpers::BevyMarkdownView, NodeInteraction, NodeInteractionType, UiState}; pub fn clickable_links( mut windows: Query<&mut Window, With>, mut markdown_text_query: Query< (&GlobalTransform, &mut CosmicEdit, &BevyMarkdownView), With, >, mut node_interaction_events: EventReader, ui_state: Res, camera_q: Query<(&Camera, &GlobalTransform), With>, ) { if ui_state.hold_entity.is_some() { return; } let primary_window = windows.iter_mut().next().unwrap(); let scale_factor = primary_window.scale_factor() as f32; let (camera, camera_transform) = camera_q.single(); for event in node_interaction_events.iter() { if let Ok((transform, cosmic_edit, bevy_markdown_view)) = markdown_text_query.get_mut(event.entity) { if event.node_interaction_type == NodeInteractionType::LeftClick { if !cosmic_edit.readonly { return; } let size = (cosmic_edit.width, cosmic_edit.height); if let Some(pos) = get_node_cursor_pos( &primary_window, transform, size, cosmic_edit.is_ui_node, camera, camera_transform, ) { let font_size = cosmic_edit.editor.buffer().metrics().font_size; let line_height = cosmic_edit.editor.buffer().metrics().line_height; let y_start = get_y_offset(cosmic_edit.editor.buffer()) as f32; let x_start = get_x_offset(cosmic_edit.editor.buffer()) as f32; for layout_runs in cosmic_edit.editor.buffer().layout_runs() { let line_offset = (y_start + (layout_runs.line_y - font_size)) / scale_factor; if pos.1 < (line_offset + line_height / scale_factor) && pos.1 > line_offset { for glyph in layout_runs.glyphs { let start = (x_start + glyph.x) / scale_factor; let end = (x_start + glyph.x + glyph.w) / scale_factor; if pos.0 > start && pos.0 < end { let idx = glyph.metadata; if let Some(text_span) = bevy_markdown_view.span_metadata.get(idx) { if let Some(link) = text_span.link.clone() { #[cfg(not(target_arch = "wasm32"))] open::that(link.clone()).unwrap(); #[cfg(target_arch = "wasm32")] open_url_in_new_tab(link.clone().as_str()).unwrap(); } } } } } } } } } } } #[cfg(target_arch = "wasm32")] pub fn open_url_in_new_tab(url: &str) -> Result<(), wasm_bindgen::prelude::JsValue> { use wasm_bindgen::prelude::*; use web_sys::window; let window = window().ok_or_else(|| JsValue::from_str("Failed to get window object"))?; let new_window: Option = window.open_with_url_and_target(url, "_blank")?; new_window.unwrap().focus()?; Ok(()) } ================================================ FILE: src/ui_plugin/systems/create_new_node.rs ================================================ use bevy::{prelude::*, window::PrimaryWindow}; use bevy_cosmic_edit::CosmicFont; use crate::{ canvas::shadows::CustomShadowMaterial, resources::{AppState, FontSystemState}, themes::Theme, utils::ReflectableUuid, }; use super::{ui_helpers::spawn_sprite_node, AddRect, NodeMeta, UiState}; pub fn create_new_node( mut commands: Commands, mut events: EventReader>, mut ui_state: ResMut, mut app_state: ResMut, mut windows: Query<&mut Window, With>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, mut z_index_local: Local, mut materials: ResMut>, mut meshes: ResMut>, ) { let window = windows.single_mut(); for event in events.iter() { let current_document = app_state.current_document.unwrap(); let tab = app_state .docs .get_mut(¤t_document) .unwrap() .tabs .iter_mut() .find(|x| x.is_active) .unwrap(); *z_index_local += 0.01 % f32::MAX; tab.z_index += *z_index_local; *ui_state = UiState::default(); ui_state.entity_to_edit = Some(ReflectableUuid(event.node.id)); let _ = spawn_sprite_node( &mut commands, &mut materials, &mut meshes, &theme, &mut cosmic_fonts, font_system_state.0.clone().unwrap(), window.scale_factor() as f32, NodeMeta { id: ReflectableUuid(event.node.id), size: (event.node.width, event.node.height), node_type: event.node.node_type.clone(), image: event.image.clone(), text: event.node.text.text.clone(), pair_bg_color: event.node.bg_color.clone(), position: (event.node.x, event.node.y, tab.z_index), text_pos: event.node.text.pos.clone(), is_active: true, visible: true, }, ); } } ================================================ FILE: src/ui_plugin/systems/doc_list.rs ================================================ use bevy::{ input::mouse::{MouseScrollUnit, MouseWheel}, prelude::*, window::PrimaryWindow, }; use bevy_cosmic_edit::CosmicFont; use super::ui_helpers::ScrollingList; use crate::{resources::FontSystemState, themes::Theme, ui_plugin::ui_helpers::DocListItemButton}; use crate::resources::{AppState, LoadDocRequest, SaveDocRequest}; use std::collections::{HashMap, HashSet}; use bevy_pkv::PkvStore; use crate::{ui_plugin::ui_helpers::add_list_item, utils::ReflectableUuid}; use super::{ ui_helpers::{DeleteDoc, DocList, DocListItemContainer}, UpdateDeleteDocBtn, }; pub fn list_item_click( mut interaction_query: Query< (&Interaction, &DocListItemButton), (Changed, With), >, mut state: ResMut, mut commands: Commands, ) { for (interaction, doc_list_item) in &mut interaction_query.iter_mut() { match *interaction { Interaction::Pressed => { if Some(doc_list_item.id) != state.current_document { commands.insert_resource(SaveDocRequest { doc_id: state.current_document.unwrap(), path: None, }); state.current_document = Some(doc_list_item.id); commands.insert_resource(LoadDocRequest { doc_id: doc_list_item.id, }); } } Interaction::Hovered => {} Interaction::None => {} } } } pub fn mouse_scroll_list( mut mouse_wheel_events: EventReader, mut query_list: Query<(&mut ScrollingList, &mut Style, &Parent, &Node)>, query_node: Query<&Node>, ) { for mouse_wheel_event in mouse_wheel_events.iter() { for (mut scrolling_list, mut style, parent, list_node) in &mut query_list { let items_height = list_node.size().y; let container_height = query_node.get(parent.get()).unwrap().size().y; let max_scroll = (items_height - container_height).max(0.); let dy = match mouse_wheel_event.unit { MouseScrollUnit::Line => mouse_wheel_event.y * 20., MouseScrollUnit::Pixel => mouse_wheel_event.y, }; scrolling_list.position += dy; scrolling_list.position = scrolling_list.position.clamp(-max_scroll, 0.); style.top = Val::Px(scrolling_list.position); } } } pub fn doc_list_del_button_update( app_state: Res, mut delete_doc: Query<(&mut Visibility, &DeleteDoc), With>, mut event_reader: EventReader, ) { for _ in event_reader.iter() { for (mut visibility, doc) in delete_doc.iter_mut() { if Some(doc.id) == app_state.current_document { *visibility = Visibility::Visible; } else { *visibility = Visibility::Hidden; } } } } pub fn doc_list_ui_changed( mut commands: Commands, app_state: Res, mut last_doc_list: Local>, mut doc_list_query: Query>, asset_server: Res, pkv: Res, mut query_container: Query>, mut event_writer: EventWriter, theme: Res, mut cosmic_fonts: ResMut>, font_system_state: ResMut, windows: Query<&mut Window, With>, ) { let primary_window = windows.single(); let scale_factor = primary_window.scale_factor() as f32; if app_state.is_changed() && app_state.doc_list_ui != *last_doc_list { // Think about re-using UI elements instead of destroying and re-creating them for entity in query_container.iter_mut() { commands.entity(entity).despawn_recursive(); } let doc_list = doc_list_query.single_mut(); let mut doc_tuples: Vec<(String, ReflectableUuid)> = app_state .doc_list_ui .iter() .map(|doc_id| { let doc_name = get_doc_name(*doc_id, &pkv, &app_state); (doc_name, *doc_id) }) .collect(); // Sort the tuples alphabetically based on doc_name doc_tuples.sort_by(|(name1, _), (name2, _)| name1.cmp(name2)); for (doc_name, doc_id) in doc_tuples { let doc_list_item = add_list_item( &mut commands, &mut cosmic_fonts, font_system_state.0.clone().unwrap(), &theme, &asset_server, doc_id, doc_name, scale_factor, ); commands.entity(doc_list).add_child(doc_list_item); } event_writer.send(UpdateDeleteDocBtn); *last_doc_list = app_state.doc_list_ui.clone(); } } pub fn get_doc_name( doc_id: ReflectableUuid, pkv: &Res, app_state: &Res, ) -> String { if let Some(doc) = app_state.docs.get(&doc_id) { return doc.name.clone(); } if let Ok(names) = pkv.get::>("names") { if let Some(name) = names.get(&doc_id) { return name.clone(); } } "Unknown".to_string() } ================================================ FILE: src/ui_plugin/systems/drawing.rs ================================================ use std::{f32::consts::PI, time::Duration}; use bevy::{prelude::*, window::PrimaryWindow}; use bevy_prototype_lyon::prelude::{Path, PathBuilder, ShapeBundle, Stroke}; use crate::{ components::MainCamera, resources::AppState, themes::Theme, utils::{get_timestamp, ReflectableUuid}, }; use super::{ ui_helpers::{Drawing, InteractiveNode, MainPanel, TwoPointsDrawType}, NodeInteraction, NodeInteractionType, UiState, }; #[path = "../../macros.rs"] #[macro_use] mod macros; pub fn entity_to_draw_selected_changed( ui_state: Res, theme: Res, mut last_entity_to_draw: Local>, mut drawing_q: Query<(&mut Stroke, &Drawing<(String, Color)>), With>>, ) { if ui_state.is_changed() && ui_state.entity_to_draw_selected != *last_entity_to_draw { match ui_state.entity_to_draw_selected { Some(entity_to_draw_selected) => { for (mut stroke, drawing) in &mut drawing_q.iter_mut() { if drawing.id == entity_to_draw_selected { stroke.color = theme.drawing_selected; } else { stroke.color = drawing.drawing_color.1; } } } None => { for (mut stroke, drawing) in &mut drawing_q.iter_mut() { stroke.color = drawing.drawing_color.1; } } }; *last_entity_to_draw = ui_state.entity_to_draw_selected; } } pub fn set_focus_drawing( mut node_interaction_events: EventReader, mut ui_state: ResMut, drawing_container_q: Query<&Drawing<(String, Color)>, With>>, ) { for event in node_interaction_events.iter() { if let Ok(drawing) = drawing_container_q.get(event.entity) { if event.node_interaction_type == NodeInteractionType::LeftDoubleClick { if let Some(entity_to_draw_selected) = ui_state.entity_to_draw_selected { if entity_to_draw_selected == drawing.id { ui_state.entity_to_draw_selected = None; continue; } } ui_state.entity_to_draw_selected = Some(drawing.id); } if event.node_interaction_type == NodeInteractionType::LeftMouseHoldAndDrag && ui_state.entity_to_draw_selected == Some(drawing.id) { ui_state.entity_to_draw_hold = Some(drawing.id); } } if event.node_interaction_type == NodeInteractionType::LeftMouseRelease { ui_state.entity_to_draw_hold = None; } } } pub fn drawing_two_points( mut commands: Commands, ui_state: ResMut, mut app_state: ResMut, mut z_index_local: Local, theme: Res, mut start: Local>, mut end: Local>, mut drawing_entity: Local>, mut windows: Query<&mut Window, With>, camera_q: Query<(&Camera, &GlobalTransform), With>, mut previous_draw_mode: Local>, mut drawing_q: Query< (&mut Path, &mut Drawing<(String, Color)>), With>, >, interaction_query: Query<&Interaction, (Changed, With)>, ) { if *previous_draw_mode != ui_state.drawing_two_points_mode.clone() || ui_state.entity_to_draw_selected.is_some() { *drawing_entity = None; *start = None; *end = None; } if ui_state.drawing_two_points_mode.clone().is_some() { *previous_draw_mode = ui_state.drawing_two_points_mode.clone(); let (camera, camera_transform) = camera_q.single(); let primary_window = windows.single_mut(); for interaction in interaction_query.iter() { if *interaction == Interaction::Pressed { if let Some(pos) = primary_window.cursor_position() { if let Some(pos) = camera.viewport_to_world_2d(camera_transform, pos) { if start.is_none() { *start = Some(pos) } else if end.is_none() { *end = Some(pos) } } } } } if start.is_none() { return; } let start_point = start.unwrap(); let end_point = match *end { Some(v) => v, None => { if let Some(pos) = primary_window.cursor_position() { if let Some(pos) = camera.viewport_to_world_2d(camera_transform, pos) { pos } else { start_point } } else { start_point } } }; if (f32::abs(start_point.x - end_point.x) < 5.) && (f32::abs(start_point.y - end_point.y) < 5.) { *end = None; return; } let current_document_id = app_state.current_document.unwrap(); let current_document = app_state.docs.get(¤t_document_id); if current_document.is_none() { return; } let tab = app_state .docs .get_mut(¤t_document_id) .unwrap() .tabs .iter_mut() .find(|x| x.is_active) .unwrap(); let id = ReflectableUuid::generate(); let pair_color = ui_state .draw_color_pair .clone() .unwrap_or(pair_struct!(theme.drawing_two_points_btn)); let two_points_draw_type = ui_state.drawing_two_points_mode.clone().unwrap(); let (path, points) = match two_points_draw_type { super::ui_helpers::TwoPointsDrawType::Arrow => { let mut path_builder = PathBuilder::new(); let dt = end_point.x - start_point.x; let dy = end_point.y - start_point.y; let angle = dy.atan2(dt); let headlen = 20.0; let first = end_point - headlen * Vec2::from_angle(angle + PI / 6.); let second = end_point - headlen * Vec2::from_angle(angle - PI / 6.); path_builder.move_to(start_point); path_builder.line_to(end_point); path_builder.line_to(first); path_builder.move_to(end_point); path_builder.line_to(second); ( path_builder.build(), vec![start_point, end_point, first, end_point, second], ) } super::ui_helpers::TwoPointsDrawType::Line => { let mut path_builder = PathBuilder::new(); path_builder.move_to(start_point); path_builder.line_to(end_point); (path_builder.build(), vec![start_point, end_point]) } super::ui_helpers::TwoPointsDrawType::Rhombus => { let mut path_builder = PathBuilder::new(); let dx = (end_point.x - start_point.x).abs(); let dy = (end_point.y - start_point.y).abs(); let size = f32::max(dx, dy); let top = Vec2::new(start_point.x, start_point.y - size); let right = Vec2::new(start_point.x + size, start_point.y); let bottom = Vec2::new(start_point.x, start_point.y + size); let left = Vec2::new(start_point.x - size, start_point.y); path_builder.move_to(top); path_builder.line_to(right); path_builder.line_to(bottom); path_builder.line_to(left); path_builder.close(); let vertices = vec![top, right, bottom, left, top]; (path_builder.build(), vertices) } super::ui_helpers::TwoPointsDrawType::Rectangle => { let mut path_builder = PathBuilder::new(); let top_left = Vec2::new(start_point.x, end_point.y); let top_right = end_point; let bottom_right = Vec2::new(end_point.x, start_point.y); let bottom_left = start_point; path_builder.move_to(top_left); path_builder.line_to(top_right); path_builder.line_to(bottom_right); path_builder.line_to(bottom_left); path_builder.close(); let vertices = vec![top_left, top_right, bottom_right, bottom_left, top_left]; (path_builder.build(), vertices) } }; if drawing_entity.is_none() { *z_index_local += 0.01 % f32::MAX; tab.z_index += *z_index_local; let entity = commands .spawn(( ShapeBundle { path, transform: Transform::from_xyz(0., 0., tab.z_index), ..Default::default() }, Stroke::new(pair_color.1, 2.), Drawing { points, drawing_color: pair_color, id, }, InteractiveNode, )) .id(); *drawing_entity = Some(entity); } else { if let Ok((mut drawing_path, mut drawing_comp)) = drawing_q.get_mut(drawing_entity.unwrap()) { *drawing_path = path; drawing_comp.points = points; } if end.is_some() { *drawing_entity = None; *start = None; *end = None; } } } } pub fn drawing( mut commands: Commands, interaction_query: Query<&Interaction, (Changed, With)>, mut ui_state: ResMut, mut windows: Query<&mut Window, With>, mut holding_state: Local>, buttons: Res>, camera_q: Query<(&Camera, &GlobalTransform), With>, theme: Res, mut drawing_line_q: Query< (&mut Path, &mut Drawing<(String, Color)>), With>, >, mut app_state: ResMut, mut z_index_local: Local, ) { if ui_state.entity_to_draw_hold.is_some() || ui_state.entity_to_draw_selected.is_some() { *holding_state = None; return; } let (camera, camera_transform) = camera_q.single(); let mut primary_window = windows.single_mut(); let now_ms = get_timestamp(); let now = Duration::from_millis(now_ms as u64); for interaction in interaction_query.iter() { if *interaction == Interaction::Pressed { *holding_state = Some(now); } } if ui_state.drawing_mode { let current_document_id = app_state.current_document.unwrap(); let current_document = app_state.docs.get(¤t_document_id); if current_document.is_none() { return; } let tab = app_state .docs .get_mut(¤t_document_id) .unwrap() .tabs .iter_mut() .find(|x| x.is_active) .unwrap(); if buttons.just_released(MouseButton::Left) { *holding_state = None; primary_window.cursor.icon = CursorIcon::Default; ui_state.entity_to_draw = None; } if let Some(holding_time) = *holding_state { primary_window.cursor.icon = CursorIcon::Crosshair; if now - holding_time > Duration::from_millis(60) { if let Some(pos) = primary_window.cursor_position() { if let Some(pos) = camera.viewport_to_world_2d(camera_transform, pos) { if let Some(entity_to_draw) = ui_state.entity_to_draw { for (mut path, mut drawing_line) in &mut drawing_line_q.iter_mut() { if entity_to_draw == drawing_line.id { if drawing_line.points.last() == Some(&pos) { continue; } drawing_line.points.push(pos); let mut path_builder = PathBuilder::new(); let mut points_iter = drawing_line.points.iter(); let start = points_iter.next().unwrap(); path_builder.move_to(*start); path_builder.line_to(*start); for point in points_iter { path_builder.line_to(*point); } *path = path_builder.build(); } } } else { let id = ReflectableUuid::generate(); let pair_color = ui_state .draw_color_pair .clone() .unwrap_or(pair_struct!(theme.drawing_pencil_btn)); *z_index_local += 0.01 % f32::MAX; tab.z_index += *z_index_local; commands.spawn(( ShapeBundle { path: PathBuilder::new().build(), transform: Transform::from_xyz(0., 0., tab.z_index), ..Default::default() }, Stroke::new(pair_color.1, 2.), Drawing { points: vec![pos], drawing_color: pair_color, id, }, InteractiveNode, )); ui_state.entity_to_draw = Some(id); } } } } } } } pub fn update_drawing_position( mut cursor_moved_events: EventReader, camera_q: Query<(&Camera, &GlobalTransform), With>, ui_state: Res, mut previous_position: Local>, mut drawing_q: Query< (&mut Transform, &Drawing<(String, Color)>), With>, >, ) { let (camera, camera_transform) = camera_q.single(); if ui_state.entity_to_draw_hold.is_none() { *previous_position = None; return; } if previous_position.is_none() && !cursor_moved_events.is_empty() { if let Some(pos) = camera.viewport_to_world_2d( camera_transform, cursor_moved_events.iter().next().unwrap().position, ) { *previous_position = Some(pos.round()); } } if previous_position.is_some() { for (mut transform, drawing) in &mut drawing_q.iter_mut() { if ui_state.modal_id.is_none() && Some(drawing.id) == ui_state.entity_to_draw_hold { let event = cursor_moved_events.iter().last(); if let Some(pos) = event .and_then(|event| camera.viewport_to_world_2d(camera_transform, event.position)) { transform.translation.x += (pos.x - previous_position.unwrap().x).round(); transform.translation.y += (pos.y - previous_position.unwrap().y).round(); *previous_position = Some(pos.round()); break; } } } } } ================================================ FILE: src/ui_plugin/systems/effects.rs ================================================ #![allow(clippy::duplicate_mod)] use bevy::prelude::*; use bevy::render::view::RenderLayers; use super::ui_helpers::ParticlesEffect; use crate::components::EffectsCamera; pub fn update_particles_effect( mut q_effect: Query<(&mut bevy_hanabi::EffectSpawner, &mut Transform), Without>, mouse_button_input: Res>, window: Query<&Window, With>, mut effects_camera: Query<(&mut Camera, &GlobalTransform), With>, ) { let (camera, camera_transform) = effects_camera.single_mut(); if !camera.is_active { return; } // Note: On first frame where the effect spawns, EffectSpawner is spawned during // CoreSet::PostUpdate, so will not be available yet. Ignore for a frame if // so. let Ok((mut spawner, mut effect_transform)) = q_effect.get_single_mut() else { return; }; if let Ok(window) = window.get_single() { if let Some(mouse_pos) = window.cursor_position() { if mouse_button_input.pressed(MouseButton::Left) { let ray = camera .viewport_to_world(camera_transform, mouse_pos) .unwrap(); let spawning_pos = Vec3::new(ray.origin.x, ray.origin.y, 0.); effect_transform.translation = spawning_pos; // Spawn a single burst of particles spawner.reset(); } } } } pub fn create_particles_effect( mut query: Query<(&Interaction, &Children), (Changed, With)>, mut text_style_query: Query<&mut Text, With>, mut commands: Commands, mut effects: ResMut>, mut effects_camera: Query<&mut Camera, With>, mut effects_query: Query<(&Name, Entity)>, ) { use bevy_hanabi::prelude::*; use rand::Rng; for (interaction, children) in &mut query.iter_mut() { match *interaction { Interaction::Pressed => { for child in children.iter() { if let Ok(mut text) = text_style_query.get_mut(*child) { if effects_camera.single_mut().is_active { text.sections[0].style.color = text.sections[0].style.color.with_a(0.5) } else { text.sections[0].style.color = text.sections[0].style.color.with_a(1.) } } } if effects_camera.single_mut().is_active { effects_camera.single_mut().is_active = false; for (name, entity) in effects_query.iter_mut() { if name.as_str() == "effect:2d" { commands.entity(entity).despawn_recursive(); } } } else { effects_camera.single_mut().is_active = true; let mut gradient = Gradient::new(); let mut rng = rand::thread_rng(); gradient.add_key( 0.0, Vec4::new( rng.gen_range(0.0..1.0), rng.gen_range(0.0..1.0), rng.gen_range(0.0..1.0), 1.0, ), ); gradient.add_key( 1.0, Vec4::new( rng.gen_range(0.0..1.0), rng.gen_range(0.0..1.0), rng.gen_range(0.0..1.0), 0.0, ), ); let mut size_gradient1 = Gradient::new(); size_gradient1.add_key(0.0, Vec2::splat(0.008)); size_gradient1.add_key(0.3, Vec2::splat(0.012)); size_gradient1.add_key(1.0, Vec2::splat(0.0)); let writer = ExprWriter::new(); let lifetime = writer.lit(5.).uniform(writer.lit(10.)).expr(); let spawner = Spawner::rate(rng.gen_range(10.0..300.0).into()); let position_circle_modifier = SetPositionCircleModifier { center: writer.lit(Vec3::ZERO).expr(), axis: writer.lit(Vec3::Z).expr(), radius: writer.lit(0.0001).expr(), dimension: ShapeDimension::Surface, }; let velocity_circle_modifier = SetVelocityCircleModifier { center: writer.lit(Vec3::ZERO).expr(), axis: writer.lit(Vec3::Z).expr(), speed: writer.lit(0.05).uniform(writer.lit(0.1)).expr(), }; let effect = effects.add( EffectAsset::new(32768, spawner, writer.finish()) .with_name("Effect") .init(position_circle_modifier) .init(velocity_circle_modifier) .init(SetAttributeModifier { attribute: Attribute::LIFETIME, value: lifetime, }) .render(SizeOverLifetimeModifier { gradient: size_gradient1, screen_space_size: false, }) .render(ColorOverLifetimeModifier { gradient }), ); commands .spawn(ParticleEffectBundle { effect: ParticleEffect::new(effect), ..default() }) .insert(Name::new("effect:2d")) .insert(RenderLayers::layer(2)); } } Interaction::Hovered => {} Interaction::None => {} } } } ================================================ FILE: src/ui_plugin/systems/entity_to_edit_changed.rs ================================================ use std::collections::VecDeque; use bevy::prelude::*; use bevy_cosmic_edit::{ cosmic_edit_set_text, get_cosmic_text, get_text_spans, ActiveEditor, CosmicEdit, CosmicEditHistory, CosmicFont, CosmicText, EditHistoryItem, }; use bevy_markdown::{generate_markdown_lines, BevyMarkdown, BevyMarkdownTheme}; use bevy_prototype_lyon::prelude::{Fill, Stroke}; use cosmic_text::{Cursor, Edit}; use crate::{ resources::{AppState, SaveDocRequest}, themes::Theme, utils::{bevy_color_to_cosmic, ReflectableUuid}, }; use super::{ui_helpers::VeloShape, BevyMarkdownView, NodeType, RawText, UiState}; pub fn entity_to_edit_changed( ui_state: Res, app_state: Res, theme: Res, mut last_entity_to_edit: Local>, mut velo_border: Query<(&Fill, &mut Stroke, &VeloShape), With>, mut raw_text_node_query: Query< ( Entity, &mut RawText, &mut CosmicEdit, &mut CosmicEditHistory, ), With, >, mut commands: Commands, mut cosmic_fonts: ResMut>, ) { if ui_state.is_changed() && ui_state.entity_to_edit != *last_entity_to_edit { match ui_state.entity_to_edit { Some(entity_to_edit) => { // Change border for selected node for (fill, mut stroke, velo_border) in velo_border.iter_mut() { if velo_border.id == entity_to_edit { stroke.color = theme.selected_node_border; stroke.options.line_width = 2.; } else { let has_border = velo_border.node_type.clone() != NodeType::Paper; if has_border && fill.color != Color::NONE { stroke.color = theme.node_border; } else { stroke.color = Color::NONE; }; stroke.options.line_width = 1.; } } for (entity, mut raw_text, mut cosmic_edit, mut cosmic_edit_history) in raw_text_node_query.iter_mut() { // cosmic-edit editing mode if raw_text.id == entity_to_edit { cosmic_edit.readonly = false; let current_cursor = cosmic_edit.editor.cursor(); let new_cursor = Cursor::new_with_color( current_cursor.line, current_cursor.index, bevy_color_to_cosmic(theme.font), ); cosmic_edit.editor.set_cursor(new_cursor); let text = raw_text.last_text.clone(); let font = cosmic_fonts .get_mut(&cosmic_edit.font_system.clone()) .unwrap(); cosmic_edit_set_text( CosmicText::OneStyle(text), cosmic_edit.attrs.clone(), &mut cosmic_edit.editor, &mut font.0, ); commands.insert_resource(ActiveEditor { entity: Some(entity), }); cosmic_edit.editor.buffer_mut().set_redraw(true); } // cosmic-edit readonly mode if Some(raw_text.id) == *last_entity_to_edit { cosmic_edit.readonly = true; let cursor_color = cosmic_edit.bg; let current_cursor = cosmic_edit.editor.cursor(); let new_cursor = Cursor::new_with_color( current_cursor.line, current_cursor.index, bevy_color_to_cosmic(cursor_color), ); cosmic_edit.editor.set_cursor(new_cursor); let mut edits = VecDeque::new(); edits.push_back(EditHistoryItem { cursor: new_cursor, lines: get_text_spans( cosmic_edit.editor.buffer(), cosmic_edit.attrs.clone(), ), }); *cosmic_edit_history = CosmicEditHistory { edits, current_edit: 0, }; let text = get_cosmic_text(cosmic_edit.editor.buffer()); raw_text.last_text = text.clone(); let markdown_theme = BevyMarkdownTheme { code_theme: theme.code_theme.clone(), code_default_lang: theme.code_default_lang.clone(), link: bevy_color_to_cosmic(theme.link), inline_code: bevy_color_to_cosmic(theme.inline_code), }; let markdown_lines = generate_markdown_lines(BevyMarkdown { text, markdown_theme, attrs: cosmic_edit.attrs.clone(), }) .expect("should handle markdown convertion"); let font = cosmic_fonts .get_mut(&cosmic_edit.font_system.clone()) .unwrap(); cosmic_edit_set_text( CosmicText::MultiStyle(markdown_lines.lines), cosmic_edit.attrs.clone(), &mut cosmic_edit.editor, &mut font.0, ); commands.entity(entity).insert(BevyMarkdownView { id: raw_text.id, span_metadata: markdown_lines.span_metadata, }); cosmic_edit.editor.buffer_mut().set_redraw(true); } } } None => { for (fill, mut stroke, velo_border) in velo_border.iter_mut() { let has_border = velo_border.node_type.clone() != NodeType::Paper; if has_border && fill.color != Color::NONE { stroke.color = theme.node_border; } else { stroke.color = Color::NONE; }; stroke.options.line_width = 1.; } for (entity, mut raw_text, mut cosmic_edit, mut cosmic_edit_history) in raw_text_node_query.iter_mut() { // cosmic-edit readonly mode if Some(raw_text.id) == *last_entity_to_edit { cosmic_edit.readonly = true; let cursor_color = cosmic_edit.bg; let current_cursor = cosmic_edit.editor.cursor(); let new_cursor = Cursor::new_with_color( current_cursor.line, current_cursor.index, bevy_color_to_cosmic(cursor_color), ); cosmic_edit.editor.set_cursor(new_cursor); let mut edits = VecDeque::new(); edits.push_back(EditHistoryItem { cursor: new_cursor, lines: get_text_spans( cosmic_edit.editor.buffer(), cosmic_edit.attrs.clone(), ), }); *cosmic_edit_history = CosmicEditHistory { edits, current_edit: 0, }; let text = get_cosmic_text(cosmic_edit.editor.buffer()); raw_text.last_text = text.clone(); let markdown_theme = BevyMarkdownTheme { code_theme: theme.code_theme.clone(), code_default_lang: theme.code_default_lang.clone(), link: bevy_color_to_cosmic(theme.link), inline_code: bevy_color_to_cosmic(theme.inline_code), }; let markdown_lines = generate_markdown_lines(BevyMarkdown { text, markdown_theme, attrs: cosmic_edit.attrs.clone(), }) .expect("should handle markdown convertion"); let font = cosmic_fonts .get_mut(&cosmic_edit.font_system.clone()) .unwrap(); cosmic_edit.attrs = cosmic_edit.attrs.clone(); cosmic_edit_set_text( CosmicText::MultiStyle(markdown_lines.lines), cosmic_edit.attrs.clone(), &mut cosmic_edit.editor, &mut font.0, ); commands.entity(entity).insert(BevyMarkdownView { id: raw_text.id, span_metadata: markdown_lines.span_metadata, }); cosmic_edit.editor.buffer_mut().set_redraw(true); } } if let Some(current_document) = app_state.current_document { commands.insert_resource(SaveDocRequest { doc_id: current_document, path: None, }); } } } *last_entity_to_edit = ui_state.entity_to_edit; } } ================================================ FILE: src/ui_plugin/systems/init_layout/add_arrow.rs ================================================ use bevy::prelude::*; use crate::{ themes::Theme, ui_plugin::ui_helpers::{GenericButton, TooltipPosition}, }; use super::ui_helpers::{get_tooltip, Tooltip}; use crate::canvas::arrow::components::{ArrowMode, ArrowType}; pub fn add_arrow( commands: &mut Commands, theme: &Res, asset_server: &Res, arrow_mode: ArrowMode, ) -> Entity { let (image, text) = match arrow_mode.arrow_type { ArrowType::Line => (asset_server.load("line.png"), "Enable line mode"), ArrowType::Arrow => (asset_server.load("arrow.png"), "Enable single arrow mode"), ArrowType::DoubleArrow => ( asset_server.load("double-arrow.png"), "Enable double arrow mode", ), ArrowType::ParallelLine => ( asset_server.load("parallel-line.png"), "Enable parallel line mode", ), ArrowType::ParallelArrow => ( asset_server.load("parallel-arrow.png"), "Enable parallel arrow mode", ), ArrowType::ParallelDoubleArrow => ( asset_server.load("parallel-double-arrow.png"), "Enable parallel double arrow mode", ), }; let top = commands .spawn(NodeBundle { style: Style { flex_direction: FlexDirection::Column, align_self: AlignSelf::Stretch, margin: UiRect::all(Val::Px(3.)), width: Val::Percent(13.), height: Val::Percent(100.), ..default() }, background_color: theme.shadow.into(), ..default() }) .id(); let button = commands .spawn(( ButtonBundle { background_color: theme.arrow_btn_bg.into(), border_color: theme.btn_border.into(), image: image.into(), style: Style { width: Val::Percent(100.), height: Val::Percent(100.), align_items: AlignItems::Center, position_type: PositionType::Absolute, left: Val::Px(1.), right: Val::Px(0.), top: Val::Px(-1.), bottom: Val::Px(0.), border: UiRect::all(Val::Px(1.)), justify_content: JustifyContent::Center, ..default() }, ..default() }, arrow_mode, GenericButton, )) .with_children(|builder| { builder.spawn(( get_tooltip(theme, text.to_string(), TooltipPosition::Bottom), Tooltip, )); }) .id(); commands.entity(top).add_child(button); top } ================================================ FILE: src/ui_plugin/systems/init_layout/add_color.rs ================================================ use bevy::prelude::*; use crate::{themes::Theme, ui_plugin::ui_helpers::GenericButton}; use super::ui_helpers::ChangeColor; pub fn add_color(commands: &mut Commands, theme: &Res, color: (String, Color)) -> Entity { let top = commands .spawn(NodeBundle { style: Style { flex_direction: FlexDirection::Column, align_self: AlignSelf::Stretch, margin: UiRect::all(Val::Px(5.)), width: Val::Percent(20.), height: Val::Percent(100.), ..default() }, background_color: theme.shadow.into(), ..default() }) .id(); let button = commands .spawn(( ButtonBundle { background_color: color.1.into(), border_color: BorderColor(theme.btn_border), style: Style { width: Val::Percent(100.), height: Val::Percent(100.), align_items: AlignItems::Center, position_type: PositionType::Absolute, left: Val::Px(1.), right: Val::Px(0.), top: Val::Px(-1.), bottom: Val::Px(0.), border: UiRect::all(Val::Px(1.)), justify_content: JustifyContent::Center, ..default() }, ..default() }, ChangeColor { pair_color: color }, GenericButton, )) .id(); commands.entity(top).add_child(button); top } ================================================ FILE: src/ui_plugin/systems/init_layout/add_effect.rs ================================================ use bevy::{prelude::*, text::BreakLineOn}; use crate::{themes::Theme, ui_plugin::ui_helpers::GenericButton}; pub fn add_effect( commands: &mut Commands, theme: &Res, icon_font: &Handle, component: impl Component + Clone, ) -> Entity { let top = commands .spawn((NodeBundle { style: Style { align_self: AlignSelf::Stretch, flex_direction: FlexDirection::Column, margin: UiRect { left: Val::Px(8.), right: Val::Px(20.), ..default() }, padding: UiRect { top: Val::Px(3.), ..default() }, width: Val::Percent(2.3), height: Val::Percent(85.), ..default() }, ..default() },)) .id(); let button = commands .spawn(( ButtonBundle { background_color: theme.celebrate_btn_bg.into(), style: Style { padding: UiRect::all(Val::Px(10.)), width: Val::Percent(100.), height: Val::Percent(100.), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, ..default() }, component.clone(), GenericButton, )) .with_children(|builder| { let text_style = TextStyle { font_size: 25.0, color: theme.celebrate_btn.with_a(0.5), font: icon_font.clone(), }; let text = Text { sections: vec![TextSection { value: "\u{ea65}".to_string(), style: text_style, }], alignment: TextAlignment::Center, linebreak_behavior: BreakLineOn::WordBoundary, }; builder.spawn((TextBundle { text, ..default() }, component)); }) .id(); commands.entity(top).add_child(button); top } ================================================ FILE: src/ui_plugin/systems/init_layout/add_front_back.rs ================================================ use bevy::prelude::*; use crate::{ themes::Theme, ui_plugin::ui_helpers::{GenericButton, TooltipPosition}, }; use super::ui_helpers::{get_tooltip, ButtonAction, ButtonTypes, Tooltip}; pub fn add_front_back( commands: &mut Commands, theme: &Res, asset_server: &Res, button_action: ButtonAction, ) -> Entity { let (image, text) = if button_action.button_type == ButtonTypes::Front { (asset_server.load("front.png"), "Move to front") } else { (asset_server.load("back.png"), "Move to back") }; let top = commands .spawn(NodeBundle { style: Style { flex_direction: FlexDirection::Column, align_self: AlignSelf::Stretch, margin: UiRect::all(Val::Px(5.)), width: Val::Percent(15.), height: Val::Percent(100.), ..default() }, background_color: theme.shadow.into(), ..default() }) .id(); let button = commands .spawn(( ButtonBundle { background_color: theme.front_back_btn_bg.into(), border_color: theme.btn_border.into(), image: image.into(), style: Style { width: Val::Percent(100.), height: Val::Percent(100.), align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.)), position_type: PositionType::Absolute, left: Val::Px(1.), right: Val::Px(0.), top: Val::Px(-1.), bottom: Val::Px(0.), justify_content: JustifyContent::Center, ..default() }, ..default() }, button_action, GenericButton, )) .with_children(|builder| { builder.spawn(( get_tooltip(theme, text.to_string(), TooltipPosition::Bottom), Tooltip, )); }) .id(); commands.entity(top).add_child(button); top } ================================================ FILE: src/ui_plugin/systems/init_layout/add_list.rs ================================================ use std::collections::{HashMap, VecDeque}; use bevy::{ a11y::{ accesskit::{NodeBuilder, Role}, AccessibilityNode, }, prelude::*, }; use bevy_pkv::PkvStore; use super::ui_helpers::ScrollingList; use crate::resources::{AppState, LoadDocRequest}; use crate::ui_plugin::ui_helpers::DocList; use crate::utils::ReflectableUuid; use crate::{ components::{Doc, Tab}, themes::Theme, }; pub fn add_list( commands: &mut Commands, theme: &Res, app_state: &mut ResMut, pkv: &mut ResMut, ) -> Entity { if let Ok(last_saved) = pkv.get::("last_saved") { app_state.current_document = Some(last_saved); commands.insert_resource(LoadDocRequest { doc_id: last_saved }); } let top = commands .spawn(NodeBundle { style: Style { flex_direction: FlexDirection::Column, width: Val::Percent(80.), height: Val::Percent(80.), overflow: Overflow::clip(), ..default() }, background_color: theme.doc_list_bg.into(), ..default() }) .id(); let node = commands .spawn(( NodeBundle { style: Style { flex_direction: FlexDirection::Column, align_items: AlignItems::Center, ..default() }, ..default() }, DocList, ScrollingList::default(), AccessibilityNode(NodeBuilder::new(Role::List)), )) .id(); if let Ok(names) = pkv.get::>("names") { let keys: Vec<_> = names.keys().collect(); app_state.doc_list_ui.extend(keys); } else { let tab_id = ReflectableUuid::generate(); let tab_name: String = "Tab 1".to_string(); let tabs = vec![Tab { id: tab_id, name: tab_name, checkpoints: VecDeque::new(), z_index: 1., is_active: true, }]; let doc_id = ReflectableUuid::generate(); app_state.docs.insert( doc_id, Doc { id: doc_id, name: "Untitled".to_string(), tabs, tags: vec![], }, ); app_state.current_document = Some(doc_id); app_state.doc_list_ui.insert(doc_id); commands.insert_resource(LoadDocRequest { doc_id }); } commands.entity(top).add_child(node); top } ================================================ FILE: src/ui_plugin/systems/init_layout/add_menu_button.rs ================================================ use bevy::{prelude::*, text::BreakLineOn}; use crate::{ themes::Theme, ui_plugin::ui_helpers::{get_tooltip, GenericButton, Tooltip, TooltipPosition}, utils::{DARK_THEME_ICON_CODE, LIGHT_THEME_ICON_CODE}, }; pub fn add_menu_button( commands: &mut Commands, theme: &Res, label: String, icon_font: &Handle, component: impl Component + Clone, ) -> Entity { let icon_code = match label.as_str() { "New Tab" => "\u{e3ba}", "New Document" => "\u{e89c}", "Save Document" => "\u{e161}", "Export To File" => "\u{e2c6}", "Import From File" => "\u{e255}", "Import From URL" => "\u{e902}", "Save Document to window.velo object" => "\u{e866}", "Share Document (copy URL to clipboard)" => "\u{e80d}", "Enable dark theme (restart is required for now)" => DARK_THEME_ICON_CODE, "Enable light theme (restart is required for now)" => LIGHT_THEME_ICON_CODE, _ => panic!("Unknown menu button tooltip label: {}", label), }; match label.as_str() { "New Tab" => { let top = commands .spawn((NodeBundle { style: Style { align_self: AlignSelf::Stretch, flex_direction: FlexDirection::Column, margin: UiRect { left: Val::Px(10.), right: Val::Px(10.), ..default() }, padding: UiRect { top: Val::Px(3.), ..default() }, width: Val::Percent(2.3), height: Val::Percent(85.), ..default() }, ..default() },)) .id(); let button = commands .spawn(( ButtonBundle { background_color: theme.new_tab_btn_bg.into(), style: Style { width: Val::Percent(100.), height: Val::Percent(100.), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, ..default() }, component, GenericButton, )) .with_children(|builder| { let text_style = TextStyle { font_size: 30.0, color: theme.menu_btn, font: icon_font.clone(), }; let text = Text { sections: vec![TextSection { value: icon_code.to_string(), style: text_style, }], alignment: TextAlignment::Left, linebreak_behavior: BreakLineOn::WordBoundary, }; builder.spawn(TextBundle { text, ..default() }); }) .id(); commands.entity(top).add_child(button); top } _ => { let top = commands .spawn((NodeBundle { background_color: theme.shadow.into(), border_color: BorderColor(theme.btn_border), style: Style { flex_direction: FlexDirection::Column, align_self: AlignSelf::Stretch, border: UiRect::all(Val::Px(1.)), margin: UiRect { left: Val::Px(10.), right: Val::Px(10.), top: Val::Px(3.), bottom: Val::Px(3.), }, width: Val::Percent(2.3), height: Val::Percent(85.), ..default() }, ..default() },)) .id(); let button = commands .spawn(( ButtonBundle { background_color: theme.menu_btn_bg.into(), style: Style { width: Val::Percent(100.), height: Val::Percent(100.), justify_content: JustifyContent::Center, align_items: AlignItems::Center, position_type: PositionType::Absolute, left: Val::Px(1.), right: Val::Auto, top: Val::Px(-2.), bottom: Val::Auto, ..default() }, ..default() }, component.clone(), GenericButton, )) .with_children(|builder| { builder.spawn((get_tooltip(theme, label, TooltipPosition::Bottom), Tooltip)); let text_style = TextStyle { font_size: 30.0, color: theme.menu_btn, font: icon_font.clone(), }; let text = Text { sections: vec![TextSection { value: icon_code.to_string(), style: text_style, }], alignment: TextAlignment::Left, linebreak_behavior: BreakLineOn::WordBoundary, }; let text_bundle_style = Style { position_type: PositionType::Absolute, padding: UiRect::all(Val::Px(5.)), margin: UiRect::all(Val::Px(3.)), ..default() }; builder.spawn(( TextBundle { text, style: text_bundle_style, ..default() }, component, )); }) .id(); commands.entity(top).add_child(button); top } } } ================================================ FILE: src/ui_plugin/systems/init_layout/add_pencil.rs ================================================ use bevy::{prelude::*, text::BreakLineOn}; use crate::{themes::Theme, ui_plugin::ui_helpers::GenericButton}; pub fn add_pencil( commands: &mut Commands, theme: &Res, icon_font: &Handle, component: impl Component + Clone, ) -> Entity { let top = commands .spawn((NodeBundle { style: Style { align_self: AlignSelf::Stretch, flex_direction: FlexDirection::Column, margin: UiRect { left: Val::Px(8.), right: Val::Px(20.), ..default() }, padding: UiRect { top: Val::Px(3.), ..default() }, width: Val::Percent(2.3), height: Val::Percent(85.), ..default() }, ..default() },)) .id(); let button = commands .spawn(( ButtonBundle { background_color: theme.drawing_pencil_btn_bg.into(), style: Style { padding: UiRect::all(Val::Px(10.)), width: Val::Percent(100.), height: Val::Percent(100.), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, ..default() }, component.clone(), GenericButton, )) .with_children(|builder| { let text_style = TextStyle { font_size: 25.0, color: theme.drawing_pencil_btn.with_a(0.5), font: icon_font.clone(), }; let text = Text { sections: vec![TextSection { value: "\u{e746}".to_string(), style: text_style, }], alignment: TextAlignment::Center, linebreak_behavior: BreakLineOn::WordBoundary, }; builder.spawn((TextBundle { text, ..default() }, component)); }) .id(); commands.entity(top).add_child(button); top } ================================================ FILE: src/ui_plugin/systems/init_layout/add_search_box.rs ================================================ use bevy::prelude::*; use bevy_cosmic_edit::{ spawn_cosmic_edit, CosmicEditMeta, CosmicFont, CosmicMetrics, CosmicNode, CosmicText, }; use cosmic_text::AttrsOwned; use crate::{ themes::Theme, ui_plugin::{ ui_helpers::{ get_tooltip, GenericButton, SearchButton, SearchText, Tooltip, TooltipPosition, }, TextPos, }, utils::{bevy_color_to_cosmic, ReflectableUuid}, }; pub fn add_search_box( commands: &mut Commands, theme: &Res, cosmic_fonts: &mut ResMut>, cosmic_font_handle: Handle, scale_factor: f32, ) -> Entity { let id = ReflectableUuid::generate(); let root = commands .spawn((NodeBundle { border_color: theme.btn_border.into(), background_color: theme.search_box_bg.into(), style: Style { width: Val::Percent(80.), height: Val::Percent(8.), border: UiRect::all(Val::Px(1.)), flex_direction: FlexDirection::Column, margin: UiRect::all(Val::Px(5.)), ..default() }, ..default() },)) .id(); let mut attrs = cosmic_text::Attrs::new(); attrs = attrs.family(cosmic_text::Family::Name(theme.font_name.as_str())); attrs = attrs.color(bevy_color_to_cosmic(theme.font)); let cosmic_edit_meta = CosmicEditMeta { text: CosmicText::OneStyle("".to_string()), attrs: AttrsOwned::new(attrs), text_pos: TextPos::Center.into(), font_system_handle: cosmic_font_handle, node: CosmicNode::Ui, size: None, metrics: CosmicMetrics { font_size: 14., line_height: 18., scale_factor, }, bg: theme.search_box_bg, readonly: false, bg_image: None, }; let cosmic_edit = spawn_cosmic_edit(commands, cosmic_fonts, cosmic_edit_meta); commands .entity(cosmic_edit) .insert(SearchButton { id }) .insert(GenericButton); commands.entity(cosmic_edit).insert(SearchText { id }); let tooltip = commands .spawn(( get_tooltip( theme, "Filter documents by text in nodes".to_string(), TooltipPosition::Top, ), Tooltip, )) .id(); commands.entity(cosmic_edit).add_child(tooltip); commands.entity(root).add_child(cosmic_edit); root } ================================================ FILE: src/ui_plugin/systems/init_layout/add_text.rs ================================================ use bevy::{prelude::*, text::BreakLineOn}; use crate::{themes::Theme, ui_plugin::ui_helpers::GenericButton}; pub fn add_text( commands: &mut Commands, theme: &Res, icon_font: &Handle, component: impl Component + Clone, ) -> Entity { let top = commands .spawn((NodeBundle { style: Style { align_self: AlignSelf::Stretch, flex_direction: FlexDirection::Column, margin: UiRect { left: Val::Px(8.), right: Val::Px(20.), ..default() }, padding: UiRect { top: Val::Px(3.), ..default() }, width: Val::Percent(2.3), height: Val::Percent(85.), ..default() }, ..default() },)) .id(); let button = commands .spawn(( ButtonBundle { background_color: theme.add_text_btn_bg.into(), style: Style { padding: UiRect::all(Val::Px(10.)), width: Val::Percent(100.), height: Val::Percent(100.), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, ..default() }, component.clone(), GenericButton, )) .with_children(|builder| { let text_style = TextStyle { font_size: 25.0, color: theme.add_text_btn.with_a(0.5), font: icon_font.clone(), }; let text = Text { sections: vec![TextSection { value: "\u{e262}".to_string(), style: text_style, }], alignment: TextAlignment::Center, linebreak_behavior: BreakLineOn::WordBoundary, }; builder.spawn((TextBundle { text, ..default() }, component)); }) .id(); commands.entity(top).add_child(button); top } ================================================ FILE: src/ui_plugin/systems/init_layout/add_text_pos.rs ================================================ use bevy::{prelude::*, text::BreakLineOn}; use crate::{ themes::Theme, ui_plugin::ui_helpers::{get_tooltip, GenericButton, Tooltip, TooltipPosition}, }; use super::ui_helpers::TextPosMode; pub fn add_text_pos( commands: &mut Commands, theme: &Res, text_pos_mode: TextPosMode, tooltip_label: String, icon_font: &Handle, ) -> Entity { let icon_code = match text_pos_mode.text_pos { crate::TextPos::Center => "\u{e234}".to_string(), crate::TextPos::TopLeft => "\u{e236}".to_string(), }; let top = commands .spawn(NodeBundle { style: Style { flex_direction: FlexDirection::Column, align_self: AlignSelf::Stretch, margin: UiRect::all(Val::Px(5.)), width: Val::Percent(15.), height: Val::Percent(100.), ..default() }, background_color: theme.shadow.into(), ..default() }) .id(); let new_button_action = commands .spawn(( ButtonBundle { background_color: Color::BLACK.into(), border_color: theme.btn_border.into(), style: Style { width: Val::Percent(100.), height: Val::Percent(100.), align_items: AlignItems::Center, position_type: PositionType::Absolute, left: Val::Px(1.), right: Val::Px(0.), top: Val::Px(-1.), bottom: Val::Px(0.), border: UiRect::all(Val::Px(1.)), justify_content: JustifyContent::Center, ..default() }, ..default() }, text_pos_mode, GenericButton, )) .with_children(|builder| { builder.spawn(( get_tooltip(theme, tooltip_label, TooltipPosition::Bottom), Tooltip, )); let text_style = TextStyle { font_size: 25.0, color: theme.text_pos_btn_bg, font: icon_font.clone(), }; let text = Text { sections: vec![TextSection { value: icon_code, style: text_style, }], alignment: TextAlignment::Left, linebreak_behavior: BreakLineOn::WordBoundary, }; let text_bundle_style = Style { position_type: PositionType::Absolute, padding: UiRect::all(Val::Px(5.)), margin: UiRect::all(Val::Px(3.)), ..default() }; builder.spawn(TextBundle { text, style: text_bundle_style, ..default() }); }) .id(); commands.entity(top).add_child(new_button_action); top } ================================================ FILE: src/ui_plugin/systems/init_layout/add_two_points_draw.rs ================================================ use bevy::{prelude::*, text::BreakLineOn}; use crate::{ themes::Theme, ui_plugin::ui_helpers::{GenericButton, TwoPointsDraw}, }; pub fn add_two_points_draw( commands: &mut Commands, theme: &Res, icon_font: &Handle, component: TwoPointsDraw, ) -> Entity { let top = commands .spawn((NodeBundle { style: Style { align_self: AlignSelf::Stretch, flex_direction: FlexDirection::Column, margin: UiRect { left: Val::Px(8.), right: Val::Px(20.), ..default() }, padding: UiRect { top: Val::Px(3.), ..default() }, width: Val::Percent(2.3), height: Val::Percent(85.), ..default() }, ..default() },)) .id(); let code = match component.drawing_type { crate::ui_plugin::ui_helpers::TwoPointsDrawType::Arrow => "\u{f8ce}", crate::ui_plugin::ui_helpers::TwoPointsDrawType::Line => "\u{f108}", crate::ui_plugin::ui_helpers::TwoPointsDrawType::Rhombus => "\u{e86b}", crate::ui_plugin::ui_helpers::TwoPointsDrawType::Rectangle => "\u{e3c1}", }; let button = commands .spawn(( ButtonBundle { background_color: theme.drawing_two_points_btn_bg.into(), style: Style { padding: UiRect::all(Val::Px(10.)), width: Val::Percent(100.), height: Val::Percent(100.), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, ..default() }, component.clone(), GenericButton, )) .with_children(|builder| { let text_style = TextStyle { font_size: 25.0, color: theme.drawing_two_points_btn.with_a(0.5), font: icon_font.clone(), }; let text = Text { sections: vec![TextSection { value: code.to_string(), style: text_style, }], alignment: TextAlignment::Center, linebreak_behavior: BreakLineOn::WordBoundary, }; builder.spawn((TextBundle { text, ..default() }, component)); }) .id(); commands.entity(top).add_child(button); top } ================================================ FILE: src/ui_plugin/systems/init_layout/add_visibility.rs ================================================ use bevy::{prelude::*, text::BreakLineOn}; use crate::{ themes::Theme, ui_plugin::ui_helpers::{get_tooltip, ButtonAction, GenericButton, Tooltip, TooltipPosition}, }; pub fn add_visibility( commands: &mut Commands, theme: &Res, button_action: ButtonAction, tooltip_label: String, icon_font: &Handle, ) -> Entity { let icon_code = match button_action.button_type { crate::ui_plugin::ui_helpers::ButtonTypes::ShowChildren => "\u{e8f4}".to_string(), crate::ui_plugin::ui_helpers::ButtonTypes::HideChildren => "\u{e8f5}".to_string(), crate::ui_plugin::ui_helpers::ButtonTypes::ShowRandom => "\u{e043}".to_string(), _ => panic!("unexpected button type"), }; let top = commands .spawn(NodeBundle { style: Style { flex_direction: FlexDirection::Column, align_self: AlignSelf::Stretch, margin: UiRect::all(Val::Px(5.)), width: Val::Percent(15.), height: Val::Percent(100.), ..default() }, background_color: theme.shadow.into(), ..default() }) .id(); let new_button_action = commands .spawn(( ButtonBundle { background_color: Color::BLACK.into(), border_color: theme.btn_border.into(), style: Style { width: Val::Percent(100.), height: Val::Percent(100.), align_items: AlignItems::Center, position_type: PositionType::Absolute, left: Val::Px(1.), right: Val::Px(0.), top: Val::Px(-1.), bottom: Val::Px(0.), border: UiRect::all(Val::Px(1.)), justify_content: JustifyContent::Center, ..default() }, ..default() }, button_action, GenericButton, )) .with_children(|builder| { builder.spawn(( get_tooltip(theme, tooltip_label, TooltipPosition::Bottom), Tooltip, )); let text_style = TextStyle { font_size: 25.0, color: theme.text_pos_btn_bg, font: icon_font.clone(), }; let text = Text { sections: vec![TextSection { value: icon_code, style: text_style, }], alignment: TextAlignment::Left, linebreak_behavior: BreakLineOn::WordBoundary, }; let text_bundle_style = Style { position_type: PositionType::Absolute, padding: UiRect::all(Val::Px(5.)), margin: UiRect::all(Val::Px(3.)), ..default() }; builder.spawn(TextBundle { text, style: text_bundle_style, ..default() }); }) .id(); commands.entity(top).add_child(new_button_action); top } ================================================ FILE: src/ui_plugin/systems/init_layout/init_layout.rs ================================================ #![allow(clippy::duplicate_mod)] use bevy::prelude::*; use bevy::window::PrimaryWindow; use bevy_cosmic_edit::{create_cosmic_font_system, CosmicFont, CosmicFontConfig}; use bevy_pkv::PkvStore; use super::ui_helpers::{ self, AddTab, BottomPanel, ButtonAction, ChangeTheme, DrawPencil, LeftPanel, LeftPanelControls, LeftPanelExplorer, MainPanel, Menu, NewDoc, ParticlesEffect, Root, SaveDoc, TextPosMode, TwoPointsDraw, }; use super::{CommChannels, ExportToFile, ImportFromFile, ImportFromUrl, ShareDoc}; use crate::canvas::arrow::components::{ArrowMode, ArrowType}; use crate::resources::{AppState, FontSystemState}; use crate::themes::Theme; use crate::utils::get_theme_key; use crate::TextPos; #[path = "../../../macros.rs"] #[macro_use] mod macros; #[path = "add_arrow.rs"] mod add_arrow; use add_arrow::*; #[path = "add_color.rs"] mod add_color; use add_color::*; #[path = "add_front_back.rs"] mod add_front_back; use add_front_back::*; #[path = "add_text_pos.rs"] mod add_text_pos; use add_text_pos::*; #[path = "node_manipulation.rs"] mod node_manipulation; use node_manipulation::*; #[path = "add_menu_button.rs"] mod add_menu_button; use add_menu_button::*; #[path = "add_list.rs"] mod add_list; use add_list::*; #[path = "add_effect.rs"] mod add_effect; use add_effect::*; #[path = "add_pencil.rs"] mod add_pencil; use add_pencil::*; #[path = "add_two_points_draw.rs"] mod add_two_points_draw; use add_two_points_draw::*; #[path = "add_text.rs"] mod add_text; use add_text::*; #[path = "add_search_box.rs"] mod add_search_box; use add_search_box::*; #[path = "add_visibility.rs"] mod add_visibility; use add_visibility::*; // Think about splitting this function to wasm and native pub fn init_layout( mut commands: Commands, mut app_state: ResMut, asset_server: Res, mut pkv: ResMut, mut cosmic_fonts: ResMut>, windows: Query<&Window, With>, mut fonts: ResMut>, theme: Res, ) { // font setup let font_bytes_regular = include_bytes!("../../../../assets/fonts/VictorMono-Regular.ttf"); let font_bytes_bold = include_bytes!("../../../../assets/fonts/VictorMono-Bold.ttf"); let font_bytes_italic = include_bytes!("../../../../assets/fonts/VictorMono-Italic.ttf"); let font_bytes_bold_italic = include_bytes!("../../../../assets/fonts/VictorMono-BoldItalic.ttf"); let font_bytes_medium = include_bytes!("../../../../assets/fonts/VictorMono-Medium.ttf"); let font_bytes_semibold = include_bytes!("../../../../assets/fonts/VictorMono-SemiBold.ttf"); let font = Font::try_from_bytes(font_bytes_regular.to_vec()).unwrap(); let text_style = TextStyle { font: TextStyle::default().font, font_size: 14.0, color: theme.font, }; fonts.set_untracked(text_style.font, font); let cosmic_font_config = CosmicFontConfig { fonts_dir_path: None, load_system_fonts: true, font_bytes: Some(vec![ font_bytes_regular, font_bytes_italic, font_bytes_bold, font_bytes_bold_italic, font_bytes_medium, font_bytes_semibold, ]), }; let font_system = create_cosmic_font_system(cosmic_font_config); let cosmic_font_handle = cosmic_fonts.add(CosmicFont(font_system)); commands.insert_resource(FontSystemState(Some(cosmic_font_handle.clone()))); let primary_window: &Window = windows.single(); #[cfg(not(target_arch = "wasm32"))] { let (tx, rx) = async_channel::bounded(1); commands.insert_resource(CommChannels { tx, rx }); } let icon_font = asset_server.load("fonts/MaterialIcons-Regular.ttf"); let bottom_panel = commands .spawn(( NodeBundle { border_color: theme.btn_border.into(), background_color: theme.bottom_panel_bg.into(), style: Style { border: UiRect::all(Val::Px(1.0)), position_type: PositionType::Absolute, left: Val::Percent(0.), right: Val::Percent(0.), bottom: Val::Percent(0.), top: Val::Percent(96.), width: Val::Percent(100.), height: Val::Percent(4.), align_items: AlignItems::Center, justify_content: JustifyContent::Start, overflow: Overflow::clip(), ..default() }, ..default() }, BottomPanel, )) .id(); let add_tab = add_menu_button( &mut commands, &theme, "New Tab".to_string(), &icon_font, AddTab, ); commands.entity(bottom_panel).add_child(add_tab); let docs = add_list(&mut commands, &theme, &mut app_state, &mut pkv); let root_ui = commands .spawn(( NodeBundle { style: Style { left: Val::Px(0.0), bottom: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Percent(100.0), align_items: AlignItems::Start, justify_content: JustifyContent::Center, flex_direction: FlexDirection::Column, ..default() }, ..default() }, Root, )) .id(); let menu = commands .spawn(( NodeBundle { background_color: theme.menu_bg.into(), border_color: theme.btn_border.into(), style: Style { border: UiRect::all(Val::Px(1.0)), width: Val::Percent(100.), height: Val::Percent(5.), padding: UiRect { left: Val::Px(10.), ..default() }, align_items: AlignItems::Center, justify_content: JustifyContent::Start, ..default() }, ..default() }, Menu, )) .id(); let new_doc = add_menu_button( &mut commands, &theme, "New Document".to_string(), &icon_font, NewDoc, ); let save_doc = add_menu_button( &mut commands, &theme, "Save Document".to_string(), &icon_font, SaveDoc, ); #[cfg(not(target_arch = "wasm32"))] let export_file = add_menu_button( &mut commands, &theme, "Export To File".to_string(), &icon_font, ExportToFile, ); #[cfg(not(target_arch = "wasm32"))] let import_file = add_menu_button( &mut commands, &theme, "Import From File".to_string(), &icon_font, ImportFromFile, ); #[cfg(not(target_arch = "wasm32"))] let import_url = add_menu_button( &mut commands, &theme, "Import From URL".to_string(), &icon_font, ImportFromUrl, ); #[cfg(target_arch = "wasm32")] let set_window_prop = add_menu_button( &mut commands, &theme, "Save Document to window.velo object".to_string(), &icon_font, super::SetWindowProperty, ); commands.entity(menu).add_child(new_doc); commands.entity(menu).add_child(save_doc); #[cfg(not(target_arch = "wasm32"))] commands.entity(menu).add_child(export_file); #[cfg(not(target_arch = "wasm32"))] commands.entity(menu).add_child(import_file); #[cfg(not(target_arch = "wasm32"))] commands.entity(menu).add_child(import_url); if app_state.github_token.is_some() { let share_doc = add_menu_button( &mut commands, &theme, "Share Document (copy URL to clipboard)".to_string(), &icon_font, ShareDoc, ); commands.entity(menu).add_child(share_doc); } #[cfg(target_arch = "wasm32")] commands.entity(menu).add_child(set_window_prop); let theme_key = get_theme_key(&pkv); let theme_msg = if theme_key == "light" { "Enable dark theme (restart is required for now)".to_string() } else { "Enable light theme (restart is required for now)".to_string() }; let change_theme = add_menu_button(&mut commands, &theme, theme_msg, &icon_font, ChangeTheme); commands.entity(menu).add_child(change_theme); let main_bottom = commands .spawn(NodeBundle { style: Style { width: Val::Percent(100.), height: Val::Percent(95.), align_items: AlignItems::Center, justify_content: JustifyContent::Center, ..default() }, ..default() }) .id(); let left_panel = commands .spawn(( NodeBundle { background_color: theme.left_panel_bg.into(), border_color: theme.btn_border.into(), style: Style { border: UiRect::all(Val::Px(1.0)), width: Val::Percent(15.), height: Val::Percent(100.), align_items: AlignItems::Start, flex_direction: FlexDirection::Column, justify_content: JustifyContent::Center, ..default() }, ..default() }, LeftPanel, )) .id(); let right_panel = commands .spawn((NodeBundle { style: Style { width: Val::Percent(85.), height: Val::Percent(100.), align_items: AlignItems::Center, justify_content: JustifyContent::Center, flex_direction: FlexDirection::Column, ..default() }, ..default() },)) .id(); let main_panel = commands .spawn(( ButtonBundle { background_color: Color::NONE.into(), style: Style { width: Val::Percent(100.), height: Val::Percent(100.), align_items: AlignItems::Center, justify_content: JustifyContent::Center, overflow: Overflow::clip(), ..default() }, ..default() }, MainPanel, )) .id(); commands.entity(right_panel).add_child(main_panel); commands.entity(right_panel).add_child(bottom_panel); let left_panel_controls = commands .spawn(( NodeBundle { style: Style { padding: UiRect::all(Val::Px(10.)), width: Val::Percent(100.), height: Val::Percent(40.), flex_direction: FlexDirection::Column, align_items: AlignItems::Center, justify_content: JustifyContent::Center, ..default() }, ..default() }, LeftPanelControls, )) .id(); #[cfg(not(target_arch = "wasm32"))] let search_box = add_search_box( &mut commands, &theme, &mut cosmic_fonts, cosmic_font_handle, primary_window.scale_factor() as f32, ); let left_panel_explorer = commands .spawn(( NodeBundle { style: Style { width: Val::Percent(100.), height: Val::Percent(60.), flex_direction: FlexDirection::Column, align_items: AlignItems::Center, justify_content: JustifyContent::Center, ..default() }, ..default() }, LeftPanelExplorer, )) .id(); #[cfg(not(target_arch = "wasm32"))] commands.entity(left_panel_explorer).add_child(search_box); commands.entity(left_panel_explorer).add_child(docs); commands.entity(left_panel).add_child(left_panel_controls); commands.entity(left_panel).add_child(left_panel_explorer); let rectangle_creation = node_manipulation( &mut commands, &theme, &icon_font, ButtonAction { button_type: ui_helpers::ButtonTypes::AddRec, }, ButtonAction { button_type: ui_helpers::ButtonTypes::AddCircle, }, ButtonAction { button_type: ui_helpers::ButtonTypes::AddPaper, }, ButtonAction { button_type: ui_helpers::ButtonTypes::Del, }, ); let fron_back = commands .spawn((NodeBundle { style: Style { align_items: AlignItems::Center, width: Val::Percent(90.), height: Val::Percent(9.), margin: UiRect::all(Val::Px(5.)), justify_content: JustifyContent::Start, ..default() }, ..default() },)) .id(); let front = add_front_back( &mut commands, &theme, &asset_server, ButtonAction { button_type: ui_helpers::ButtonTypes::Front, }, ); let back = add_front_back( &mut commands, &theme, &asset_server, ButtonAction { button_type: ui_helpers::ButtonTypes::Back, }, ); commands.entity(fron_back).add_child(front); commands.entity(fron_back).add_child(back); let color_picker = commands .spawn((NodeBundle { style: Style { align_items: AlignItems::Center, width: Val::Percent(90.), height: Val::Percent(9.), margin: UiRect::all(Val::Px(5.)), justify_content: JustifyContent::Start, ..default() }, ..default() },)) .id(); let color1 = add_color(&mut commands, &theme, pair_struct!(theme.color_change_1)); let color2 = add_color(&mut commands, &theme, pair_struct!(theme.color_change_2)); let color3 = add_color(&mut commands, &theme, pair_struct!(theme.color_change_3)); let color4 = add_color(&mut commands, &theme, pair_struct!(theme.color_change_4)); let color5 = add_color(&mut commands, &theme, pair_struct!(theme.color_change_5)); commands.entity(color_picker).add_child(color1); commands.entity(color_picker).add_child(color2); commands.entity(color_picker).add_child(color3); commands.entity(color_picker).add_child(color4); commands.entity(color_picker).add_child(color5); let arrow_modes = commands .spawn((NodeBundle { style: Style { align_items: AlignItems::Center, width: Val::Percent(90.), height: Val::Percent(9.), margin: UiRect::all(Val::Px(5.)), justify_content: JustifyContent::Start, ..default() }, ..default() },)) .id(); let arrow1 = add_arrow( &mut commands, &theme, &asset_server, ArrowMode { arrow_type: ArrowType::Line, }, ); let arrow2 = add_arrow( &mut commands, &theme, &asset_server, ArrowMode { arrow_type: ArrowType::Arrow, }, ); let arrow3 = add_arrow( &mut commands, &theme, &asset_server, ArrowMode { arrow_type: ArrowType::DoubleArrow, }, ); let arrow4 = add_arrow( &mut commands, &theme, &asset_server, ArrowMode { arrow_type: ArrowType::ParallelLine, }, ); let arrow5 = add_arrow( &mut commands, &theme, &asset_server, ArrowMode { arrow_type: ArrowType::ParallelArrow, }, ); let arrow6 = add_arrow( &mut commands, &theme, &asset_server, ArrowMode { arrow_type: ArrowType::ParallelDoubleArrow, }, ); commands.entity(arrow_modes).add_child(arrow1); commands.entity(arrow_modes).add_child(arrow2); commands.entity(arrow_modes).add_child(arrow3); commands.entity(arrow_modes).add_child(arrow4); commands.entity(arrow_modes).add_child(arrow5); commands.entity(arrow_modes).add_child(arrow6); let text_modes = commands .spawn((NodeBundle { style: Style { align_items: AlignItems::Center, width: Val::Percent(90.), height: Val::Percent(9.), margin: UiRect::all(Val::Px(5.)), justify_content: JustifyContent::Start, ..default() }, ..default() },)) .id(); let text_pos1 = add_text_pos( &mut commands, &theme, TextPosMode { text_pos: TextPos::Center, }, "Center Text".to_string(), &icon_font, ); let text_pos2 = add_text_pos( &mut commands, &theme, TextPosMode { text_pos: TextPos::TopLeft, }, "Top Left Text".to_string(), &icon_font, ); commands.entity(text_modes).add_child(text_pos1); commands.entity(text_modes).add_child(text_pos2); let visibility = commands .spawn((NodeBundle { style: Style { align_items: AlignItems::Center, width: Val::Percent(90.), height: Val::Percent(9.), margin: UiRect::all(Val::Px(5.)), justify_content: JustifyContent::Start, ..default() }, ..default() },)) .id(); let show_children = add_visibility( &mut commands, &theme, ButtonAction { button_type: ui_helpers::ButtonTypes::ShowChildren, }, "Show children notes".to_string(), &icon_font, ); let hide_notes = add_visibility( &mut commands, &theme, ButtonAction { button_type: ui_helpers::ButtonTypes::HideChildren, }, "Hide children notes".to_string(), &icon_font, ); let show_random = add_visibility( &mut commands, &theme, ButtonAction { button_type: ui_helpers::ButtonTypes::ShowRandom, }, "Show random note".to_string(), &icon_font, ); commands.entity(visibility).add_child(show_children); commands.entity(visibility).add_child(hide_notes); commands.entity(visibility).add_child(show_random); let left_panel_bottom = commands .spawn((NodeBundle { style: Style { flex_direction: FlexDirection::Column, width: Val::Percent(90.), height: Val::Percent(10.), margin: UiRect { left: Val::Px(5.), right: Val::Px(5.), top: Val::Px(5.), bottom: Val::Px(20.), }, ..default() }, ..default() },)) .id(); let pencil_panel = commands.spawn(NodeBundle::default()).id(); #[cfg(not(target_arch = "wasm32"))] { let effect = add_effect(&mut commands, &theme, &icon_font, ParticlesEffect); commands.entity(pencil_panel).add_child(effect); } let pencil = add_pencil(&mut commands, &theme, &icon_font, DrawPencil); commands.entity(pencil_panel).add_child(pencil); commands.entity(left_panel_bottom).add_child(pencil_panel); let two_points_draw = commands.spawn(NodeBundle::default()).id(); let draw_line = add_two_points_draw( &mut commands, &theme, &icon_font, TwoPointsDraw { drawing_type: ui_helpers::TwoPointsDrawType::Line, }, ); commands.entity(two_points_draw).add_child(draw_line); let draw_circle = add_two_points_draw( &mut commands, &theme, &icon_font, TwoPointsDraw { drawing_type: ui_helpers::TwoPointsDrawType::Rhombus, }, ); commands.entity(two_points_draw).add_child(draw_circle); let draw_rect = add_two_points_draw( &mut commands, &theme, &icon_font, TwoPointsDraw { drawing_type: ui_helpers::TwoPointsDrawType::Rectangle, }, ); commands.entity(two_points_draw).add_child(draw_rect); let draw_arrow = add_two_points_draw( &mut commands, &theme, &icon_font, TwoPointsDraw { drawing_type: ui_helpers::TwoPointsDrawType::Arrow, }, ); commands.entity(two_points_draw).add_child(draw_arrow); let add_text = add_text( &mut commands, &theme, &icon_font, ButtonAction { button_type: ui_helpers::ButtonTypes::AddText, }, ); commands.entity(two_points_draw).add_child(add_text); commands .entity(left_panel_bottom) .add_child(two_points_draw); commands .entity(left_panel_controls) .add_child(rectangle_creation); commands.entity(left_panel_controls).add_child(color_picker); commands.entity(left_panel_controls).add_child(arrow_modes); commands.entity(left_panel_controls).add_child(text_modes); commands.entity(left_panel_controls).add_child(fron_back); commands.entity(left_panel_controls).add_child(visibility); commands .entity(left_panel_controls) .add_child(left_panel_bottom); commands.entity(main_bottom).add_child(left_panel); commands.entity(main_bottom).add_child(right_panel); commands.entity(root_ui).add_child(menu); commands.entity(root_ui).add_child(main_bottom); } ================================================ FILE: src/ui_plugin/systems/init_layout/node_manipulation.rs ================================================ use bevy::{prelude::*, text::BreakLineOn}; use crate::{ themes::Theme, ui_plugin::ui_helpers::{get_tooltip, ButtonAction, GenericButton, Tooltip, TooltipPosition}, }; pub fn node_manipulation( commands: &mut Commands, theme: &Res, icon_font: &Handle, create_rec_component: ButtonAction, create_circle_component: ButtonAction, papernote_component: ButtonAction, delete_component: ButtonAction, ) -> Entity { let node = commands .spawn(NodeBundle { style: Style { align_items: AlignItems::Center, width: Val::Percent(90.), height: Val::Percent(12.), margin: UiRect::all(Val::Px(5.)), justify_content: JustifyContent::Start, ..default() }, ..default() }) .id(); let top_new_paper = add_button_action( commands, theme, "New Papernote".to_string(), icon_font, "\u{eb54}".to_string(), theme.paper_node_bg, papernote_component, ); let top_new_rec = add_button_action( commands, theme, "New Rectangle".to_string(), icon_font, "\u{eb54}".to_string(), theme.node_manipulation, create_rec_component, ); let top_new_circle = add_button_action( commands, theme, "New Circle".to_string(), icon_font, "\u{ef4a}".to_string(), theme.node_manipulation, create_circle_component, ); let top_del = add_button_action( commands, theme, "Delete element".to_string(), icon_font, "\u{e872}".to_string(), theme.node_manipulation, delete_component, ); commands.entity(node).add_child(top_del); commands.entity(node).add_child(top_new_circle); commands.entity(node).add_child(top_new_rec); commands.entity(node).add_child(top_new_paper); node } fn add_button_action( commands: &mut Commands, theme: &Res, label: String, icon_font: &Handle, icon_code: String, icon_color: Color, button_action: ButtonAction, ) -> Entity { let top = commands .spawn(NodeBundle { background_color: theme.shadow.into(), style: Style { flex_direction: FlexDirection::Column, align_self: AlignSelf::Stretch, margin: UiRect::all(Val::Px(5.)), width: Val::Percent(23.), height: Val::Percent(100.), ..default() }, ..default() }) .id(); let new_button_action = commands .spawn(( ButtonBundle { background_color: theme.node_manipulation_bg.into(), style: Style { width: Val::Percent(100.), height: Val::Percent(100.), align_items: AlignItems::Center, justify_content: JustifyContent::Center, position_type: PositionType::Absolute, left: Val::Px(1.), right: Val::Auto, top: Val::Px(-1.), bottom: Val::Auto, ..default() }, ..default() }, button_action, GenericButton, )) .with_children(|builder| { builder.spawn((get_tooltip(theme, label, TooltipPosition::Bottom), Tooltip)); let text_style = TextStyle { font_size: 25.0, color: icon_color, font: icon_font.clone(), }; let text = Text { sections: vec![TextSection { value: icon_code, style: text_style, }], alignment: TextAlignment::Left, linebreak_behavior: BreakLineOn::WordBoundary, }; let text_bundle_style = Style { position_type: PositionType::Absolute, padding: UiRect::all(Val::Px(5.)), margin: UiRect::all(Val::Px(3.)), ..default() }; builder.spawn(TextBundle { text, style: text_bundle_style, ..default() }); }) .id(); commands.entity(top).add_child(new_button_action); top } ================================================ FILE: src/ui_plugin/systems/interactive_sprites.rs ================================================ use bevy::{prelude::*, window::PrimaryWindow}; use crate::{components::MainCamera, utils::get_timestamp}; use std::time::Duration; use super::{ ui_helpers::{Drawing, InteractiveNode}, NodeInteraction, NodeInteractionType, }; #[derive(Default, Debug)] pub struct HoldingState { duration: Duration, entity: Option, is_holding: bool, } pub fn interactive_node( windows: Query<&Window, With>, buttons: Res>, res_images: Res>, mut sprite_query: Query< (&Sprite, &Handle, &GlobalTransform, Entity), With, >, drawing_query: Query< (&Drawing<(String, Color)>, &GlobalTransform, Entity), With, >, camera_q: Query<(&Camera, &GlobalTransform), With>, mut node_interaction_events: EventWriter, mut double_click: Local<(Duration, Option)>, mut holding_state: Local, ) { let (camera, camera_transform) = camera_q.single(); let primary_window = windows.single(); let scale_factor = primary_window.scale_factor() as f32; let mut active_entity = None; for (drawing, node_transform, entity) in drawing_query.iter() { let (mut x_min, mut x_max, mut y_min, mut y_max) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN); for point in &drawing.points { x_min = x_min.min(point.x); x_max = x_max.max(point.x); y_min = y_min.min(point.y); y_max = y_max.max(point.y); } x_min += node_transform.affine().translation.x; x_max += node_transform.affine().translation.x; x_min = x_min.min(x_max - 5.); y_min += node_transform.affine().translation.y; y_max += node_transform.affine().translation.y; y_min = y_min.min(y_max - 5.); let z_current = node_transform.affine().translation.z; if let Some(pos) = primary_window.cursor_position() { if let Some(pos) = camera.viewport_to_world_2d(camera_transform, pos) { if x_min < pos.x && pos.x < x_max && y_min < pos.y && pos.y < y_max { if let Some((_, z)) = active_entity { if z < z_current { active_entity = Some((entity, z_current)); } } else { active_entity = Some((entity, node_transform.affine().translation.z)); } } }; } } for (sprite, handle, node_transform, entity) in &mut sprite_query.iter_mut() { let size = match sprite.custom_size { Some(size) => (size.x, size.y), None => { if let Some(sprite_image) = res_images.get(handle) { ( sprite_image.size().x / scale_factor, sprite_image.size().y / scale_factor, ) } else { (1., 1.) } } }; let x_min = node_transform.affine().translation.x - size.0 / 2.; let y_min = node_transform.affine().translation.y - size.1 / 2.; let x_max = node_transform.affine().translation.x + size.0 / 2.; let y_max = node_transform.affine().translation.y + size.1 / 2.; let z_current = node_transform.affine().translation.z; if let Some(pos) = primary_window.cursor_position() { if let Some(pos) = camera.viewport_to_world_2d(camera_transform, pos) { if x_min < pos.x && pos.x < x_max && y_min < pos.y && pos.y < y_max { if let Some((_, z)) = active_entity { if z < z_current { active_entity = Some((entity, z_current)); } } else { active_entity = Some((entity, node_transform.affine().translation.z)); } } }; } } if let Some((active, _)) = active_entity { let now_ms = get_timestamp(); let mut is_hover = true; if buttons.just_pressed(MouseButton::Left) { is_hover = false; if double_click.1 == Some(active) && Duration::from_millis(now_ms as u64) - double_click.0 < Duration::from_millis(500) { node_interaction_events.send(NodeInteraction { entity: active, node_interaction_type: NodeInteractionType::LeftDoubleClick, }); } else { node_interaction_events.send(NodeInteraction { entity: active, node_interaction_type: NodeInteractionType::LeftClick, }); *double_click = (Duration::from_millis(now_ms as u64), Some(active)); *holding_state = HoldingState { duration: Duration::from_millis(now_ms as u64), entity: Some(active), is_holding: false, }; } } if buttons.just_pressed(MouseButton::Right) { is_hover = false; node_interaction_events.send(NodeInteraction { entity: active, node_interaction_type: NodeInteractionType::RightClick, }); } if buttons.pressed(MouseButton::Left) && !holding_state.is_holding && Duration::from_millis(now_ms as u64) - holding_state.duration > Duration::from_millis(100) && holding_state.entity.is_some() { is_hover = false; holding_state.is_holding = true; node_interaction_events.send(NodeInteraction { entity: active, node_interaction_type: NodeInteractionType::LeftMouseHoldAndDrag, }); } if buttons.just_released(MouseButton::Left) { *holding_state = HoldingState { is_holding: false, duration: Duration::ZERO, entity: None, }; node_interaction_events.send(NodeInteraction { entity: active, node_interaction_type: NodeInteractionType::LeftMouseRelease, }); } if is_hover { node_interaction_events.send(NodeInteraction { entity: active, node_interaction_type: NodeInteractionType::Hover, }); } } else if buttons.just_released(MouseButton::Left) { *holding_state = HoldingState { is_holding: false, duration: Duration::ZERO, entity: None, }; node_interaction_events.send(NodeInteraction { entity: Entity::PLACEHOLDER, node_interaction_type: NodeInteractionType::LeftMouseRelease, }); } } ================================================ FILE: src/ui_plugin/systems/keyboard.rs ================================================ #![allow(clippy::duplicate_mod)] use bevy::{ prelude::*, render::render_resource::{Extent3d, TextureDimension, TextureFormat}, window::PrimaryWindow, }; use bevy_cosmic_edit::{ get_cosmic_text, get_text_spans, ActiveEditor, CosmicEdit, CosmicEditHistory, EditHistoryItem, }; use bevy_prototype_lyon::prelude::{PathBuilder, ShapeBundle, Stroke}; use cosmic_text::Edit; #[cfg(not(target_arch = "wasm32"))] use image::*; use std::{collections::VecDeque, convert::TryInto}; use uuid::Uuid; use crate::{ components::MainCamera, resources::{LoadTabRequest, SaveTabRequest}, themes::Theme, utils::{bevy_color_to_cosmic, ReflectableUuid}, AddRect, JsonNode, JsonNodeText, NodeType, UiState, }; use super::ui_helpers::{Drawing, EditableText, InteractiveNode, VeloNode}; use crate::resources::{AppState, SaveDocRequest}; #[path = "../../macros.rs"] #[macro_use] mod macros; pub fn keyboard_input_system( mut commands: Commands, mut images: ResMut>, mut app_state: ResMut, mut ui_state: ResMut, mut events: EventWriter>, mut input: ResMut>, windows: Query<&Window, With>, mut editable_text_query: Query< (&EditableText, &mut CosmicEdit, &mut CosmicEditHistory), With, >, mut camera_proj_query: Query<&Transform, With>, theme: Res, mut copied_drawing: Local, f32)>>, mut drawing_q: Query< (&Drawing<(String, Color)>, &GlobalTransform), With>, >, velo_node_query: Query<(Entity, &VeloNode)>, ) { let camera_transform = camera_proj_query.single_mut(); let x = camera_transform.translation.x; let y = camera_transform.translation.y; let primary_window = windows.single(); let scale_factor = primary_window.scale_factor(); #[cfg(target_os = "macos")] let command = input.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]); #[cfg(not(target_os = "macos"))] let command = input.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]); let shift = input.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); if command && input.just_pressed(KeyCode::C) { if ui_state.entity_to_draw_selected.is_some() { for (drawing, gt) in &mut drawing_q.iter_mut() { if drawing.id == ui_state.entity_to_draw_selected.unwrap() { *copied_drawing = Some((drawing.clone(), gt.affine().translation.z)); } } } else { *copied_drawing = None; } } else if command && input.just_pressed(KeyCode::V) { #[cfg(not(target_arch = "wasm32"))] insert_from_clipboard(&mut images, &mut events, x, y, scale_factor, &theme); if let Some((copied_drawing, z_index)) = copied_drawing.clone() { let mut path_builder = PathBuilder::new(); let mut points_iter = copied_drawing.points.iter(); let start = points_iter.next().unwrap(); path_builder.move_to(*start); path_builder.line_to(*start); for point in points_iter { path_builder.line_to(*point); } let path = path_builder.build(); commands.spawn(( ShapeBundle { path, transform: Transform::from_xyz(x, y, z_index + 0.01), ..Default::default() }, Stroke::new(copied_drawing.drawing_color.1, 2.), Drawing { id: ReflectableUuid::generate(), points: copied_drawing.points.clone(), drawing_color: copied_drawing.drawing_color, }, InteractiveNode, )); } } else if command && shift && input.just_pressed(KeyCode::S) { commands.insert_resource(SaveDocRequest { doc_id: app_state.current_document.unwrap(), path: None, }); } else if command && input.just_pressed(KeyCode::S) { if let Some(current_doc) = app_state.docs.get(&app_state.current_document.unwrap()) { if let Some(active_tab) = current_doc.tabs.iter().find(|t| t.is_active) { commands.insert_resource(SaveTabRequest { doc_id: app_state.current_document.unwrap(), tab_id: active_tab.id, }); } } } else if command && input.just_pressed(KeyCode::L) { if let Some(current_doc) = app_state.docs.get(&app_state.current_document.unwrap()) { if let Some(active_tab) = current_doc.tabs.iter().find(|t| t.is_active) { commands.insert_resource(LoadTabRequest { doc_id: app_state.current_document.unwrap(), tab_id: active_tab.id, drop_last_checkpoint: true, }); } } } else if command && input.just_pressed(KeyCode::P) { events.send(AddRect { node: JsonNode { id: Uuid::new_v4(), node_type: NodeType::Paper, x, y, width: theme.node_width, height: theme.node_height, text: JsonNodeText { text: "".to_string(), pos: crate::TextPos::Center, }, bg_color: pair_struct!(theme.paper_node_bg), ..Default::default() }, image: None, }); input.release_all() } else if command && input.just_pressed(KeyCode::R) { events.send(AddRect { node: JsonNode { id: Uuid::new_v4(), node_type: NodeType::Rect, x, y, width: theme.node_width, height: theme.node_height, text: JsonNodeText { text: "".to_string(), pos: crate::TextPos::Center, }, bg_color: pair_struct!(theme.node_bg), ..default() }, image: None, }); input.release_all() } else if command && input.just_pressed(KeyCode::O) { events.send(AddRect { node: JsonNode { id: Uuid::new_v4(), node_type: NodeType::Circle, x, y, width: theme.node_width, height: theme.node_height, text: JsonNodeText { text: "".to_string(), pos: crate::TextPos::Center, }, bg_color: pair_struct!(theme.node_bg), ..default() }, image: None, }); input.release_all() } else if input.just_pressed(KeyCode::Delete) { if let Some(id) = ui_state.entity_to_edit { for (entity, node) in velo_node_query.iter() { if node.id == id { commands.entity(entity).despawn_recursive(); } } } input.release_all() } else { for (editable_text, mut cosmic_edit, mut cosmit_edit_history) in &mut editable_text_query.iter_mut() { if [ui_state.tab_to_edit, ui_state.doc_to_edit].contains(&Some(editable_text.id)) { if input.any_just_pressed([KeyCode::Escape, KeyCode::Return]) { commands.insert_resource(ActiveEditor { entity: None }); cosmic_edit.readonly = true; let mut current_cursor = cosmic_edit.editor.cursor(); if ui_state.doc_to_edit.is_some() { current_cursor.color = Some(bevy_color_to_cosmic(theme.doc_list_bg)); cosmic_edit.editor.set_cursor(current_cursor); } if ui_state.tab_to_edit.is_some() { current_cursor.color = Some(bevy_color_to_cosmic(theme.add_tab_bg)); cosmic_edit.editor.set_cursor(current_cursor); } let mut edits = VecDeque::new(); edits.push_back(EditHistoryItem { cursor: current_cursor, lines: get_text_spans( cosmic_edit.editor.buffer(), cosmic_edit.attrs.clone(), ), }); *cosmit_edit_history = CosmicEditHistory { edits, current_edit: 0, }; cosmic_edit.editor.buffer_mut().set_redraw(true); *ui_state = UiState::default(); } if let Some(doc_id) = ui_state.doc_to_edit { let doc = app_state.docs.get_mut(&doc_id).unwrap(); doc.name = get_cosmic_text(cosmic_edit.editor.buffer()) } if let Some(tab_id) = ui_state.tab_to_edit { if let Some(doc_id) = app_state.current_document { let doc = app_state.docs.get_mut(&doc_id).unwrap(); if let Some(tab) = doc.tabs.iter_mut().find(|x| x.id == tab_id) { tab.name = get_cosmic_text(cosmic_edit.editor.buffer()) } } } } } } } #[cfg(not(target_arch = "wasm32"))] pub fn insert_from_clipboard( images: &mut ResMut>, events: &mut EventWriter>, x: f32, y: f32, scale_factor: f64, theme: &Res, ) { if let Ok(mut clipboard) = arboard::Clipboard::new() { if let Ok(image) = clipboard.get_image() { let image: RgbaImage = ImageBuffer::from_raw( image.width.try_into().unwrap(), image.height.try_into().unwrap(), image.bytes.into_owned(), ) .unwrap(); let width = image.width(); let height = image.height(); let size: Extent3d = Extent3d { width, height, ..Default::default() }; let image = Image::new( size, TextureDimension::D2, image.to_vec(), TextureFormat::Rgba8UnormSrgb, ); let image = images.add(image); events.send(AddRect { node: JsonNode { visible: true, id: Uuid::new_v4(), node_type: crate::NodeType::Rect, x, y, width: width as f32 / scale_factor as f32, height: height as f32 / scale_factor as f32, text: crate::JsonNodeText { text: "".to_string(), pos: crate::TextPos::Center, }, bg_color: pair_struct!(theme.clipboard_image_bg), z: 0., }, image: Some(image), }); } } } ================================================ FILE: src/ui_plugin/systems/load.rs ================================================ use base64::{engine::general_purpose, Engine}; use bevy::{ prelude::*, render::render_resource::{Extent3d, TextureDimension, TextureFormat}, window::PrimaryWindow, }; use bevy_cosmic_edit::CosmicFont; use bevy_prototype_lyon::prelude::{PathBuilder, ShapeBundle, Stroke}; use super::{ ui_helpers::{ add_tab, spawn_sprite_node, BottomPanel, Drawing, InteractiveNode, NodeMeta, TabContainer, VeloNode, }, DeleteDoc, DeleteTab, DrawingJsonNode, }; use crate::{canvas::arrow::events::CreateArrow, utils::load_doc_to_memory}; use crate::{ canvas::{arrow::components::ArrowMeta, shadows::CustomShadowMaterial}, resources::{FontSystemState, LoadTabRequest}, themes::Theme, }; use crate::resources::{AppState, LoadDocRequest}; use crate::utils::ReflectableUuid; use crate::{JsonNode, UiState}; use bevy_pkv::PkvStore; use image::{load_from_memory_with_format, ImageFormat}; use serde_json::{Map, Value}; pub fn should_load_doc(request: Option>) -> bool { request.is_some() } pub fn should_load_tab(request: Option>) -> bool { request.is_some() } pub fn remove_load_tab_request(world: &mut World) { world.remove_resource::().unwrap(); } pub fn remove_load_doc_request(world: &mut World) { world.remove_resource::().unwrap(); } pub fn load_doc( request: Res, mut app_state: ResMut, mut commands: Commands, mut bottom_panel: Query>, mut pkv: ResMut, asset_server: Res, mut tabs_query: Query>, mut delete_doc: Query<(&mut Visibility, &DeleteDoc), With>, theme: Res, mut cosmic_fonts: ResMut>, font_system_state: ResMut, windows: Query<&Window, With>, ) { let primary_window = windows.single(); let scale_factor = primary_window.scale_factor() as f32; let bottom_panel = bottom_panel.single_mut(); let doc_id = request.doc_id; for (mut visibility, doc) in delete_doc.iter_mut() { if doc.id == doc_id { *visibility = Visibility::Visible; } else { *visibility = Visibility::Hidden; } } load_doc_to_memory(doc_id, &mut app_state, &mut pkv); let mut tabs = vec![]; for entity in tabs_query.iter_mut() { commands.entity(entity).despawn_recursive(); } for tab in app_state.docs.get_mut(&doc_id).unwrap().tabs.iter() { let tab_view: Entity = add_tab( &mut commands, &mut cosmic_fonts, font_system_state.0.clone().unwrap(), &theme, &asset_server, tab.name.clone(), tab.id, tab.is_active, scale_factor, ); tabs.push(tab_view); if tab.is_active { commands.insert_resource(LoadTabRequest { doc_id, tab_id: tab.id, drop_last_checkpoint: false, }); } } commands.entity(bottom_panel).insert_children(0, &tabs); } pub fn load_tab( old_nodes: Query>, mut old_arrows: Query>, mut old_drawings: Query>>, request: Res, mut app_state: ResMut, mut ui_state: ResMut, mut commands: Commands, mut res_images: ResMut>, mut create_arrow: EventWriter, mut delete_tab: Query<(&mut Visibility, &DeleteTab), (With, Without)>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, mut windows: Query<&mut Window, With>, theme: Res, mut local_theme: Local>>, mut materials_meshes: (ResMut>, ResMut>), ) { *ui_state = UiState::default(); let value = serde_json::to_value(&*theme).unwrap(); if local_theme.is_none() || theme.is_changed() { *local_theme = Some(value.as_object().unwrap().clone()); } commands.insert_resource(bevy_cosmic_edit::ActiveEditor { entity: None }); let primary_window = windows.single_mut(); let scale_factor = primary_window.scale_factor() as f32; for entity in &mut old_arrows.iter_mut() { commands.entity(entity).despawn_recursive(); } for entity in &mut old_drawings.iter_mut() { commands.entity(entity).despawn_recursive(); } for entity in old_nodes.iter() { commands.entity(entity).despawn_recursive(); } let doc_id = request.doc_id; for (mut visibility, tab) in delete_tab.iter_mut() { if tab.id == request.tab_id { *visibility = Visibility::Visible; } else { *visibility = Visibility::Hidden; } } for tab in app_state.docs.get_mut(&doc_id).unwrap().tabs.iter_mut() { if tab.id == request.tab_id { if tab.checkpoints.is_empty() { break; } let json = if request.drop_last_checkpoint && tab.checkpoints.len() > 1 { tab.checkpoints.pop_back().unwrap() } else { tab.checkpoints.back().unwrap().clone() }; let mut json: Value = serde_json::from_str(&json).unwrap(); let images = json["images"].as_object().unwrap(); let nodes = json["nodes"].as_array().unwrap(); for node in nodes.iter() { let json_node: JsonNode = serde_json::from_value(node.clone()).unwrap(); let image: Option> = match images.get(&json_node.id.to_string()) { Some(image) => { let image_bytes = general_purpose::STANDARD .decode(image.as_str().unwrap().as_bytes()) .unwrap(); let img = load_from_memory_with_format(&image_bytes, ImageFormat::Png).unwrap(); let size: Extent3d = Extent3d { width: img.width(), height: img.height(), ..Default::default() }; let image = Image::new( size, TextureDimension::D2, img.into_bytes(), TextureFormat::Rgba8UnormSrgb, ); let image_handle = res_images.add(image); Some(image_handle) } None => None, }; let theme_color = local_theme .as_ref() .unwrap() .get(json_node.bg_color.as_str()) .unwrap(); let pair_bg_color = ( json_node.bg_color, serde_json::from_value(theme_color.clone()).unwrap(), ); let _ = spawn_sprite_node( &mut commands, &mut materials_meshes.0, &mut materials_meshes.1, &theme, &mut cosmic_fonts, font_system_state.0.clone().unwrap(), scale_factor, NodeMeta { size: (json_node.width, json_node.height), node_type: json_node.node_type, id: ReflectableUuid(json_node.id), image, text: json_node.text.text.clone(), pair_bg_color, position: (json_node.x, json_node.y, json_node.z), text_pos: json_node.text.pos, is_active: false, visible: json_node.visible, }, ); } let arrows = json["arrows"].as_array_mut().unwrap(); for arrow in arrows.iter() { let arrow_meta: ArrowMeta = serde_json::from_value(arrow.clone()).unwrap(); create_arrow.send(CreateArrow { visible: arrow_meta.visible, start: arrow_meta.start, end: arrow_meta.end, arrow_type: arrow_meta.arrow_type, }); } let drawings = json["drawings"].as_array_mut().unwrap(); for drawing in drawings.iter() { let drawing_json_node: DrawingJsonNode = serde_json::from_value(drawing.clone()).unwrap(); let mut path_builder = PathBuilder::new(); let mut points_iter = drawing_json_node.points.iter(); let start = points_iter.next().unwrap(); path_builder.move_to(*start); path_builder.line_to(*start); for point in points_iter { path_builder.line_to(*point); } let path = path_builder.build(); let theme_color = local_theme .as_ref() .unwrap() .get(drawing_json_node.drawing_color.as_str()) .unwrap(); let pair_color = ( drawing_json_node.drawing_color, serde_json::from_value(theme_color.clone()).unwrap(), ); commands.spawn(( ShapeBundle { path, transform: Transform::from_xyz( drawing_json_node.x, drawing_json_node.y, drawing_json_node.z, ), ..Default::default() }, Stroke::new(pair_color.1, 2.), Drawing { id: drawing_json_node.id, points: drawing_json_node.points.clone(), drawing_color: pair_color, }, InteractiveNode, )); } break; } } } ================================================ FILE: src/ui_plugin/systems/modal.rs ================================================ use std::collections::HashMap; use std::fs::canonicalize; use std::path::PathBuf; use bevy::prelude::*; use bevy::tasks::IoTaskPool; use bevy_cosmic_edit::{get_cosmic_text, ActiveEditor, CosmicEdit}; use bevy_pkv::PkvStore; use cosmic_text::Edit; use linkify::{LinkFinder, LinkKind}; use super::ui_helpers::{ModalCancel, ModalConfirm, ModalTop}; use super::{CommChannels, EditableText, ModalAction, TabContainer}; use crate::components::Doc; use crate::resources::{AppState, LoadDocRequest, LoadTabRequest, SaveDocRequest}; use crate::utils::ReflectableUuid; use crate::UiState; pub fn cancel_modal( mut commands: Commands, mut interaction_query: Query< (&Interaction, &ModalCancel), (Changed, With), >, mut state: ResMut, query: Query<(Entity, &ModalTop), With>, ) { for (interaction, path_modal_cancel) in interaction_query.iter_mut() { if let Interaction::Pressed = interaction { for (entity, path_modal_top) in query.iter() { if path_modal_cancel.id == path_modal_top.id { commands.entity(entity).despawn_recursive(); state.modal_id = None; } } } } } fn delete_doc( app_state: &mut ResMut, commands: &mut Commands, pkv: &mut ResMut, ) { let current_document = app_state.current_document.unwrap(); let id_to_remove = current_document; app_state.docs.remove(¤t_document); remove_from_storage(pkv, id_to_remove, app_state.current_document.unwrap()); app_state.current_document = app_state.docs.keys().next().cloned(); app_state.doc_list_ui.remove(&id_to_remove); commands.insert_resource(LoadDocRequest { doc_id: app_state.current_document.unwrap(), }); #[cfg(not(target_arch = "wasm32"))] { if let Some(index) = &app_state.search_index { let index = std::sync::Arc::new(index.index.clone()); let pool = IoTaskPool::get(); let id_to_remove = std::sync::Arc::new(id_to_remove); pool.spawn(async move { let _ = super::clear_doc_index(&index, &id_to_remove.0); }) .detach(); } } } fn delete_tab( app_state: &mut ResMut, commands: &mut Commands, query_container: &mut Query<(Entity, &TabContainer), With>, ) { let current_document = app_state.current_document.unwrap(); let tab_id = app_state .docs .get(¤t_document) .unwrap() .tabs .iter() .find(|x| x.is_active) .unwrap() .id; #[cfg(not(target_arch = "wasm32"))] if let Some(index) = &mut app_state.search_index { index.tabs_to_delete.insert(tab_id.0); } for (entity, tab) in query_container.iter_mut() { if tab.id == tab_id { commands.entity(entity).despawn_recursive(); break; } } let index = app_state .docs .get_mut(¤t_document) .unwrap() .tabs .iter() .position(|x| x.is_active) .unwrap(); app_state .docs .get_mut(¤t_document) .unwrap() .tabs .remove(index); let last_tab = app_state .docs .get_mut(¤t_document) .unwrap() .tabs .last_mut() .unwrap(); last_tab.is_active = true; commands.insert_resource(LoadTabRequest { doc_id: current_document, tab_id: last_tab.id, drop_last_checkpoint: false, }); } pub fn load_doc_handler( mut commands: Commands, mut app_state: ResMut, comm_channels: Res, pkv: Res, ) { if comm_channels.rx.is_empty() { return; } let r = comm_channels .rx .try_recv() .expect("Failed to receive document string"); let import_document: Doc = serde_json::from_str(&r).expect("Failed to deserialize document"); if let Ok(docs) = pkv.get::>("docs") { if docs.contains_key(&import_document.id) { return; } } app_state.current_document = Some(import_document.id); app_state.doc_list_ui.insert(import_document.id); app_state .docs .insert(import_document.id, import_document.clone()); commands.insert_resource(LoadDocRequest { doc_id: import_document.id, }); } pub fn confirm_modal( mut commands: Commands, mut interaction_query: Query< (&Interaction, &ModalConfirm), (Changed, With), >, mut app_state: ResMut, mut ui_state: ResMut, query_top: Query<(Entity, &ModalTop), With>, mut tab_query_container: Query<(Entity, &TabContainer), With>, mut pkv: ResMut, input: Res>, mut query_path: Query<(&CosmicEdit, &EditableText), With>, comm_channels: Res, ) { for (interaction, path_modal_confirm) in interaction_query.iter_mut() { if let Interaction::Pressed = interaction { for (entity, path_modal_top) in query_top.iter() { if path_modal_confirm.id == path_modal_top.id { for (editor, editable_text) in query_path.iter_mut() { let text = get_cosmic_text(editor.editor.buffer()); if editable_text.id == path_modal_top.id { match path_modal_confirm.action { ModalAction::SaveToFile => { commands.insert_resource(SaveDocRequest { doc_id: app_state.current_document.unwrap(), path: Some(PathBuf::from(text.trim())), }); break; } ModalAction::LoadFromFile => { if let Ok(path) = canonicalize(PathBuf::from(text.trim())) { let json = std::fs::read_to_string(path) .expect("Error reading document from file"); let cc = comm_channels.tx.clone(); cc.try_send(json).unwrap() } } ModalAction::LoadFromUrl => { let pool = IoTaskPool::get(); let url = text.trim(); let mut finder = LinkFinder::new(); finder.kinds(&[LinkKind::Url]); let links: Vec<_> = finder.links(url).collect(); if links.len() == 1 { let url = links.first().unwrap().as_str().to_owned(); let cc = comm_channels.tx.clone(); let task = pool.spawn(async move { let request = ehttp::Request::get(url); ehttp::fetch(request, move |result| { let json_string = result.unwrap().text().unwrap(); cc.try_send(json_string).unwrap(); }); }); task.detach(); } } ModalAction::DeleteDocument => {} ModalAction::DeleteTab => {} } } } match path_modal_confirm.action { ModalAction::SaveToFile => {} ModalAction::LoadFromFile => {} ModalAction::LoadFromUrl => {} ModalAction::DeleteDocument => { delete_doc(&mut app_state, &mut commands, &mut pkv); } ModalAction::DeleteTab => { delete_tab(&mut app_state, &mut commands, &mut tab_query_container); } } } commands.entity(entity).despawn_recursive(); ui_state.modal_id = None; commands.insert_resource(ActiveEditor { entity: None }); } } } if input.just_pressed(KeyCode::Return) { for (entity, path_modal_top) in query_top.iter() { if Some(path_modal_top.id) == ui_state.modal_id { for (editor, editable_text) in query_path.iter_mut() { let text = get_cosmic_text(editor.editor.buffer()); if editable_text.id == path_modal_top.id { match path_modal_top.action { ModalAction::SaveToFile => { commands.insert_resource(SaveDocRequest { doc_id: app_state.current_document.unwrap(), path: Some(PathBuf::from(text.trim())), }); break; } ModalAction::LoadFromFile => { if let Ok(path) = canonicalize(PathBuf::from(text.trim())) { let json = std::fs::read_to_string(path) .expect("Error reading document from file"); let cc = comm_channels.tx.clone(); cc.try_send(json).unwrap() } } ModalAction::LoadFromUrl => { let pool = IoTaskPool::get(); let url = text.trim(); let mut finder = LinkFinder::new(); finder.kinds(&[LinkKind::Url]); let links: Vec<_> = finder.links(url).collect(); if links.len() == 1 { let url = links.first().unwrap().as_str().to_owned(); let cc = comm_channels.tx.clone(); let task = pool.spawn(async move { let request = ehttp::Request::get(url); ehttp::fetch(request, move |result| { let json_string = result.unwrap().text().unwrap(); cc.try_send(json_string).unwrap(); }); }); task.detach(); } } ModalAction::DeleteDocument => {} ModalAction::DeleteTab => {} } } } match path_modal_top.action { ModalAction::SaveToFile => {} ModalAction::LoadFromFile => {} ModalAction::LoadFromUrl => {} ModalAction::DeleteDocument => { delete_doc(&mut app_state, &mut commands, &mut pkv); } ModalAction::DeleteTab => { delete_tab(&mut app_state, &mut commands, &mut tab_query_container) } } } commands.entity(entity).despawn_recursive(); ui_state.modal_id = None; commands.insert_resource(ActiveEditor { entity: None }); } } } fn remove_from_storage( pkv: &mut ResMut, id_to_remove: ReflectableUuid, new_id: ReflectableUuid, ) { if let Ok(mut docs) = pkv.get::>("docs") { if docs.remove(&id_to_remove).is_some() { pkv.set("docs", &docs).unwrap(); } } if let Ok(mut tags) = pkv.get::>>("tags") { if tags.remove(&id_to_remove).is_some() { pkv.set("tags", &tags).unwrap(); } } if let Ok(mut names) = pkv.get::>("names") { if names.remove(&id_to_remove).is_some() { pkv.set("names", &names).unwrap(); } } if let Ok(last_saved) = pkv.get::("last_saved") { if last_saved == id_to_remove { pkv.set("last_saved", &new_id).unwrap(); } } } ================================================ FILE: src/ui_plugin/systems/resize_node.rs ================================================ use super::{ ui_helpers::{ResizeMarker, VeloShape}, NodeInteraction, NodeType, RawText, RedrawArrow, VeloNode, }; use crate::{ canvas::{arrow::components::ArrowConnect, shadows::systems::Shadow}, components::MainCamera, UiState, }; use bevy::{prelude::*, window::PrimaryWindow}; use bevy_cosmic_edit::CosmicEdit; use bevy_prototype_lyon::prelude::Path; use cosmic_text::Edit; pub fn resize_entity_start( mut ui_state: ResMut, mut node_interaction_events: EventReader, mut windows: Query<&mut Window, With>, resize_marker_query: Query<(&ResizeMarker, &Parent, &mut Transform), With>, velo_node_query: Query<&VeloNode, With>, ) { let mut primary_window = windows.single_mut(); for event in node_interaction_events.iter() { if let Ok((resize_marker, parent, _)) = resize_marker_query.get(event.entity) { match event.node_interaction_type { super::NodeInteractionType::Hover => match *resize_marker { ResizeMarker::TopLeft => { primary_window.cursor.icon = CursorIcon::NwseResize; } ResizeMarker::TopRight => { primary_window.cursor.icon = CursorIcon::NeswResize; } ResizeMarker::BottomLeft => { primary_window.cursor.icon = CursorIcon::NeswResize; } ResizeMarker::BottomRight => { primary_window.cursor.icon = CursorIcon::NwseResize; } }, super::NodeInteractionType::LeftClick => {} super::NodeInteractionType::LeftDoubleClick => {} super::NodeInteractionType::LeftMouseHoldAndDrag => { let velo_node = velo_node_query.get(parent.get()).unwrap(); ui_state.entity_to_resize = Some(velo_node.id); } super::NodeInteractionType::RightClick => {} super::NodeInteractionType::LeftMouseRelease => {} } } } } pub fn resize_entity_end( mut ui_state: ResMut, mut node_interaction_events: EventReader, ) { for event in node_interaction_events.iter() { if event.node_interaction_type == super::NodeInteractionType::LeftMouseRelease && ui_state.entity_to_resize.is_some() { ui_state.entity_to_resize = None; } } } pub fn resize_entity_run( ui_state: ResMut, mut cursor_moved_events: EventReader, mut events: EventWriter, mut resize_marker_query: Query< (&ResizeMarker, &Parent, &mut Transform), (With, Without, Without), >, mut arrow_connector_query: Query< (&ArrowConnect, &mut Transform), (With, Without, Without), >, mut raw_text_query: Query< (&Parent, &RawText, &mut CosmicEdit, &mut Sprite), (With, Without), >, mut border_query: Query<(&Parent, &VeloShape, &mut Path), With>, mut velo_node_query: Query< (&mut Transform, &Children), (With, Without, Without), >, camera_q: Query<(&Camera, &GlobalTransform), With>, mut shadows_q: Query<(&mut Sprite, &Shadow), (With, Without)>, ) { let (camera, camera_transform) = camera_q.single(); if let Some(id) = ui_state.entity_to_resize { for (raw_text_parent, raw_text, mut cosmic_edit, mut sprite) in &mut raw_text_query.iter_mut() { if id != raw_text.id { continue; } let event = cursor_moved_events.iter().last(); if let Some(cursor_pos) = event .and_then(|event| camera.viewport_to_world_2d(camera_transform, event.position)) { let (border_parent, velo_border, mut path) = border_query.get_mut(raw_text_parent.get()).unwrap(); let (velo_transform, children) = velo_node_query.get_mut(border_parent.get()).unwrap(); let pos = velo_transform.translation.truncate(); let mut width = f32::max(((cursor_pos.x - pos.x).abs() * 2.).round(), 1.); let mut height = f32::max(((cursor_pos.y - pos.y).abs() * 2.).round(), 1.); if velo_border.node_type == NodeType::Circle { width = f32::max(width, height); height = f32::max(width, height); } if width % 2.0 != 0.0 { width += 1.0; } if height % 2.0 != 0.0 { height += 1.0; } cosmic_edit.width = width; cosmic_edit.height = height; sprite.custom_size = Some(Vec2::new(width, height)); cosmic_edit.editor.buffer_mut().set_redraw(true); for child in children.iter() { // update shadows sprite if let Ok((mut sprite, _)) = shadows_q.get_mut(*child) { sprite.custom_size = Some(Vec2::new(width, height)); } // update resize markers positions if let Ok(resize) = resize_marker_query.get_mut(*child) { let mut resize_transform = resize.2; match resize.0 { ResizeMarker::TopLeft => { resize_transform.translation.x = -width / 2.; resize_transform.translation.y = height / 2.; } ResizeMarker::TopRight => { resize_transform.translation.x = width / 2.; resize_transform.translation.y = height / 2.; } ResizeMarker::BottomLeft => { resize_transform.translation.x = -width / 2.; resize_transform.translation.y = -height / 2.; } ResizeMarker::BottomRight => { resize_transform.translation.x = width / 2.; resize_transform.translation.y = -height / 2.; } } } // update arrow connectors positions if let Ok(arrow_connect) = arrow_connector_query.get_mut(*child) { let mut arrow_transform = arrow_connect.1; match arrow_connect.0.pos { crate::canvas::arrow::components::ArrowConnectPos::Top => { arrow_transform.translation.x = 0.; arrow_transform.translation.y = height / 2.; } crate::canvas::arrow::components::ArrowConnectPos::Bottom => { arrow_transform.translation.x = 0.; arrow_transform.translation.y = -height / 2.; } crate::canvas::arrow::components::ArrowConnectPos::Left => { arrow_transform.translation.x = -width / 2.; arrow_transform.translation.y = 0.; } crate::canvas::arrow::components::ArrowConnectPos::Right => { arrow_transform.translation.x = width / 2.; arrow_transform.translation.y = 0.; } } } } // update size of bevy_lyon node let points = [ Vec2::new(-width / 2., -height / 2.), Vec2::new(-width / 2., height / 2.), Vec2::new(width / 2., height / 2.), Vec2::new(width / 2., -height / 2.), ]; let new_path = match velo_border.node_type { NodeType::Rect => bevy_prototype_lyon::prelude::GeometryBuilder::build_as( &bevy_prototype_lyon::shapes::RoundedPolygon { points: points.into_iter().collect(), closed: true, radius: 10., }, ), NodeType::Paper => bevy_prototype_lyon::prelude::GeometryBuilder::build_as( &bevy_prototype_lyon::shapes::Polygon { points: points.into_iter().collect(), closed: true, }, ), NodeType::Circle => bevy_prototype_lyon::prelude::GeometryBuilder::build_as( &bevy_prototype_lyon::shapes::Circle { radius: width / 2., center: Vec2::new(0., 0.), }, ), }; *path = new_path; events.send(RedrawArrow { id: raw_text.id }); } } } } ================================================ FILE: src/ui_plugin/systems/resize_window.rs ================================================ use bevy::{prelude::*, window::WindowResized}; use bevy_cosmic_edit::CosmicEdit; use cosmic_text::Edit; use crate::resources::AppState; use crate::resources::LoadTabRequest; use super::ui_helpers::DocListItemButton; use super::ui_helpers::TabButton; pub fn resize_notificator( mut commands: Commands, resize_event: Res>, app_state: Res, mut tabs: Query<&mut CosmicEdit, (With, Without)>, mut docs: Query<&mut CosmicEdit, (With, Without)>, ) { let mut reader = resize_event.get_reader(); let resize_events: Vec<_> = reader.iter(&resize_event).collect(); if !resize_events.is_empty() { if let Some(current_doc) = app_state.docs.get(&app_state.current_document.unwrap()) { if let Some(active_tab) = current_doc.tabs.iter().find(|t| t.is_active) { commands.insert_resource(LoadTabRequest { doc_id: current_doc.id, tab_id: active_tab.id, drop_last_checkpoint: false, }); } } for mut cosmic_edit in &mut tabs.iter_mut() { cosmic_edit.editor.buffer_mut().set_redraw(true); } for mut cosmic_edit in &mut docs.iter_mut() { cosmic_edit.editor.buffer_mut().set_redraw(true); } } } ================================================ FILE: src/ui_plugin/systems/save.rs ================================================ use base64::{engine::general_purpose, Engine}; use bevy::prelude::*; use bevy_cosmic_edit::CosmicEdit; use bevy_pkv::PkvStore; use bevy_prototype_lyon::prelude::Stroke; use image::*; use serde_json::json; use std::{collections::HashMap, io::Cursor}; use super::ui_helpers::{Drawing, VeloNode, VeloShape}; use super::{DrawingJsonNode, RawText, SaveStore}; use crate::canvas::arrow::components::ArrowMeta; use crate::components::Doc; use crate::resources::SaveDocRequest; use crate::resources::{AppState, SaveTabRequest}; use crate::utils::{load_doc_to_memory, ReflectableUuid}; use crate::{JsonNode, JsonNodeText, MAX_CHECKPOINTS}; pub fn should_save_doc(request: Option>) -> bool { request.is_some() } pub fn should_save_tab(request: Option>) -> bool { request.is_some() } pub fn remove_save_doc_request(world: &mut World) { world.remove_resource::().unwrap(); } pub fn remove_save_tab_request(world: &mut World) { world.remove_resource::().unwrap(); } pub fn save_doc( request: Res, mut app_state: ResMut, mut pkv: ResMut, mut commands: Commands, mut events: EventWriter, ) { let doc_id = request.doc_id; load_doc_to_memory(doc_id, &mut app_state, &mut pkv); for tab in app_state.docs.get_mut(&doc_id).unwrap().tabs.iter() { if tab.is_active { commands.insert_resource(SaveTabRequest { doc_id, tab_id: tab.id, }); } } // event is used for running save_tab logic before saving to store events.send(SaveStore { doc_id, path: request.path.clone(), }); } pub fn save_to_store( mut pkv: ResMut, mut app_state: ResMut, mut events: EventReader, ) { for event in events.iter() { let doc_id = event.doc_id; if let Ok(mut docs) = pkv.get::>("docs") { docs.insert(doc_id, app_state.docs.get(&doc_id).unwrap().clone()); pkv.set("docs", &docs).unwrap(); } else { let mut docs = HashMap::new(); docs.insert(doc_id, app_state.docs.get(&doc_id).unwrap().clone()); pkv.set("docs", &docs).unwrap(); } if let Ok(mut tags) = pkv.get::>>("tags") { let doc = app_state.docs.get(&doc_id).unwrap(); if let Some(tags) = tags.get_mut(&doc_id) { tags.append(&mut doc.tags.clone()); } else { tags.insert(doc.id, doc.tags.clone()); } pkv.set("tags", &tags).unwrap(); } else { let doc = app_state.docs.get(&doc_id).unwrap(); let mut tags = HashMap::new(); tags.insert(doc.id, doc.tags.clone()); pkv.set("tags", &tags).unwrap(); } if let Ok(mut names) = pkv.get::>("names") { let doc = app_state.docs.get(&doc_id).unwrap(); names.insert(doc.id, doc.name.clone()); pkv.set("names", &names).unwrap(); } else { let doc = app_state.docs.get(&doc_id).unwrap(); let mut names = HashMap::new(); names.insert(doc.id, doc.name.clone()); pkv.set("names", &names).unwrap(); } pkv.set("last_saved", &doc_id).unwrap(); if let Some(path) = event.path.clone() { let current_doc = app_state.docs.get(&doc_id).unwrap().clone(); std::fs::write(path, serde_json::to_string_pretty(¤t_doc).unwrap()) .expect("Error saving current document to file") } #[cfg(not(target_arch = "wasm32"))] { if let Some(index) = &mut app_state.search_index { let pool = bevy::tasks::IoTaskPool::get(); let tabs_to_delete = std::sync::Arc::new(index.tabs_to_delete.clone()); let node_updates = std::sync::Arc::new(index.node_updates.clone()); index.tabs_to_delete.clear(); index.node_updates.clear(); let index = std::sync::Arc::new(index.index.clone()); pool.spawn(async move { let _ = super::clear_tabs_index(&index, &tabs_to_delete); let _ = super::update_search_index(&index, &node_updates); }) .detach(); } } } } pub fn save_tab( images: Res>, arrows: Query<(&ArrowMeta, &Visibility), With>, request: Res, mut app_state: ResMut, raw_text_query: Query<(&RawText, &CosmicEdit, &Parent), With>, border_query: Query<(&Parent, &VeloShape), With>, velo_node_query: Query<(&Transform, &Visibility), With>, drawing_query: Query< (&Transform, &Drawing<(String, Color)>, &Stroke), With>, >, ) { #[cfg(not(target_arch = "wasm32"))] if let Some(index) = &mut app_state.search_index { index.tabs_to_delete.insert(request.tab_id.0); } let mut json = json!({ "images": {}, "nodes": [], "arrows": [], "drawings": [] }); let json_images = json["images"].as_object_mut().unwrap(); for (raw_text, cosmic_edit, _) in raw_text_query.iter() { if let Some(handle) = cosmic_edit.bg_image.clone() { let image = images.get(&handle).unwrap(); if let Ok(img) = image.clone().try_into_dynamic() { let mut image_data: Vec = Vec::new(); img.write_to(&mut Cursor::new(&mut image_data), ImageOutputFormat::Png) .unwrap(); let res_base64 = general_purpose::STANDARD.encode(image_data); json_images.insert(raw_text.id.0.to_string(), json!(res_base64)); } } } let json_nodes = json["nodes"].as_array_mut().unwrap(); for (raw_text, cosmic_edit, parent) in raw_text_query.iter() { let (border_parent, border) = border_query.get(parent.get()).unwrap(); let (top_transform, top_visibility) = velo_node_query.get(border_parent.get()).unwrap(); let x = top_transform.translation.x; let y = top_transform.translation.y; let z = top_transform.translation.z; let (width, height) = (cosmic_edit.width, cosmic_edit.height); let visible = top_visibility == Visibility::Visible; json_nodes.push(json!(JsonNode { visible, node_type: border.node_type.clone(), id: raw_text.id.0, x, y, z, width, height, bg_color: border.pair_color.0.clone(), text: JsonNodeText { text: raw_text.last_text.clone(), pos: cosmic_edit.text_pos.clone().into() }, })); #[cfg(not(target_arch = "wasm32"))] if let Some(index) = &mut app_state.search_index { index.node_updates.insert( super::NodeSearchLocation { doc_id: request.doc_id.0, tab_id: request.tab_id.0, node_id: raw_text.id.0, }, raw_text.last_text.clone(), ); } } let json_arrows = json["arrows"].as_array_mut().unwrap(); for (arrow_meta, visibility) in arrows.iter() { let mut meta = *arrow_meta; meta.visible = visibility == Visibility::Visible; json_arrows.push(json!(meta)); } let json_drawing = json["drawings"].as_array_mut().unwrap(); for (transform, drawing, stroke) in drawing_query.iter() { json_drawing.push(json!(DrawingJsonNode { x: transform.translation.x, y: transform.translation.y, z: transform.translation.z, width: stroke.options.line_width, id: drawing.id, points: drawing.points.clone(), drawing_color: drawing.drawing_color.0.clone() })); } let doc_id = request.doc_id; for tab in &mut app_state.docs.get_mut(&doc_id).unwrap().tabs { if request.tab_id == tab.id { if (tab.checkpoints.len() as i32) > MAX_CHECKPOINTS { tab.checkpoints.pop_front(); } if let Some(last) = tab.checkpoints.back() { if last == &json.to_string() { break; } } tab.checkpoints.push_back(json.to_string()); break; } } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] /// No PKV with tags fn test_save_doc1() { // Setup let mut app = App::new(); app.add_systems(Update, (save_doc, save_to_store.after(save_doc))); let temp_dir = tempdir().unwrap(); let temp_file_path = temp_dir.path().join("test_doc.json"); let doc_id = ReflectableUuid::generate(); let tab_id = ReflectableUuid::generate(); let mut app_state = AppState::default(); app_state.docs.insert( doc_id, Doc { id: doc_id, name: "test_doc".to_string(), tags: vec!["test_tag".to_string()], tabs: vec![crate::components::Tab { id: tab_id, is_active: true, name: "Test tab".to_string(), checkpoints: std::collections::VecDeque::new(), z_index: 1., }], }, ); let request = SaveDocRequest { doc_id, path: Some(temp_file_path.clone()), }; app.insert_resource(request); app.add_event::(); PkvStore::new("test", "test").clear().unwrap(); app.insert_resource(PkvStore::new("test", "test")); app.insert_resource(app_state); // Run systems app.update(); // Assertions let pkv = app.world.resource::(); let saved_docs: HashMap = pkv.get("docs").unwrap(); assert_eq!(saved_docs.get(&doc_id).unwrap().name, "test_doc"); assert!(saved_docs.get(&doc_id).unwrap().tabs[0].is_active); let saved_tags: HashMap> = pkv.get("tags").unwrap(); assert_eq!( saved_tags.get(&doc_id).unwrap(), &vec!["test_tag".to_string()] ); let saved_names: HashMap = pkv.get("names").unwrap(); assert_eq!(saved_names.get(&doc_id).unwrap(), "test_doc"); assert_eq!(pkv.get::("last_saved").unwrap(), doc_id); let file_contents = std::fs::read_to_string(temp_file_path).unwrap(); let saved_doc: Doc = serde_json::from_str(&file_contents).unwrap(); assert_eq!(saved_doc.name, "test_doc"); assert!(saved_doc.tabs[0].is_active); } #[test] ///the PKV store has tags, but not for the document being saved: fn test_save_doc2() { // Setup let mut app = App::new(); app.add_systems(Update, (save_doc, save_to_store.after(save_doc))); let temp_dir = tempdir().unwrap(); let temp_file_path = temp_dir.path().join("test_doc.json"); let doc_id = ReflectableUuid::generate(); let tab_id = ReflectableUuid::generate(); let mut app_state = AppState::default(); app_state.docs.insert( doc_id, Doc { id: doc_id, name: "test_doc".to_string(), tags: vec!["test_tag_1".to_string()], tabs: vec![crate::components::Tab { id: tab_id, is_active: true, z_index: 1., name: "Test tab".to_string(), checkpoints: std::collections::VecDeque::new(), }], }, ); let request = SaveDocRequest { doc_id, path: Some(temp_file_path.clone()), }; app.insert_resource(request); PkvStore::new("test", "test1").clear().unwrap(); let mut pkv = PkvStore::new("test", "test1"); let mut tags = HashMap::new(); tags.insert(ReflectableUuid::generate(), vec!["test_tag_2".to_string()]); pkv.set("tags", &tags).unwrap(); app.add_event::(); app.insert_resource(pkv); app.insert_resource(app_state); // Run systems app.update(); // Assertions let pkv = app.world.resource::(); let saved_docs: HashMap = pkv.get("docs").unwrap(); assert_eq!(saved_docs.get(&doc_id).unwrap().name, "test_doc"); assert!(saved_docs.get(&doc_id).unwrap().tabs[0].is_active); let saved_tags: HashMap> = pkv.get("tags").unwrap(); assert_eq!( saved_tags.get(&doc_id).unwrap(), &vec!["test_tag_1".to_string()] ); let saved_names: HashMap = pkv.get("names").unwrap(); assert_eq!(saved_names.get(&doc_id).unwrap(), "test_doc"); assert_eq!(pkv.get::("last_saved").unwrap(), doc_id); let file_contents = std::fs::read_to_string(temp_file_path).unwrap(); let saved_doc: Doc = serde_json::from_str(&file_contents).unwrap(); assert_eq!(saved_doc.name, "test_doc"); assert!(saved_doc.tabs[0].is_active); } #[test] /// the PKV store already has tags for the document being saved. fn test_save_doc3() { // Setup let mut app = App::new(); app.add_systems(Update, (save_doc, save_to_store.after(save_doc))); let temp_dir = tempdir().unwrap(); let temp_file_path = temp_dir.path().join("test_doc.json"); let doc_id = ReflectableUuid::generate(); let tab_id = ReflectableUuid::generate(); let mut app_state = AppState::default(); let existing_tags = vec!["test_tag_2".to_string(), "test_tag_1".to_string()]; app_state.docs.insert( doc_id, Doc { id: doc_id, name: "test_doc".to_string(), tags: vec!["test_tag_1".to_string()], tabs: vec![crate::components::Tab { id: tab_id, is_active: true, z_index: 1., name: "Test tab".to_string(), checkpoints: std::collections::VecDeque::new(), }], }, ); let request = SaveDocRequest { doc_id, path: Some(temp_file_path.clone()), }; app.insert_resource(request); PkvStore::new("test", "test3").clear().unwrap(); let mut pkv = PkvStore::new("test", "test3"); let mut tags = HashMap::new(); tags.insert(doc_id, vec!["test_tag_2".to_string()]); pkv.set("tags", &tags).unwrap(); app.add_event::(); app.insert_resource(pkv); app.insert_resource(app_state); // Run systems app.update(); // Assertions // Check that the document was saved to the PKV store let pkv = app.world.resource::(); let saved_docs: HashMap = pkv.get("docs").unwrap(); assert_eq!(saved_docs.get(&doc_id).unwrap().name, "test_doc"); assert!(saved_docs.get(&doc_id).unwrap().tabs[0].is_active); // Check that the tags were saved to the PKV store let saved_tags: HashMap> = pkv.get("tags").unwrap(); let expected_tags = existing_tags; assert_eq!(saved_tags.get(&doc_id).unwrap(), &expected_tags); // Check that the name was saved to the PKV store let saved_names: HashMap = pkv.get("names").unwrap(); assert_eq!(saved_names.get(&doc_id).unwrap(), "test_doc"); // Check that the last_saved field was updated in the PKV store assert_eq!(pkv.get::("last_saved").unwrap(), doc_id); // Check that the file was saved to the correct path let file_contents = std::fs::read_to_string(temp_file_path).unwrap(); let saved_doc: Doc = serde_json::from_str(&file_contents).unwrap(); assert_eq!(saved_doc.name, "test_doc"); assert!(saved_doc.tabs[0].is_active); } } ================================================ FILE: src/ui_plugin/systems/search.rs ================================================ use bevy::prelude::*; use bevy::window::PrimaryWindow; use bevy_cosmic_edit::get_cosmic_text; use bevy_cosmic_edit::ActiveEditor; use bevy_cosmic_edit::CosmicEdit; use bevy_pkv::PkvStore; use bevy_prototype_lyon::prelude::Stroke; use cosmic_text::Edit; use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use tantivy::collector::TopDocs; use tantivy::query::BooleanQuery; use tantivy::query::FuzzyTermQuery; use tantivy::query::Occur; use tantivy::ReloadPolicy; use tantivy::schema::*; use tantivy::Index; use uuid::Uuid; use crate::resources::AppState; use crate::themes::Theme; use crate::utils::ReflectableUuid; use crate::APP_NAME; use crate::ORG_NAME; use super::ui_helpers::SearchButton; use super::ui_helpers::SearchText; use super::ui_helpers::VeloShape; use super::NodeType; use super::UiState; pub struct SearchIndexState { pub index: Index, pub tabs_to_delete: HashSet, pub node_updates: HashMap, } #[derive(Eq, PartialEq, Hash, Clone)] pub struct NodeSearchLocation { pub doc_id: Uuid, pub tab_id: Uuid, pub node_id: Uuid, } pub fn search_box_click( mut commands: Commands, mut interaction_query: Query< (&Interaction, &SearchButton), (Changed, With), >, mut search_query: Query<(&SearchText, Entity), With>, mut state: ResMut, mut windows: Query<&mut Window, With>, ) { let mut primary_window = windows.single_mut(); for (interaction, node) in &mut interaction_query { match *interaction { Interaction::Pressed => { primary_window.cursor.icon = CursorIcon::Text; *state = UiState::default(); state.search_box_to_edit = Some(node.id); for (search_text, entity) in &mut search_query.iter_mut() { if search_text.id == node.id { commands.insert_resource(ActiveEditor { entity: Some(entity), }); break; } } } Interaction::Hovered => { primary_window.cursor.icon = CursorIcon::Hand; } Interaction::None => { primary_window.cursor.icon = CursorIcon::Default; } } } } pub fn search_box_text_changed( text_query: Query<&CosmicEdit, With>, mut velo_border: Query<(&mut Stroke, &VeloShape), With>, mut previous_search_text: Local, mut app_state: ResMut, pkv: Res, theme: Res, ) { let str = get_cosmic_text(text_query.single().editor.buffer()); if str != *previous_search_text { if !str.is_empty() { if let Some(index) = &app_state.search_index { let index = &index.index; let result = fuzzy_search(index, str.as_str()); match result { Ok(docs) => { let node_ids: HashSet = docs .clone() .into_iter() .filter(|l| { Some(ReflectableUuid(l.doc_id)) == app_state.current_document }) .map(|l| ReflectableUuid(l.node_id)) .collect(); highlight_search_match_nodes(&node_ids, &mut velo_border, &theme); let doc_ids: HashSet = docs .into_iter() .map(|location| ReflectableUuid(location.doc_id)) .collect(); app_state.doc_list_ui = doc_ids; } Err(e) => info!("Error searching index {:?}", e), } } } else if let Ok(names) = pkv.get::>("names") { highlight_search_match_nodes(&HashSet::new(), &mut velo_border, &theme); let keys_in_storage: Vec<_> = names.keys().collect(); let keys_in_memory: Vec<_> = app_state.docs.keys().cloned().collect(); let mut combined_keys = keys_in_memory; combined_keys.extend(keys_in_storage); app_state.doc_list_ui.extend(combined_keys); } *previous_search_text = str; } } pub fn init_search_index(mut app_state: ResMut) { let dirs = directories::ProjectDirs::from("", ORG_NAME, APP_NAME); let path = match dirs.as_ref() { Some(dirs) => dirs.data_dir(), None => Path::new("."), } .to_path_buf(); app_state.search_index = Some(SearchIndexState { index: initialize_search_index(path), node_updates: HashMap::new(), tabs_to_delete: HashSet::new(), }); } pub fn initialize_search_index(dir: PathBuf) -> tantivy::Index { Index::open_in_dir(dir.clone()).unwrap_or_else(|_| { let mut schema_builder = Schema::builder(); schema_builder.add_text_field("text", TEXT); schema_builder.add_text_field("full_text", STRING); schema_builder.add_text_field("doc_id", STRING | STORED); schema_builder.add_text_field("tab_id", STRING | STORED); schema_builder.add_text_field("node_id", STRING | STORED); let schema = schema_builder.build(); Index::create_in_dir(dir, schema).unwrap() }) } pub fn update_search_index( index: &Index, node_search_locations: &HashMap, ) -> tantivy::Result<()> { let mut index_writer = index.writer(50_000_000)?; for (node_search_location, str) in node_search_locations.iter() { let term = tantivy::Term::from_field_text( index.schema().get_field("node_id").unwrap(), &node_search_location.node_id.to_string(), ); index_writer.delete_term(term); let mut document = tantivy::Document::new(); document.add_text(index.schema().get_field("text").unwrap(), str); document.add_text(index.schema().get_field("full_text").unwrap(), str); document.add_text( index.schema().get_field("doc_id").unwrap(), &node_search_location.doc_id.to_string(), ); document.add_text( index.schema().get_field("tab_id").unwrap(), &node_search_location.tab_id.to_string(), ); document.add_text( index.schema().get_field("node_id").unwrap(), &node_search_location.node_id.to_string(), ); index_writer.add_document(document)?; } index_writer.commit()?; Ok(()) } const MAX_SEARCH_RESULTS: usize = 1000; pub fn clear_tabs_index(index: &Index, tab_ids: &HashSet) -> tantivy::Result<()> { let mut index_writer = index.writer(50_000_000)?; for tab_id in tab_ids { let term = tantivy::Term::from_field_text( index.schema().get_field("tab_id").unwrap(), &tab_id.to_string(), ); index_writer.delete_term(term); } index_writer.commit()?; Ok(()) } pub fn clear_doc_index(index: &Index, doc_id: &Uuid) -> tantivy::Result<()> { let mut index_writer = index.writer(50_000_000)?; let term = tantivy::Term::from_field_text( index.schema().get_field("doc_id").unwrap(), &doc_id.to_string(), ); index_writer.delete_term(term); index_writer.commit()?; Ok(()) } pub fn fuzzy_search(index: &Index, query: &str) -> tantivy::Result> { let reader = index .reader_builder() .reload_policy(ReloadPolicy::OnCommit) .try_into()?; let searcher = reader.searcher(); let normalized_query = query.to_lowercase(); let schema = index.schema(); let text_field = schema.get_field("text").unwrap(); let full_text_field = schema.get_field("full_text").unwrap(); let doc_id_field = schema.get_field("doc_id").unwrap(); let tab_id_field = schema.get_field("tab_id").unwrap(); let node_id_field = schema.get_field("node_id").unwrap(); let text_term = Term::from_field_text(text_field, normalized_query.as_str()); let query1 = FuzzyTermQuery::new(text_term, 2, true); let full_text_term = Term::from_field_text(full_text_field, normalized_query.as_str()); let query2 = FuzzyTermQuery::new(full_text_term, 2, true); let query = BooleanQuery::new(vec![ (Occur::Should, Box::new(query1)), (Occur::Should, Box::new(query2)), ]); let top_docs = searcher .search(&query, &(TopDocs::with_limit(MAX_SEARCH_RESULTS))) .unwrap(); let ids: Vec = top_docs .iter() .map(|(_, doc_address)| { let doc = searcher.doc(*doc_address).unwrap(); let doc_id_value = doc.get_first(doc_id_field).unwrap(); let tab_id_value = doc.get_first(tab_id_field).unwrap(); let node_id_value = doc.get_first(node_id_field).unwrap(); NodeSearchLocation { doc_id: Uuid::parse_str(doc_id_value.as_text().unwrap()).unwrap(), tab_id: Uuid::parse_str(tab_id_value.as_text().unwrap()).unwrap(), node_id: Uuid::parse_str(node_id_value.as_text().unwrap()).unwrap(), } }) .collect(); Ok(ids) } fn highlight_search_match_nodes( node_ids: &HashSet, velo_border: &mut Query<(&mut Stroke, &VeloShape), With>, theme: &Res, ) { let highlight_color = theme.node_found_color; let highlight_thickness = 3.; for (mut stroke, velo_border) in velo_border.iter_mut() { if node_ids.contains(&velo_border.id) { stroke.color = highlight_color; stroke.options.line_width = highlight_thickness; } else { // revert let has_border = velo_border.node_type.clone() != NodeType::Paper; if has_border { stroke.color = theme.node_border; } else { stroke.color = Color::NONE; }; stroke.options.line_width = 1.; } } } #[cfg(test)] mod tests { use tempfile::TempDir; use uuid::Uuid; use super::*; #[test] fn test_fuzzy_search() { // Create a temporary directory for the index let temp_dir = TempDir::new().expect("Failed to create temporary directory"); // Initialize the index using the temporary directory let index = initialize_search_index(temp_dir.path().to_path_buf()); let id1 = Uuid::new_v4(); let text1 = "apple".to_string(); let id2 = Uuid::new_v4(); let text2 = "banana".to_string(); let mut node_search_locations = HashMap::new(); node_search_locations.insert( NodeSearchLocation { doc_id: id1, tab_id: Uuid::new_v4(), node_id: Uuid::new_v4(), }, text1, ); node_search_locations.insert( NodeSearchLocation { doc_id: id2, tab_id: Uuid::new_v4(), node_id: Uuid::new_v4(), }, text2, ); update_search_index(&index, &node_search_locations).unwrap(); // Perform fuzzy search and assert the results let query = "appla"; let result = fuzzy_search(&index, query).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].doc_id, id1); // Clean up the temporary directory temp_dir .close() .expect("Failed to remove temporary directory"); } #[test] fn test_clear_tab() { // Create a temporary directory for the index let temp_dir = TempDir::new().expect("Failed to create temporary directory"); // Initialize the index using the temporary directory let index = initialize_search_index(temp_dir.path().to_path_buf()); let doc_id = Uuid::new_v4(); let tab_id = Uuid::new_v4(); let text_1 = "example text 1".to_string(); let text_2 = "example text 2".to_string(); let mut node_search_locations = HashMap::new(); node_search_locations.insert( NodeSearchLocation { doc_id, tab_id, node_id: Uuid::new_v4(), }, text_1, ); node_search_locations.insert( NodeSearchLocation { doc_id, tab_id, node_id: Uuid::new_v4(), }, text_2, ); update_search_index(&index, &node_search_locations).unwrap(); let mut tab_ids = HashSet::new(); tab_ids.insert(tab_id); // Clear the tab from the index clear_tabs_index(&index, &tab_ids).unwrap(); // Perform a search and assert that the tab is not found let query = "example"; let result = fuzzy_search(&index, query).unwrap(); assert_eq!(result.len(), 0); // Clean up the temporary directory temp_dir .close() .expect("Failed to remove temporary directory"); } #[test] fn test_clear_doc() { // Create a temporary directory for the index let temp_dir = TempDir::new().expect("Failed to create temporary directory"); // Initialize the index using the temporary directory let index = initialize_search_index(temp_dir.path().to_path_buf()); let doc_id = Uuid::new_v4(); let text_1 = "example text 1".to_string(); let text_2 = "example text 2".to_string(); let mut node_search_locations = HashMap::new(); node_search_locations.insert( NodeSearchLocation { doc_id, tab_id: Uuid::new_v4(), node_id: Uuid::new_v4(), }, text_1, ); node_search_locations.insert( NodeSearchLocation { doc_id, tab_id: Uuid::new_v4(), node_id: Uuid::new_v4(), }, text_2, ); update_search_index(&index, &node_search_locations).unwrap(); // Clear the document from the index clear_doc_index(&index, &doc_id).unwrap(); // Perform a search and assert that the document is not found let query = "example"; let result = fuzzy_search(&index, query).unwrap(); assert_eq!(result.len(), 0); // Clean up the temporary directory temp_dir .close() .expect("Failed to remove temporary directory"); } } ================================================ FILE: src/ui_plugin/systems/set_focused_entity.rs ================================================ use bevy::{prelude::*, window::PrimaryWindow}; use super::{ui_helpers::RawText, NodeInteraction, UiState}; pub fn set_focused_entity( mut windows: Query<&mut Window, With>, mut node_interaction_events: EventReader, mut ui_state: ResMut, velo: Query<&RawText, With>, ) { let mut primary_window = windows.single_mut(); if ui_state.modal_id.is_some() { return; } for event in node_interaction_events.iter() { if let Ok(velo_node) = velo.get(event.entity) { match event.node_interaction_type { crate::ui_plugin::NodeInteractionType::Hover => { if ui_state.hold_entity.is_none() && ui_state.entity_to_edit.is_none() { primary_window.cursor.icon = CursorIcon::Hand; } if ui_state.entity_to_edit.is_some() { primary_window.cursor.icon = CursorIcon::Text; } } crate::ui_plugin::NodeInteractionType::LeftClick => {} crate::ui_plugin::NodeInteractionType::LeftDoubleClick => { *ui_state = UiState::default(); ui_state.entity_to_edit = Some(velo_node.id); } crate::ui_plugin::NodeInteractionType::LeftMouseHoldAndDrag => { if ui_state.entity_to_edit.is_none() { ui_state.hold_entity = Some(velo_node.id); primary_window.cursor.icon = CursorIcon::Move; } } crate::ui_plugin::NodeInteractionType::RightClick => {} crate::ui_plugin::NodeInteractionType::LeftMouseRelease => {} } } if event.node_interaction_type == crate::ui_plugin::NodeInteractionType::LeftMouseRelease { ui_state.hold_entity = None; } } } ================================================ FILE: src/ui_plugin/systems/tabs.rs ================================================ use std::{collections::VecDeque, time::Duration}; use bevy::prelude::*; use bevy::window::PrimaryWindow; use bevy_cosmic_edit::{CosmicEdit, CosmicFont}; use cosmic_text::{Cursor, Edit}; use super::ui_helpers::{spawn_modal, AddTab, DeleteTab, TabButton}; use super::MainPanel; use crate::components::Tab; use crate::resources::{AppState, FontSystemState, LoadDocRequest, LoadTabRequest, SaveTabRequest}; use crate::themes::Theme; use crate::utils::{bevy_color_to_cosmic, get_timestamp, ReflectableUuid}; use crate::UiState; pub fn select_tab_handler( mut commands: Commands, mut interaction_query: Query< (&Interaction, &TabButton), (Changed, With), >, mut state: ResMut, ) { for (interaction, selected_tab) in &mut interaction_query { match *interaction { Interaction::Pressed => { let current_document = state.current_document.unwrap(); for tab in state .docs .get_mut(¤t_document) .unwrap() .tabs .iter_mut() { if tab.is_active && tab.id == selected_tab.id { return; } if tab.is_active { commands.insert_resource(SaveTabRequest { tab_id: tab.id, doc_id: current_document, }); } } for tab in state .docs .get_mut(¤t_document) .unwrap() .tabs .iter_mut() { tab.is_active = tab.id == selected_tab.id; } commands.insert_resource(LoadTabRequest { doc_id: current_document, tab_id: selected_tab.id, drop_last_checkpoint: false, }); } Interaction::Hovered => {} Interaction::None => {} } } } pub fn add_tab_handler( mut commands: Commands, mut interaction_query: Query<&Interaction, (Changed, With)>, mut app_state: ResMut, ) { for interaction in &mut interaction_query { match *interaction { Interaction::Pressed => { let tab_id = ReflectableUuid::generate(); let current_document = app_state.current_document.unwrap(); let tabs = &mut app_state.docs.get_mut(¤t_document).unwrap().tabs; for tab in tabs.iter_mut() { if tab.is_active { commands.insert_resource(SaveTabRequest { tab_id: tab.id, doc_id: current_document, }); } tab.is_active = false; } let tabs_len = tabs.len(); tabs.push(Tab { id: tab_id, name: "Tab ".to_string() + &(tabs_len + 1).to_string(), checkpoints: VecDeque::new(), is_active: true, z_index: 1., }); commands.insert_resource(LoadDocRequest { doc_id: app_state.current_document.unwrap(), }); } Interaction::Hovered => {} Interaction::None => {} } } } pub fn rename_tab_handler( mut commands: Commands, mut interaction_query: Query< (&Interaction, &TabButton, Entity, &mut CosmicEdit), (Changed, With), >, mut ui_state: ResMut, mut app_state: ResMut, mut double_click: Local<(Duration, Option)>, theme: Res, ) { for (interaction, item, entity, mut cosmic_edit) in &mut interaction_query { match *interaction { Interaction::Pressed => { let now_ms = get_timestamp(); if double_click.1 == Some(item.id) && Duration::from_millis(now_ms as u64) - double_click.0 < Duration::from_millis(500) { *ui_state = UiState::default(); commands.insert_resource(bevy_cosmic_edit::ActiveEditor { entity: Some(entity), }); cosmic_edit.readonly = false; let current_cursor = cosmic_edit.editor.cursor(); let new_cursor = Cursor::new_with_color( current_cursor.line, current_cursor.index, bevy_color_to_cosmic(theme.font), ); cosmic_edit.editor.set_cursor(new_cursor); let current_document = app_state.current_document.unwrap(); let tab = app_state .docs .get_mut(¤t_document) .unwrap() .tabs .iter() .find(|x| x.is_active) .unwrap(); ui_state.tab_to_edit = Some(tab.id); *double_click = (Duration::from_secs(0), None); } else { *double_click = (Duration::from_millis(now_ms as u64), Some(item.id)); } } Interaction::Hovered => {} Interaction::None => {} } } } pub fn delete_tab_handler( mut commands: Commands, mut interaction_query: Query<&Interaction, (Changed, With)>, mut app_state: ResMut, mut ui_state: ResMut, main_panel_query: Query>, windows: Query<&Window, With>, mut cosmic_fonts: ResMut>, font_system_state: ResMut, theme: Res, ) { let window = windows.single(); for interaction in &mut interaction_query { match *interaction { Interaction::Pressed => { let id: ReflectableUuid = ReflectableUuid::generate(); *ui_state = UiState::default(); commands.insert_resource(bevy_cosmic_edit::ActiveEditor { entity: None }); let current_document = app_state.current_document.unwrap(); let tabs_len = app_state .docs .get_mut(¤t_document) .unwrap() .tabs .len(); if tabs_len < 2 { return; } ui_state.modal_id = Some(id); let entity = spawn_modal( &mut commands, &theme, &mut cosmic_fonts, font_system_state.0.clone().unwrap(), window, id, super::ModalAction::DeleteTab, ); commands.entity(main_panel_query.single()).add_child(entity); } Interaction::Hovered => {} Interaction::None => {} } } } ================================================ FILE: src/ui_plugin/systems/update_rectangle_position.rs ================================================ use bevy::prelude::*; use crate::{canvas::arrow::events::RedrawArrow, components::MainCamera}; use super::{ ui_helpers::{RawText, VeloNode, VeloShape}, UiState, }; pub fn update_rectangle_position( mut cursor_moved_events: EventReader, raw_text_query: Query<(&RawText, &Parent), With>, border_query: Query<&Parent, With>, mut velo_node_query: Query<&mut Transform, With>, mut events: EventWriter, camera_q: Query<(&Camera, &GlobalTransform), With>, ui_state: Res, mut previous_position: Local>, ) { let (camera, camera_transform) = camera_q.single(); if ui_state.hold_entity.is_none() { *previous_position = None; return; } if previous_position.is_none() && !cursor_moved_events.is_empty() { if let Some(pos) = camera.viewport_to_world_2d( camera_transform, cursor_moved_events.iter().next().unwrap().position, ) { *previous_position = Some(pos.round()); } } if previous_position.is_some() { for (raw_text, parent) in &mut raw_text_query.iter() { if !ui_state.drawing_mode && ui_state.modal_id.is_none() && Some(raw_text.id) == ui_state.hold_entity && ui_state.entity_to_edit.is_none() { let event = cursor_moved_events.iter().last(); if let Some(pos) = event .and_then(|event| camera.viewport_to_world_2d(camera_transform, event.position)) { let border = border_query.get(parent.get()).unwrap(); let mut top = velo_node_query.get_mut(border.get()).unwrap(); top.translation.x += (pos.x - previous_position.unwrap().x).round(); top.translation.y += (pos.y - previous_position.unwrap().y).round(); events.send(RedrawArrow { id: raw_text.id }); *previous_position = Some(pos.round()); break; } } } } } ================================================ FILE: src/ui_plugin/ui_helpers/add_list_item.rs ================================================ use bevy::{ a11y::{ accesskit::{NodeBuilder, Role}, AccessibilityNode, }, prelude::*, }; use bevy_cosmic_edit::{ spawn_cosmic_edit, CosmicEditMeta, CosmicFont, CosmicMetrics, CosmicNode, CosmicText, }; use cosmic_text::AttrsOwned; use crate::{ themes::Theme, ui_plugin::TextPos, utils::{bevy_color_to_cosmic, ReflectableUuid}, }; use super::{DeleteDoc, DocListItemButton, DocListItemContainer, EditableText, GenericButton}; pub fn add_list_item( commands: &mut Commands, cosmic_fonts: &mut ResMut>, cosmic_font_handle: Handle, theme: &Res, asset_server: &Res, id: ReflectableUuid, name: String, scale_factor: f32, ) -> Entity { let icon_font = asset_server.load("fonts/MaterialIcons-Regular.ttf"); let root = commands .spawn(( ButtonBundle { border_color: theme.btn_border.into(), background_color: theme.doc_list_bg.into(), style: Style { width: Val::Percent(100.), height: Val::Percent(100.), justify_content: JustifyContent::Center, border: UiRect::all(Val::Px(1.)), ..default() }, ..default() }, GenericButton, DocListItemContainer { id }, AccessibilityNode(NodeBuilder::new(Role::ListItem)), )) .id(); let mut attrs = cosmic_text::Attrs::new(); attrs = attrs.family(cosmic_text::Family::Name(theme.font_name.as_str())); attrs = attrs.color(bevy_color_to_cosmic(theme.font)); let cosmic_edit_meta = CosmicEditMeta { text: CosmicText::OneStyle(name), attrs: AttrsOwned::new(attrs), font_system_handle: cosmic_font_handle, text_pos: TextPos::Center.into(), size: None, metrics: CosmicMetrics { font_size: theme.font_size, line_height: theme.line_height, scale_factor, }, bg: theme.doc_list_bg, node: CosmicNode::Ui, readonly: true, bg_image: None, }; let cosmic_edit = spawn_cosmic_edit(commands, cosmic_fonts, cosmic_edit_meta); commands .entity(cosmic_edit) .insert(EditableText { id }) .insert(Label) .insert(GenericButton) .insert(DocListItemButton { id }); let del_button = commands .spawn(( ButtonBundle { background_color: theme.doc_list_bg.into(), visibility: Visibility::Hidden, style: Style { margin: UiRect { left: Val::Px(3.), right: Val::Px(3.), top: Val::Px(0.), bottom: Val::Px(0.), }, width: Val::Percent(10.), height: Val::Percent(100.), justify_content: JustifyContent::Center, padding: UiRect::all(Val::Px(5.)), ..default() }, ..default() }, DeleteDoc { id }, GenericButton, )) .id(); let del_label = commands .spawn(( TextBundle { text: Text { sections: vec![TextSection { value: "\u{e14c}".to_string(), style: TextStyle { font_size: 24., color: theme.del_button, font: icon_font, }, }], ..default() }, ..default() }, Label, )) .id(); commands.entity(del_button).add_child(del_label); commands.entity(root).add_child(cosmic_edit); commands.entity(root).add_child(del_button); root } ================================================ FILE: src/ui_plugin/ui_helpers/add_tab.rs ================================================ use bevy::prelude::*; use bevy_cosmic_edit::{ spawn_cosmic_edit, CosmicEditMeta, CosmicFont, CosmicMetrics, CosmicNode, CosmicText, }; use cosmic_text::AttrsOwned; use crate::{ themes::Theme, ui_plugin::TextPos, utils::{bevy_color_to_cosmic, ReflectableUuid}, }; use super::{DeleteTab, EditableText, GenericButton, TabButton, TabContainer}; pub fn add_tab( commands: &mut Commands, cosmic_fonts: &mut ResMut>, cosmic_font_handle: Handle, theme: &Res, asset_server: &Res, name: String, id: ReflectableUuid, is_active: bool, scale_factor: f32, ) -> Entity { let icon_font = asset_server.load("fonts/MaterialIcons-Regular.ttf"); let root = commands .spawn(( NodeBundle { background_color: theme.add_tab_bg.into(), style: Style { width: Val::Percent(8.), height: Val::Percent(90.), justify_content: JustifyContent::Center, align_items: AlignItems::Center, margin: UiRect { left: Val::Px(10.), right: Val::Px(10.), top: Val::Px(0.), bottom: Val::Px(0.), }, ..default() }, ..default() }, TabContainer { id }, )) .id(); let mut attrs = cosmic_text::Attrs::new(); attrs = attrs.family(cosmic_text::Family::Name(theme.font_name.as_str())); attrs = attrs.color(bevy_color_to_cosmic(theme.font)); let cosmic_edit_meta = CosmicEditMeta { text: CosmicText::OneStyle(name), attrs: AttrsOwned::new(attrs), font_system_handle: cosmic_font_handle, text_pos: TextPos::Center.into(), size: None, metrics: CosmicMetrics { font_size: theme.font_size, line_height: theme.line_height, scale_factor, }, bg: theme.add_tab_bg, node: CosmicNode::Ui, readonly: true, bg_image: None, }; let cosmic_edit = spawn_cosmic_edit(commands, cosmic_fonts, cosmic_edit_meta); commands .entity(cosmic_edit) .insert(EditableText { id }) .insert(GenericButton) .insert(TabButton { id }); let del_button = commands .spawn(( ButtonBundle { background_color: theme.add_tab_bg.into(), visibility: if is_active { Visibility::Visible } else { Visibility::Hidden }, style: Style { margin: UiRect { left: Val::Px(3.), right: Val::Px(3.), top: Val::Px(0.), bottom: Val::Px(0.), }, width: Val::Percent(10.), height: Val::Percent(100.), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, ..default() }, GenericButton, DeleteTab { id }, )) .id(); let del_label = commands .spawn(( TextBundle { style: Style { ..default() }, text: Text { sections: vec![TextSection { value: "\u{e14c}".to_string(), style: TextStyle { font_size: 18., color: theme.del_button, font: icon_font, }, }], ..default() }, ..default() }, Label, )) .id(); commands.entity(del_button).add_child(del_label); commands.entity(root).add_child(cosmic_edit); commands.entity(root).add_child(del_button); root } ================================================ FILE: src/ui_plugin/ui_helpers/components.rs ================================================ use crate::{ui_plugin::NodeType, utils::ReflectableUuid}; use bevy::prelude::*; use bevy_markdown::TextSpanMetadata; use crate::TextPos; #[derive(Component)] pub struct GenericButton; #[derive(Component)] pub struct Root; #[derive(Component)] pub struct Menu; #[derive(Component, Clone)] pub struct AddTab; #[derive(Component)] pub struct DeleteTab { pub id: ReflectableUuid, } #[derive(Component)] pub struct Tooltip; #[derive(Component, Clone)] pub struct NewDoc; #[derive(Component, Clone)] pub struct ParticlesEffect; #[derive(Component, Clone)] pub struct DrawPencil; #[derive(Clone, PartialEq, Eq)] pub enum TwoPointsDrawType { Arrow, Line, Rhombus, Rectangle, } #[derive(Component, Clone)] pub struct TwoPointsDraw { pub drawing_type: TwoPointsDrawType, } #[derive(Component)] pub struct DocList; #[derive(Component, Clone)] pub struct SaveDoc; #[derive(Component, Clone)] pub struct ExportToFile; #[derive(Component, Clone)] pub struct SetWindowProperty; #[derive(Component, Clone)] pub struct ImportFromFile; #[derive(Component, Clone)] pub struct ImportFromUrl; #[derive(Component, Clone)] pub struct ShareDoc; #[derive(Component, Clone)] pub struct ChangeTheme; #[derive(Component)] pub struct DeleteDoc { pub id: ReflectableUuid, } #[derive(Component)] pub struct TabButton { pub id: ReflectableUuid, } #[derive(Component)] pub struct TabContainer { pub id: ReflectableUuid, } #[derive(Component)] pub struct SearchButton { pub id: ReflectableUuid, } #[derive(Component)] pub struct SearchText { pub id: ReflectableUuid, } #[derive(Component, Default)] pub struct ScrollingList { pub position: f32, } #[derive(Component)] pub struct DocListItemContainer { pub id: ReflectableUuid, } #[derive(Component)] pub struct DocListItemButton { pub id: ReflectableUuid, } #[derive(Component)] pub struct ChangeColor { pub pair_color: (String, Color), } #[derive(Component)] pub struct TextPosMode { pub text_pos: TextPos, } #[derive(Component)] pub struct MainPanel; #[derive(Component)] pub struct BottomPanel; #[derive(Component)] pub struct LeftPanel; #[derive(Component)] pub struct LeftPanelControls; #[derive(Component)] pub struct LeftPanelExplorer; #[derive(Component)] pub struct VeloShape { pub id: ReflectableUuid, pub node_type: NodeType, pub pair_color: (String, Color), } #[derive(Component, Default, Debug)] pub struct VeloNode { pub id: ReflectableUuid, } #[derive(PartialEq, Eq, Clone)] pub enum ButtonTypes { AddRec, AddCircle, AddText, AddPaper, Del, Front, Back, ShowChildren, HideChildren, ShowRandom, } #[derive(Component, Clone)] pub struct ButtonAction { pub button_type: ButtonTypes, } #[derive(Component, Default, Reflect)] #[reflect(Component)] pub struct EditableText { pub id: ReflectableUuid, } #[derive(Component, Default)] pub struct RawText { pub id: ReflectableUuid, pub last_text: String, } #[derive(Component, Default)] pub struct BevyMarkdownView { pub id: ReflectableUuid, pub span_metadata: Vec, } #[derive(Component, Copy, Clone, Debug, Default)] pub enum ResizeMarker { #[default] TopLeft, TopRight, BottomLeft, BottomRight, } #[derive(Component)] pub struct ModalTop { pub id: ReflectableUuid, pub action: ModalAction, } #[derive(Eq, PartialEq, Clone)] pub enum ModalAction { SaveToFile, LoadFromFile, LoadFromUrl, DeleteDocument, DeleteTab, } impl std::fmt::Display for ModalAction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ModalAction::DeleteDocument => write!(f, "delete document"), ModalAction::DeleteTab => write!(f, "delete tab"), ModalAction::LoadFromFile => write!(f, "Load from file:"), ModalAction::LoadFromUrl => write!(f, "Load from URL:"), ModalAction::SaveToFile => write!(f, "Save to file:"), } } } #[derive(Component)] pub struct ModalConfirm { pub id: ReflectableUuid, pub action: ModalAction, } #[derive(Component, Default)] pub struct ModalCancel { pub id: ReflectableUuid, } #[derive(Component)] pub struct InteractiveNode; #[derive(Component, Clone)] pub struct Drawing { pub id: ReflectableUuid, pub points: Vec, pub drawing_color: T, } ================================================ FILE: src/ui_plugin/ui_helpers/spawn_modal.rs ================================================ use bevy_cosmic_edit::{ spawn_cosmic_edit, ActiveEditor, CosmicEditMeta, CosmicFont, CosmicMetrics, CosmicNode, CosmicText, }; use bevy::prelude::*; use cosmic_text::AttrsOwned; use super::{ add_rectangle_txt, EditableText, GenericButton, ModalAction, ModalCancel, ModalConfirm, ModalTop, }; use crate::{ themes::Theme, ui_plugin::TextPos, utils::{bevy_color_to_cosmic, ReflectableUuid}, }; pub fn spawn_modal( commands: &mut Commands, theme: &Res, cosmic_fonts: &mut ResMut>, cosmic_font_handle: Handle, window: &Window, id: ReflectableUuid, modal_action: ModalAction, ) -> Entity { let width = 350.; let height = 250.; let default_value = match modal_action { ModalAction::SaveToFile => "./velo.json".to_string(), ModalAction::LoadFromFile => "./velo.json".to_string(), ModalAction::LoadFromUrl => "https://gist..".to_string(), _ => "".to_string(), }; let top = commands .spawn(( NodeBundle { z_index: ZIndex::Global(1), style: Style { flex_direction: FlexDirection::Column, align_self: AlignSelf::Stretch, position_type: PositionType::Absolute, left: Val::Px(window.width() / 2. - 250.), bottom: Val::Px(window.height() / 2. - 50.), width: Val::Px(width), height: Val::Px(height), ..default() }, background_color: theme.shadow.into(), ..default() }, ModalTop { id, action: modal_action.clone(), }, )) .id(); let modal_static = commands .spawn(NodeBundle { style: Style { align_items: AlignItems::Center, width: Val::Percent(100.), height: Val::Percent(30.), justify_content: JustifyContent::SpaceAround, ..default() }, ..default() }) .id(); let ok_button = commands .spawn(( ButtonBundle { border_color: theme.btn_border.into(), background_color: theme.ok_cancel_bg.into(), style: Style { justify_content: JustifyContent::Center, border: UiRect::all(Val::Px(1.)), align_items: AlignItems::Center, padding: UiRect::all(Val::Px(5.)), ..default() }, ..default() }, GenericButton, ModalConfirm { id, action: modal_action.clone(), }, )) .with_children(|builder| { let text_style = TextStyle { font_size: 18.0, color: theme.font, ..default() }; builder.spawn( TextBundle::from_section(" Ok ", text_style).with_style(Style { position_type: PositionType::Relative, ..default() }), ); }) .id(); let cancel_button = commands .spawn(( ButtonBundle { border_color: theme.btn_border.into(), background_color: theme.ok_cancel_bg.into(), style: Style { justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.)), padding: UiRect::all(Val::Px(5.)), ..default() }, ..default() }, GenericButton, ModalCancel { id }, )) .with_children(|builder| { let text_style = TextStyle { font_size: 18.0, color: theme.font, ..default() }; builder.spawn( TextBundle::from_section("Cancel", text_style).with_style(Style { position_type: PositionType::Relative, ..default() }), ); }) .id(); commands.entity(modal_static).add_child(ok_button); commands.entity(modal_static).add_child(cancel_button); let modal_dynamic = match modal_action { ModalAction::SaveToFile | ModalAction::LoadFromFile | ModalAction::LoadFromUrl => { let top = commands .spawn(NodeBundle { style: Style { align_items: AlignItems::Center, justify_content: JustifyContent::SpaceAround, padding: UiRect::all(Val::Px(20.)), width: Val::Percent(100.), height: Val::Percent(70.), ..default() }, ..default() }) .id(); let label = commands .spawn(NodeBundle { style: Style { justify_content: JustifyContent::Center, align_items: AlignItems::Center, width: Val::Percent(50.), height: Val::Percent(30.), ..default() }, ..default() }) .with_children(|builder| { builder.spawn(add_rectangle_txt(theme, modal_action.to_string())); }) .id(); let width = 180.; let height = 30.; let button = commands .spawn((NodeBundle { border_color: theme.btn_border.into(), style: Style { justify_content: JustifyContent::Start, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.)), width: Val::Px(width), height: Val::Px(height), ..default() }, ..default() },)) .id(); let mut attrs = cosmic_text::Attrs::new(); attrs = attrs.family(cosmic_text::Family::Name(theme.font_name.as_str())); attrs = attrs.color(bevy_color_to_cosmic(theme.font)); let cosmic_edit_meta = CosmicEditMeta { text: CosmicText::OneStyle(default_value), attrs: AttrsOwned::new(attrs), font_system_handle: cosmic_font_handle, text_pos: TextPos::Center.into(), size: Some((width, height)), metrics: CosmicMetrics { font_size: theme.font_size, line_height: theme.line_height, scale_factor: window.scale_factor() as f32, }, bg: theme.modal_text_input_bg, node: CosmicNode::Ui, readonly: false, bg_image: None, }; let cosmic_edit = spawn_cosmic_edit(commands, cosmic_fonts, cosmic_edit_meta); commands.entity(cosmic_edit).insert(EditableText { id }); commands.insert_resource(ActiveEditor { entity: Some(cosmic_edit), }); commands.entity(top).add_child(label); commands.entity(button).add_child(cosmic_edit); commands.entity(top).add_child(button); top } ModalAction::DeleteDocument | ModalAction::DeleteTab => { let top = commands .spawn(NodeBundle { style: Style { align_items: AlignItems::Center, justify_content: JustifyContent::Center, width: Val::Percent(100.), height: Val::Percent(50.), ..default() }, ..default() }) .id(); let node = commands .spawn(NodeBundle { style: Style { justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, ..default() }) .id(); let node_label = commands .spawn(add_rectangle_txt( theme, format!("Are you sure you want to {}?", modal_action), )) .id(); commands.entity(node).add_child(node_label); commands.entity(top).add_child(node); top } }; let modal = commands .spawn((NodeBundle { border_color: theme.btn_border.into(), background_color: theme.modal_bg.into(), style: Style { align_items: AlignItems::Center, justify_content: JustifyContent::Center, border: UiRect::all(Val::Px(1.)), width: Val::Percent(100.), height: Val::Percent(100.), position_type: PositionType::Absolute, left: Val::Px(-3.), right: Val::Px(0.), top: Val::Px(-3.), bottom: Val::Px(0.), flex_direction: FlexDirection::Column, ..default() }, ..default() },)) .id(); commands.entity(modal).add_child(modal_dynamic); commands.entity(modal).add_child(modal_static); commands.entity(top).add_child(modal); top } ================================================ FILE: src/ui_plugin/ui_helpers/spawn_node.rs ================================================ use bevy_cosmic_edit::{ spawn_cosmic_edit, ActiveEditor, CosmicEditMeta, CosmicEditSprite, CosmicFont, CosmicMetrics, CosmicNode, CosmicText, }; use bevy_markdown::{generate_markdown_lines, BevyMarkdown, BevyMarkdownTheme}; use bevy_prototype_lyon::prelude::{Fill, Path, Stroke}; use bevy::prelude::*; use cosmic_text::AttrsOwned; use crate::canvas::shadows::systems::spawn_shadow; use crate::canvas::shadows::CustomShadowMaterial; use crate::themes::Theme; use crate::ui_plugin::NodeType; use crate::TextPos; use super::{BevyMarkdownView, InteractiveNode, RawText, ResizeMarker, VeloNode, VeloShape}; use crate::canvas::arrow::components::{ArrowConnect, ArrowConnectPos}; use crate::utils::{bevy_color_to_cosmic, ReflectableUuid}; #[derive(Clone)] pub struct NodeMeta { pub id: ReflectableUuid, pub node_type: NodeType, pub size: (f32, f32), pub position: (f32, f32, f32), pub text: String, pub pair_bg_color: (String, Color), pub image: Option>, pub text_pos: TextPos, pub is_active: bool, pub visible: bool, } pub fn spawn_sprite_node( commands: &mut Commands, materials: &mut ResMut>, meshes: &mut ResMut>, theme: &Res, cosmic_fonts: &mut ResMut>, cosmic_font_handle: Handle, scale_factor: f32, item_meta: NodeMeta, ) -> Entity { let pos: Vec3 = Vec3::new( item_meta.position.0, item_meta.position.1, item_meta.position.2, ); let width: f32 = item_meta.size.0; let height = item_meta.size.1; let visibility = if item_meta.visible { Visibility::Visible } else { Visibility::Hidden }; let top = commands .spawn(( SpriteBundle { transform: Transform { translation: pos, ..default() }, visibility, ..Default::default() }, VeloNode { id: item_meta.id }, )) .id(); let points = [ Vec2::new(-width / 2., -height / 2.), Vec2::new(-width / 2., height / 2.), Vec2::new(width / 2., height / 2.), Vec2::new(width / 2., -height / 2.), ]; let path: Path = match item_meta.node_type { NodeType::Rect => bevy_prototype_lyon::prelude::GeometryBuilder::build_as( &bevy_prototype_lyon::shapes::RoundedPolygon { points: points.into_iter().collect(), closed: true, radius: 10., }, ), NodeType::Paper => bevy_prototype_lyon::prelude::GeometryBuilder::build_as( &bevy_prototype_lyon::shapes::Polygon { points: points.into_iter().collect(), closed: true, }, ), NodeType::Circle => bevy_prototype_lyon::prelude::GeometryBuilder::build_as( &bevy_prototype_lyon::shapes::Circle { radius: width / 2., center: Vec2::new(0., 0.), }, ), }; let has_border = item_meta.node_type != NodeType::Paper; let is_transparent = item_meta.pair_bg_color.clone().1 == Color::NONE; let shape = commands .spawn(( bevy_prototype_lyon::prelude::ShapeBundle { transform: Transform { translation: Vec3::new(0.0, 0.0, 0.001), ..default() }, path, ..default() }, Stroke::new( if has_border && !is_transparent { theme.node_border } else { Color::NONE }, 1., ), Fill::color(item_meta.pair_bg_color.1), VeloShape { id: item_meta.id, node_type: item_meta.node_type.clone(), pair_color: item_meta.pair_bg_color, }, )) .id(); let mut attrs = cosmic_text::Attrs::new(); attrs = attrs.family(cosmic_text::Family::Name(theme.font_name.as_str())); attrs = attrs.color(bevy_color_to_cosmic(theme.font)); let (text, span_metadata) = match item_meta.is_active { true => (CosmicText::OneStyle(item_meta.text.clone()), vec![]), false => { let markdown_theme = BevyMarkdownTheme { code_theme: theme.code_theme.clone(), code_default_lang: theme.code_default_lang.clone(), link: bevy_color_to_cosmic(theme.link), inline_code: bevy_color_to_cosmic(theme.inline_code), }; let markdown_lines = generate_markdown_lines(BevyMarkdown { text: item_meta.text.clone(), attrs: AttrsOwned::new(attrs), markdown_theme, }) .expect("should handle markdown convertion"); ( CosmicText::MultiStyle(markdown_lines.lines), markdown_lines.span_metadata, ) } }; let cosmic_edit_meta = CosmicEditMeta { text, font_system_handle: cosmic_font_handle, text_pos: item_meta.text_pos.clone().into(), size: Some((width, height)), node: CosmicNode::Sprite(CosmicEditSprite { transform: Transform { translation: Vec3::new(0.0, 0.0, 0.002), ..default() }, }), metrics: CosmicMetrics { font_size: if is_transparent { 3. * theme.font_size } else { theme.font_size }, line_height: if is_transparent { 3. * theme.line_height } else { theme.line_height }, scale_factor, }, bg: Color::NONE, bg_image: item_meta.image, readonly: !item_meta.is_active, attrs: AttrsOwned::new(attrs), }; let cosmic_edit = spawn_cosmic_edit(commands, cosmic_fonts, cosmic_edit_meta); commands .entity(cosmic_edit) .insert(RawText { id: item_meta.id, last_text: item_meta.text.clone(), }) .insert(InteractiveNode); match item_meta.is_active { true => { commands.insert_resource(ActiveEditor { entity: Some(cosmic_edit), }); } false => { commands.entity(cosmic_edit).insert(BevyMarkdownView { id: item_meta.id, span_metadata, }); } } if item_meta.node_type == NodeType::Paper { let shadow: Entity = spawn_shadow(commands, materials, meshes, theme, Vec2::new(width, height)); commands.entity(top).add_child(shadow); } let arrow_marker_1 = spawn_arrow_marker( commands, theme, item_meta.id, width, height, ArrowConnectPos::Left, ); let arrow_marker_2 = spawn_arrow_marker( commands, theme, item_meta.id, width, height, ArrowConnectPos::Right, ); let arrow_marker_3 = spawn_arrow_marker( commands, theme, item_meta.id, width, height, ArrowConnectPos::Top, ); let arrow_marker_4 = spawn_arrow_marker( commands, theme, item_meta.id, width, height, ArrowConnectPos::Bottom, ); let resize_marker_1 = spawn_resize_marker(commands, theme, width, height, ResizeMarker::TopLeft); let resize_marker_2 = spawn_resize_marker(commands, theme, width, height, ResizeMarker::TopRight); let resize_marker_3 = spawn_resize_marker(commands, theme, width, height, ResizeMarker::BottomLeft); let resize_marker_4 = spawn_resize_marker(commands, theme, width, height, ResizeMarker::BottomRight); commands.entity(top).add_child(shape); commands.entity(shape).add_child(cosmic_edit); commands.entity(top).add_child(arrow_marker_1); commands.entity(top).add_child(arrow_marker_2); commands.entity(top).add_child(arrow_marker_3); commands.entity(top).add_child(arrow_marker_4); commands.entity(top).add_child(resize_marker_1); commands.entity(top).add_child(resize_marker_2); commands.entity(top).add_child(resize_marker_3); commands.entity(top).add_child(resize_marker_4); top } fn spawn_resize_marker( commands: &mut Commands, theme: &Res, width: f32, height: f32, pos: ResizeMarker, ) -> Entity { let (x, y) = match pos { ResizeMarker::BottomLeft => (-width / 2., -height / 2.), ResizeMarker::BottomRight => (width / 2., -height / 2.), ResizeMarker::TopLeft => (-width / 2., height / 2.), ResizeMarker::TopRight => (width / 2., height / 2.), }; let resize_marker = commands .spawn(SpriteBundle { sprite: Sprite { color: Color::NONE, custom_size: Some(Vec2::new( theme.resize_marker_size, theme.resize_marker_size, )), ..default() }, transform: Transform { translation: Vec3 { x, y, z: 0.003 }, ..default() }, ..default() }) .id(); commands .entity(resize_marker) .insert(pos) .insert(InteractiveNode); resize_marker } fn spawn_arrow_marker( commands: &mut Commands, theme: &Res, id: ReflectableUuid, width: f32, height: f32, pos: ArrowConnectPos, ) -> Entity { let (x, y) = match pos { ArrowConnectPos::Left => (-width / 2., 0.), ArrowConnectPos::Bottom => (0., -height / 2.), ArrowConnectPos::Top => (0., height / 2.), ArrowConnectPos::Right => (width / 2., 0.), }; let arrow_marker_container = commands .spawn(SpriteBundle { sprite: Sprite { color: Color::NONE, custom_size: Some(Vec2::new( 6. * theme.arrow_connector_size, 6. * theme.arrow_connector_size, )), ..default() }, transform: Transform { translation: Vec3 { x, y, z: 0.003 }, ..default() }, ..default() }) .id(); let arrow_marker = commands .spawn(SpriteBundle { sprite: Sprite { color: theme.arrow_connector, custom_size: Some(Vec2::new( theme.arrow_connector_size, theme.arrow_connector_size, )), ..default() }, ..default() }) .id(); commands .entity(arrow_marker_container) .add_child(arrow_marker); commands .entity(arrow_marker_container) .insert(ArrowConnect { pos, id }) .insert(InteractiveNode); arrow_marker_container } ================================================ FILE: src/ui_plugin/ui_helpers/ui_helpers.rs ================================================ use bevy::{prelude::*, text::BreakLineOn}; use crate::themes::Theme; #[path = "components.rs"] mod components; pub use components::*; #[path = "spawn_node.rs"] mod spawn_node; pub use spawn_node::*; #[path = "spawn_modal.rs"] mod spawn_modal; pub use spawn_modal::*; #[path = "add_tab.rs"] mod add_tab; pub use add_tab::*; #[path = "add_list_item.rs"] mod add_list_item; pub use add_list_item::*; pub fn add_rectangle_txt(theme: &Res, text: String) -> TextBundle { let text_style = TextStyle { font_size: 18.0, color: theme.font, ..default() }; TextBundle::from_section(text, text_style).with_style(Style { position_type: PositionType::Relative, ..default() }) } pub enum TooltipPosition { Top, Bottom, } pub fn get_tooltip( theme: &Res, text: String, tooltip_position: TooltipPosition, ) -> TextBundle { let text = Text { sections: vec![TextSection { value: text, style: TextStyle { font_size: theme.font_size, color: theme.font, ..default() }, }], alignment: TextAlignment::Center, linebreak_behavior: BreakLineOn::NoWrap, }; let position = match tooltip_position { TooltipPosition::Bottom => UiRect { left: Val::Px(30.), right: Val::Auto, top: Val::Px(40.), bottom: Val::Auto, }, TooltipPosition::Top => UiRect { left: Val::Px(30.), right: Val::Auto, top: Val::Px(-40.), bottom: Val::Auto, }, }; let text_bundle_style = Style { position_type: PositionType::Absolute, left: position.left, right: position.right, top: position.top, bottom: position.bottom, display: Display::None, width: Val::Auto, height: Val::Auto, ..default() }; TextBundle { z_index: ZIndex::Global(1), background_color: theme.tooltip_bg.into(), text, style: text_bundle_style, ..default() } } ================================================ FILE: src/utils.rs ================================================ use bevy::prelude::*; use bevy_cosmic_edit::CosmicTextPos; use serde::{Deserialize, Serialize}; use crate::resources::AppState; use crate::ui_plugin::TextPos; use std::collections::HashMap; use std::{fs, path::PathBuf}; use uuid::Uuid; use bevy_pkv::PkvStore; use crate::{components::Doc, ui_plugin::MAX_SAVED_DOCS_IN_MEMORY}; #[derive(Clone, Reflect, Default, Debug, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)] #[reflect_value] pub struct ReflectableUuid(pub Uuid); #[derive(Serialize, Deserialize)] pub struct UserPreferences { pub theme_name: Option, } impl ReflectableUuid { pub fn generate() -> Self { let uuid = uuid::Uuid::new_v4(); Self(uuid) } } #[cfg(target_arch = "wasm32")] pub fn get_timestamp() -> f64 { js_sys::Date::now() } #[cfg(not(target_arch = "wasm32"))] pub fn get_timestamp() -> f64 { use std::time::SystemTime; use std::time::UNIX_EPOCH; let duration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); duration.as_millis() as f64 } pub fn load_doc_to_memory( doc_id: ReflectableUuid, app_state: &mut ResMut, pkv: &mut ResMut, ) { if app_state.docs.contains_key(&doc_id) { return; } if let Ok(docs) = pkv.get::>("docs") { if docs.contains_key(&doc_id) { let keys = app_state.docs.keys().cloned().collect::>(); while (app_state.docs.len() as i32) >= MAX_SAVED_DOCS_IN_MEMORY { app_state.docs.remove(&keys[0]); } app_state .docs .insert(doc_id, docs.get(&doc_id).unwrap().clone()); } else { error!("Document not found in pkv"); } } } #[derive(Debug, Default)] pub struct Config { pub github_access_token: Option, } #[cfg(not(target_arch = "wasm32"))] pub fn read_config_file() -> Option { let home_dir = std::env::var("HOME").ok()?; let config_file_path = PathBuf::from(&home_dir).join(".velo.toml"); let config_str = fs::read_to_string(config_file_path).ok()?; let config_value: toml::Value = toml::from_str(&config_str).ok()?; let mut config = Config::default(); if let Some(token) = config_value.get("github_access_token") { if let Some(token_str) = token.as_str() { config.github_access_token = Some(token_str.to_owned()); } } Some(config) } impl From for CosmicTextPos { fn from(pos: TextPos) -> Self { match pos { TextPos::Center => CosmicTextPos::Center, TextPos::TopLeft => CosmicTextPos::TopLeft, } } } impl From for TextPos { fn from(pos: CosmicTextPos) -> Self { match pos { CosmicTextPos::Center => TextPos::Center, CosmicTextPos::TopLeft => TextPos::TopLeft, } } } pub fn bevy_color_to_cosmic(color: bevy::prelude::Color) -> cosmic_text::Color { cosmic_text::Color::rgba( (color.r() * 255.) as u8, (color.g() * 255.) as u8, (color.b() * 255.) as u8, (color.a() * 255.) as u8, ) } pub fn get_theme_key(pkv: &PkvStore) -> String { if let Ok(user_preferences) = pkv.get::("user_preferences") { if let Some(theme_name) = user_preferences.theme_name { theme_name } else { "light".to_string() } } else { "light".to_string() } } pub static DARK_THEME_ICON_CODE: &str = "\u{e51c}"; pub static LIGHT_THEME_ICON_CODE: &str = "\u{e518}";