├── .gitattributes ├── .github ├── pull_request_template.md └── workflows │ ├── cargo_shear.yml │ ├── labels.yml │ ├── links.yml │ ├── rust.yml │ └── typos.yml ├── .gitignore ├── .typos.toml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── RELEASES.md ├── check.sh ├── clippy.toml ├── deny.toml ├── examples ├── advanced.rs └── simple.rs ├── lychee.toml ├── rust-toolchain ├── scripts ├── clippy_wasm │ └── clippy.toml ├── generate_changelog.py └── template_update.py ├── src ├── behavior.rs ├── container │ ├── grid.rs │ ├── linear.rs │ ├── mod.rs │ └── tabs.rs ├── lib.rs ├── tile.rs ├── tiles.rs └── tree.rs └── tests └── serialize.rs /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | Cargo.lock linguist-generated=false 3 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | * Closes #ISSUE_NUMBER 14 | -------------------------------------------------------------------------------- /.github/workflows/cargo_shear.yml: -------------------------------------------------------------------------------- 1 | name: Cargo Shear 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | types: [opened, synchronize] 9 | 10 | jobs: 11 | cargo-shear: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Shear 18 | run: | 19 | cargo +stable install cargo-shear@1.1.11 --locked 20 | cargo shear 21 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/rerun-io/rerun_template 2 | 3 | # https://github.com/marketplace/actions/require-labels 4 | # Check for existence of labels 5 | # See all our labels at https://github.com/rerun-io/rerun/issues/labels 6 | 7 | name: PR Labels 8 | 9 | on: 10 | pull_request: 11 | types: 12 | - opened 13 | - synchronize 14 | - reopened 15 | - labeled 16 | - unlabeled 17 | 18 | jobs: 19 | label: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Check for a "do-not-merge" label 23 | uses: mheap/github-action-required-labels@v3 24 | with: 25 | mode: exactly 26 | count: 0 27 | labels: "do-not-merge" 28 | 29 | - name: Require label "include in changelog" or "exclude from changelog" 30 | uses: mheap/github-action-required-labels@v3 31 | with: 32 | mode: minimum 33 | count: 1 34 | labels: "exclude from changelog, include in changelog" 35 | -------------------------------------------------------------------------------- /.github/workflows/links.yml: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/rerun-io/rerun_template 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | pull_request: 7 | types: [ opened, synchronize ] 8 | 9 | name: Link checker 10 | 11 | jobs: 12 | link-checker: 13 | name: Check links 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Restore link checker cache 19 | uses: actions/cache@v3 20 | with: 21 | path: .lycheecache 22 | key: cache-lychee-${{ github.sha }} 23 | restore-keys: cache-lychee- 24 | 25 | # Check https://github.com/lycheeverse/lychee on how to run locally. 26 | - name: Link Checker 27 | id: lychee 28 | uses: lycheeverse/lychee-action@v1.9.0 29 | with: 30 | fail: true 31 | lycheeVersion: "0.14.3" 32 | # When given a directory, lychee checks only markdown, html and text files, everything else we have to glob in manually. 33 | args: | 34 | --base . --cache --max-cache-age 1d . "**/*.rs" "**/*.toml" "**/*.hpp" "**/*.cpp" "**/CMakeLists.txt" "**/*.py" "**/*.yml" 35 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/rerun-io/rerun_template 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | pull_request: 7 | types: [ opened, synchronize ] 8 | 9 | name: Rust 10 | 11 | env: 12 | RUSTFLAGS: -D warnings 13 | RUSTDOCFLAGS: -D warnings 14 | 15 | jobs: 16 | rust-check: 17 | name: Rust 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - uses: actions-rs/toolchain@v1 23 | with: 24 | profile: default 25 | toolchain: 1.81.0 26 | override: true 27 | 28 | - name: Install packages (Linux) 29 | if: runner.os == 'Linux' 30 | uses: awalsh128/cache-apt-pkgs-action@v1.4.3 31 | with: 32 | # some deps used by eframe, for checking the example 33 | packages: libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev # libgtk-3-dev is used by rfd 34 | version: 1.0 35 | execute_install_scripts: true 36 | 37 | - name: Set up cargo cache 38 | uses: Swatinem/rust-cache@v2 39 | 40 | - name: Rustfmt 41 | run: cargo fmt --all -- --check 42 | 43 | - name: check --all-features 44 | run: cargo check --all-features --all-targets 45 | 46 | - name: check default features 47 | run: cargo check --all-targets 48 | 49 | - name: check --no-default-features 50 | run: cargo check --no-default-features --lib --all-targets 51 | 52 | - name: Test doc-tests 53 | run: cargo test --doc --all-features 54 | 55 | - name: cargo doc --lib 56 | run: cargo doc --lib --no-deps --all-features 57 | 58 | - name: cargo doc --document-private-items 59 | run: cargo doc --document-private-items --no-deps --all-features 60 | 61 | - name: Build tests 62 | run: cargo test --all-features --no-run 63 | 64 | - name: Run test 65 | run: cargo test --all-features 66 | 67 | - name: Clippy 68 | run: cargo clippy --all-targets --all-features -- -D warnings 69 | 70 | # --------------------------------------------------------------------------- 71 | 72 | check_wasm: 73 | name: Check wasm32 74 | runs-on: ubuntu-latest 75 | steps: 76 | - uses: actions/checkout@v4 77 | - uses: actions-rs/toolchain@v1 78 | with: 79 | profile: minimal 80 | toolchain: 1.81.0 81 | target: wasm32-unknown-unknown 82 | override: true 83 | components: clippy 84 | 85 | - name: Set up cargo cache 86 | uses: Swatinem/rust-cache@v2 87 | 88 | - name: Check wasm32 89 | run: cargo check --target wasm32-unknown-unknown --lib 90 | 91 | - name: Clippy wasm32 92 | env: 93 | CLIPPY_CONF_DIR: "scripts/clippy_wasm" # Use scripts/clippy_wasm/clippy.toml 94 | run: cargo clippy --target wasm32-unknown-unknown --lib -- -D warnings 95 | 96 | # --------------------------------------------------------------------------- 97 | 98 | cargo-deny: 99 | name: Check Rust dependencies (cargo-deny) 100 | runs-on: ubuntu-latest 101 | steps: 102 | - uses: actions/checkout@v3 103 | - uses: EmbarkStudios/cargo-deny-action@v2 104 | with: 105 | rust-version: "1.81.0" 106 | log-level: warn 107 | command: check 108 | -------------------------------------------------------------------------------- /.github/workflows/typos.yml: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/rerun-io/rerun_template 2 | 3 | # https://github.com/crate-ci/typos 4 | # Add exceptions to `.typos.toml` 5 | # install and run locally: cargo install typos-cli && typos 6 | 7 | name: Spell Check 8 | on: [pull_request] 9 | 10 | jobs: 11 | run: 12 | name: Spell Check 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Actions Repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Check spelling of entire workspace 19 | uses: crate-ci/typos@master 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac stuff: 2 | .DS_Store 3 | 4 | # Rust compile target directories: 5 | target 6 | target_ra 7 | target_wasm 8 | 9 | # https://github.com/lycheeverse/lychee 10 | .lycheecache 11 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/crate-ci/typos 2 | # install: cargo install typos-cli 3 | # run: typos 4 | 5 | [default.extend-words] 6 | teh = "teh" # part of @teh-cmc 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "charliermarsh.ruff", 6 | "esbenp.prettier-vscode", 7 | "fill-labs.dependi", 8 | "gaborv.flatbuffers", 9 | "github.vscode-github-actions", 10 | "josetr.cmake-language-support-vscode", 11 | "ms-python.mypy-type-checker", 12 | "ms-python.python", 13 | "ms-vscode.cmake-tools", 14 | "ms-vscode.cpptools-extension-pack", 15 | "ms-vsliveshare.vsliveshare", 16 | "polymeilex.wgsl", 17 | "rust-lang.rust-analyzer", 18 | "streetsidesoftware.code-spell-checker", 19 | "tamasfe.even-better-toml", 20 | "vadimcn.vscode-lldb", 21 | "wayou.vscode-todo-highlight", 22 | "webfreak.debug", 23 | "xaver.clang-format", // C++ formatter 24 | "zxh404.vscode-proto3", 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | // Rust: 8 | { 9 | "name": "Debug 'advanced' example", 10 | "type": "lldb", 11 | "request": "launch", 12 | "cargo": { 13 | "args": [ 14 | "build", 15 | "--example=advanced", 16 | ], 17 | "filter": { 18 | "name": "advanced", 19 | "kind": "example" 20 | } 21 | }, 22 | "args": [], 23 | "cwd": "${workspaceFolder}", 24 | "env": { 25 | "RUST_LOG": "debug" 26 | } 27 | }, 28 | { 29 | "name": "Launch Rust tests", 30 | "type": "lldb", 31 | "request": "launch", 32 | "cargo": { 33 | "args": [ 34 | "test", 35 | "--no-run", 36 | "--lib", 37 | "--all-features" 38 | ], 39 | "filter": { 40 | "kind": "lib" 41 | } 42 | }, 43 | "cwd": "${workspaceFolder}", 44 | "env": { 45 | "RUST_LOG": "debug" 46 | } 47 | }, 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.semanticTokenColorCustomizations": { 4 | "rules": { 5 | "*.unsafe:rust": "#eb5046" 6 | } 7 | }, 8 | "files.autoGuessEncoding": true, 9 | "files.insertFinalNewline": true, 10 | "files.trimTrailingWhitespace": true, 11 | // don't share a cargo lock with rust-analyzer. 12 | // see https://github.com/rerun-io/rerun/pull/519 for rationale 13 | "rust-analyzer.check.overrideCommand": [ 14 | "cargo", 15 | "clippy", 16 | "--target-dir=target_ra", 17 | "--workspace", 18 | "--message-format=json", 19 | "--all-targets", 20 | "--all-features" 21 | ], 22 | "rust-analyzer.cargo.buildScripts.overrideCommand": [ 23 | "cargo", 24 | "check", 25 | "--quiet", 26 | "--target-dir=target_ra", 27 | "--workspace", 28 | "--message-format=json", 29 | "--all-targets", 30 | "--all-features", 31 | ], 32 | // Our build scripts are generating code. 33 | // Having Rust Analyzer do this while doing other builds can lead to catastrophic failures. 34 | // INCLUDING attempts to publish a new release! 35 | "rust-analyzer.cargo.buildScripts.enable": false, 36 | "C_Cpp.default.configurationProvider": "ms-vscode.cmake-tools", // Use cmake-tools to grab configs. 37 | "C_Cpp.autoAddFileAssociations": false, 38 | "cmake.buildDirectory": "${workspaceRoot}/build/debug", 39 | "cmake.generator": "Ninja", // Use Ninja, just like we do in our just/pixi command. 40 | "rust-analyzer.showUnlinkedFileNotification": false, 41 | "ruff.configuration": "pyproject.toml", 42 | "prettier.requireConfig": true, 43 | "prettier.configPath": ".prettierrc.toml", 44 | "[python]": { 45 | "editor.defaultFormatter": "charliermarsh.ruff" 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # `egui_tiles` Changelog 2 | 3 | 4 | ## 0.11.0 - 2024-12-17 5 | * Update MSRV to 1.80 [#91](https://github.com/rerun-io/egui_tiles/pull/91) by [@emilk](https://github.com/emilk) 6 | * Update to egui 0.30.0 [#92](https://github.com/rerun-io/egui_tiles/pull/92) by [@emilk](https://github.com/emilk) 7 | 8 | 9 | ## 0.10.1 - 2024-10-01 10 | * Fix `Tree` serialization [#87](https://github.com/rerun-io/egui_tiles/pull/87) 11 | 12 | 13 | ## 0.10.0 - 2024-09-26 14 | * Update to egui 0.29 [#78](https://github.com/rerun-io/egui_tiles/pull/78) 15 | * Add `Tree::set_width` and `set_height` functions [#73](https://github.com/rerun-io/egui_tiles/pull/73) (thanks [@rafaga](https://github.com/rafaga)!) 16 | * Fix for eagerly starting a drag when clicking tab background [#80](https://github.com/rerun-io/egui_tiles/pull/80) 17 | * Fix `Tree` deserialization using JSON [#85](https://github.com/rerun-io/egui_tiles/pull/85) (thanks [@hastri](https://github.com/hastri)!) 18 | 19 | 20 | ## [0.9.1](https://github.com/rerun-io/egui_tiles/compare/0.9.0...0.9.1) - 2024-08-27 21 | * Add `Tree::set_width` and `set_height` functions [#73](https://github.com/rerun-io/egui_tiles/pull/73) (thanks [@rafaga](https://github.com/rafaga)!) 22 | * Fix for eagerly starting a drag when clicking tab background [#80](https://github.com/rerun-io/egui_tiles/pull/80) 23 | 24 | 25 | ## [0.9.0](https://github.com/rerun-io/egui_tiles/compare/0.8.0...0.9.0) - 2024-07-03 - egui 0.28 and tab close buttons 26 | Full diff at https://github.com/rerun-io/egui_tiles/compare/0.8.0..HEAD 27 | 28 | * Update to egui 0.28.0 [#67](https://github.com/rerun-io/egui_tiles/pull/67) 29 | * Update to Rust 1.76 [#60](https://github.com/rerun-io/egui_tiles/pull/60) [#66](https://github.com/rerun-io/egui_tiles/pull/66) 30 | * Optional close-buttons on tabs [#70](https://github.com/rerun-io/egui_tiles/pull/70) (thanks [@voidburn](https://github.com/voidburn)!) 31 | * Add `Tiles::rect` to read where a tile is [#61](https://github.com/rerun-io/egui_tiles/pull/61) 32 | * Add `Behavior::paint_on_top_of_tile` [#62](https://github.com/rerun-io/egui_tiles/pull/62) 33 | * Fix: make sure `Tree::ui` allocates the space it uses in parent `Ui` [#71](https://github.com/rerun-io/egui_tiles/pull/71) (thanks [@rydb](https://github.com/rydb)!) 34 | * Fix bugs when having multiple `Tree`s visible at the same time [#68](https://github.com/rerun-io/egui_tiles/pull/68) (thanks [@GuillaumeSchmid](https://github.com/GuillaumeSchmid)!) 35 | * Fix drag-and-drop of tiles on touchscreen devices [#74](https://github.com/rerun-io/egui_tiles/pull/74) (thanks [@mcoroz](https://github.com/mcoroz)!) 36 | * Fix container resize drag for touchscreens [#75](https://github.com/rerun-io/egui_tiles/pull/75) (thanks [@mcoroz](https://github.com/mcoroz)!) 37 | * Update release instructions [62ecb4c](https://github.com/rerun-io/egui_tiles/commit/62ecb4ccd52bdabd11e688e4e6e29e4d1a3783ab) 38 | * Add clippy lint `match_bool` [fadf41a](https://github.com/rerun-io/egui_tiles/commit/fadf41ab42af5527e8a17af436a5608dd7dbd7bf) 39 | * Add a PR template [87110a9](https://github.com/rerun-io/egui_tiles/commit/87110a98a280f73c77b80507367290691f75d33b) 40 | * Expose `egui_tiles::TabState` [6e88ea9](https://github.com/rerun-io/egui_tiles/commit/6e88ea9774d63b0a7a8a67af9a90c13a4b3efb10) 41 | * Pass `&TabState` to all relevant functions in Behavior [ee1286a](https://github.com/rerun-io/egui_tiles/commit/ee1286a975239ffa34258313a11d2bf03ec4cea9) 42 | 43 | 44 | ## [0.8.0](https://github.com/rerun-io/egui_tiles/compare/0.7.2...0.8.0) - 2024-03-26 45 | * Update to egui 0.27.0 [#58](https://github.com/rerun-io/egui_tiles/pull/58) 46 | * Re-export `Shares` [#56](https://github.com/rerun-io/egui_tiles/pull/56) (thanks [@Gohla](https://github.com/Gohla)!) 47 | * Propagate `enabled` status for tile `Ui` [#55](https://github.com/rerun-io/egui_tiles/pull/55) (thanks [@Gohla](https://github.com/Gohla)!) 48 | 49 | 50 | ## [0.7.2](https://github.com/rerun-io/egui_tiles/compare/0.7.1...0.7.2) - 2024-02-07 51 | * Fix `move_tile_to_container` behavior for grid-to-same-grid moves with reflow enabled [#53](https://github.com/rerun-io/egui_tiles/pull/53) 52 | 53 | 54 | ## [0.7.1](https://github.com/rerun-io/egui_tiles/compare/0.7.0...0.7.1) - 2024-02-06 55 | * Make sure there is always an active tab [#50](https://github.com/rerun-io/egui_tiles/pull/50) 56 | * Derive `Clone, Debug, PartialEq, Eq` for `EditAction` [#51](https://github.com/rerun-io/egui_tiles/pull/51) 57 | 58 | 59 | ## [0.7.0](https://github.com/rerun-io/egui_tiles/compare/0.6.0...0.7.0) - 2024-02-06 60 | * Add an API to move an existing tile to an give container and position index [#44](https://github.com/rerun-io/egui_tiles/pull/44) 61 | * Properly handle grid layout with `Tree::move_tile_to_container()` [#45](https://github.com/rerun-io/egui_tiles/pull/45) 62 | * Turn some warn logging to debug logging [#47](https://github.com/rerun-io/egui_tiles/pull/47) 63 | * Add an `EditAction` parameter to the `Behavior::on_edit()` call [#48](https://github.com/rerun-io/egui_tiles/pull/48) 64 | * Update to `egui` 0.26 [#49](https://github.com/rerun-io/egui_tiles/pull/49) 65 | 66 | 67 | ## [0.6.0](https://github.com/rerun-io/egui_tiles/compare/0.5.0...0.6.0) - 2024-01-08 68 | * Update to egui 0.25 [#43](https://github.com/rerun-io/egui_tiles/pull/43) 69 | 70 | 71 | ## [0.5.0](https://github.com/rerun-io/egui_tiles/compare/0.4.0...0.5.0) - 2024-01-04 72 | * Pass `TileId` to `make_active` closure [#35](https://github.com/rerun-io/egui_tiles/pull/35) 73 | * Add `SimplificationOptions::OFF` [#38](https://github.com/rerun-io/egui_tiles/pull/38) 74 | * Add `Tree::simplify_children_of_tile` [#39) [#41](https://github.com/rerun-io/egui_tiles/pull/41) 75 | * Expose the internal `u64` part of `TileId` [#40](https://github.com/rerun-io/egui_tiles/pull/40) 76 | * Fix simplification errors that result in warnings after removing panes [#41](https://github.com/rerun-io/egui_tiles/pull/41) 77 | * Add `Tree::active_tiles` for getting visible tiles [#42](https://github.com/rerun-io/egui_tiles/pull/42) 78 | 79 | 80 | ## [0.4.0](https://github.com/rerun-io/egui_tiles/compare/0.3.1...0.4.0) - 2023-11-23 81 | * Fix Id clash when using multiple `Tree`s [#32](https://github.com/rerun-io/egui_tiles/pull/32) 82 | * Scrollable tab bar [#9](https://github.com/rerun-io/egui_tiles/pull/9) 83 | * `Behavior::on_tab_button` can now add context menus, on hover ui etc [#23](https://github.com/rerun-io/egui_tiles/pull/23) 84 | * `serde` is now and optional dependency [#13](https://github.com/rerun-io/egui_tiles/pull/13) 85 | * Update to egui 0.24 86 | * Update MSRV to Rust 1.72 87 | 88 | 89 | ## [0.3.1](https://github.com/rerun-io/egui_tiles/compare/0.3.0...0.3.1) - 2023-09-29 90 | * Report edits to user with `Behavior::on_edit` [#29](https://github.com/rerun-io/egui_tiles/pull/29) 91 | * Make `Tree::simplify` public [#28](https://github.com/rerun-io/egui_tiles/pull/28) 92 | * Add `Shares::set_share` method [#25](https://github.com/rerun-io/egui_tiles/pull/25) 93 | 94 | 95 | ## [0.3.0](https://github.com/rerun-io/egui_tiles/compare/0.2.0...0.3.0) - 2023-09-28 96 | * Update to egui 0.23 97 | * Better grid column-count heuristic 98 | * Make drag preview style customizable 99 | 100 | 101 | ## [0.2.0](https://github.com/rerun-io/egui_tiles/compare/0.1.0...0.2.0) - Invisible tiles - 2023-07-06 102 | * Add support for invisible tiles 103 | * `PartialEq` for `Tiles` now ignores internal state 104 | * Add `Tiles::find_pane` 105 | * Add `Tiles::remove_recursively` 106 | 107 | 108 | ## 0.1.0 - Initial Release - 2023-05-24 109 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | opensource@rerun.io. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = [ 3 | "Emil Ernerfeldt ", 4 | "rerun.io ", 5 | ] 6 | categories = ["gui"] 7 | description = "A tiling layout engine for egui with drag-and-drop and resizing" 8 | edition = "2021" 9 | homepage = "https://github.com/rerun-io/egui_tiles" 10 | include = ["LICENSE-APACHE", "LICENSE-MIT", "**/*.rs", "Cargo.toml"] 11 | keywords = ["egui", "gui", "tile", "dock", "layout"] 12 | license = "MIT OR Apache-2.0" 13 | name = "egui_tiles" 14 | readme = "README.md" 15 | repository = "https://github.com/rerun-io/egui_tiles" 16 | rust-version = "1.81" 17 | version = "0.12.0" 18 | 19 | [package.metadata.docs.rs] 20 | all-features = true 21 | 22 | 23 | [features] 24 | default = ["serde"] 25 | serde = ["dep:serde", "egui/serde"] 26 | 27 | 28 | [dependencies] 29 | ahash = { version = "0.8.1", default-features = false, features = [ 30 | "no-rng", # we don't need DOS-protection for what we use ahash for 31 | "std", 32 | ] } 33 | egui = { version = "0.31", default-features = false } 34 | itertools = "0.13" 35 | log = { version = "0.4", features = ["std"] } 36 | serde = { version = "1", features = ["derive"], optional = true } 37 | 38 | 39 | [dev-dependencies] 40 | # For the example: 41 | eframe = { version = "0.31", default-features = false, features = [ 42 | "default_fonts", # Embed the default egui fonts. 43 | "glow", # Use the glow rendering backend. Alternative: "wgpu". 44 | "persistence", # Enable restoring app state when restarting the app. 45 | "wayland", # Required for Linux and CI. 46 | ] } 47 | env_logger = { version = "0.10", default-features = false, features = [ 48 | "auto-color", 49 | "humantime", 50 | ] } 51 | 52 | # For tests: 53 | serde_json = "1" 54 | ron = "0.8" 55 | 56 | 57 | [patch.crates-io] 58 | # Useful while developing: 59 | 60 | #eframe = { git = "https://github.com/emilk/egui.git", branch = "master" } 61 | #egui = { git = "https://github.com/emilk/egui.git", branch = "master" } 62 | 63 | # eframe = { path = "../../egui/crates/eframe" } 64 | # egui = { path = "../../egui/crates/egui" } 65 | 66 | 67 | [lints] 68 | workspace = true 69 | 70 | 71 | [workspace.lints.rust] 72 | unsafe_code = "deny" 73 | 74 | elided_lifetimes_in_paths = "warn" 75 | future_incompatible = { level = "warn", priority = -1 } 76 | nonstandard_style = { level = "warn", priority = -1 } 77 | rust_2018_idioms = { level = "warn", priority = -1 } 78 | rust_2021_prelude_collisions = "warn" 79 | semicolon_in_expressions_from_macros = "warn" 80 | trivial_numeric_casts = "warn" 81 | unsafe_op_in_unsafe_fn = "warn" # `unsafe_op_in_unsafe_fn` may become the default in future Rust versions: https://github.com/rust-lang/rust/issues/71668 82 | unused_extern_crates = "warn" 83 | unused_import_braces = "warn" 84 | unused_lifetimes = "warn" 85 | 86 | trivial_casts = "allow" 87 | unused_qualifications = "allow" 88 | 89 | [workspace.lints.rustdoc] 90 | all = "warn" 91 | missing_crate_level_docs = "warn" 92 | 93 | # See also clippy.toml 94 | [workspace.lints.clippy] 95 | as_ptr_cast_mut = "warn" 96 | await_holding_lock = "warn" 97 | bool_to_int_with_if = "warn" 98 | char_lit_as_u8 = "warn" 99 | checked_conversions = "warn" 100 | clear_with_drain = "warn" 101 | cloned_instead_of_copied = "warn" 102 | dbg_macro = "warn" 103 | debug_assert_with_mut_call = "warn" 104 | derive_partial_eq_without_eq = "warn" 105 | disallowed_macros = "warn" # See clippy.toml 106 | disallowed_methods = "warn" # See clippy.toml 107 | disallowed_names = "warn" # See clippy.toml 108 | disallowed_script_idents = "warn" # See clippy.toml 109 | disallowed_types = "warn" # See clippy.toml 110 | doc_link_with_quotes = "warn" 111 | doc_markdown = "warn" 112 | empty_enum = "warn" 113 | empty_enum_variants_with_brackets = "warn" 114 | enum_glob_use = "warn" 115 | equatable_if_let = "warn" 116 | exit = "warn" 117 | expl_impl_clone_on_copy = "warn" 118 | explicit_deref_methods = "warn" 119 | explicit_into_iter_loop = "warn" 120 | explicit_iter_loop = "warn" 121 | fallible_impl_from = "warn" 122 | filter_map_next = "warn" 123 | flat_map_option = "warn" 124 | float_cmp_const = "warn" 125 | fn_params_excessive_bools = "warn" 126 | fn_to_numeric_cast_any = "warn" 127 | from_iter_instead_of_collect = "warn" 128 | get_unwrap = "warn" 129 | if_let_mutex = "warn" 130 | implicit_clone = "warn" 131 | imprecise_flops = "warn" 132 | index_refutable_slice = "warn" 133 | inefficient_to_string = "warn" 134 | infinite_loop = "warn" 135 | into_iter_without_iter = "warn" 136 | invalid_upcast_comparisons = "warn" 137 | iter_filter_is_ok = "warn" 138 | iter_filter_is_some = "warn" 139 | iter_not_returning_iterator = "warn" 140 | iter_on_empty_collections = "warn" 141 | iter_on_single_items = "warn" 142 | iter_over_hash_type = "warn" 143 | iter_without_into_iter = "warn" 144 | large_digit_groups = "warn" 145 | large_include_file = "warn" 146 | large_stack_arrays = "warn" 147 | large_stack_frames = "warn" 148 | large_types_passed_by_value = "warn" 149 | let_underscore_must_use = "warn" 150 | let_underscore_untyped = "warn" 151 | let_unit_value = "warn" 152 | linkedlist = "warn" 153 | lossy_float_literal = "warn" 154 | macro_use_imports = "warn" 155 | manual_assert = "warn" 156 | manual_clamp = "warn" 157 | manual_instant_elapsed = "warn" 158 | manual_is_variant_and = "warn" 159 | manual_let_else = "warn" 160 | manual_ok_or = "warn" 161 | manual_string_new = "warn" 162 | map_err_ignore = "warn" 163 | map_flatten = "warn" 164 | map_unwrap_or = "warn" 165 | match_bool = "warn" 166 | match_on_vec_items = "warn" 167 | match_same_arms = "warn" 168 | match_wild_err_arm = "warn" 169 | match_wildcard_for_single_variants = "warn" 170 | mem_forget = "warn" 171 | mismatched_target_os = "warn" 172 | mismatching_type_param_order = "warn" 173 | missing_assert_message = "warn" 174 | missing_enforced_import_renames = "warn" 175 | missing_errors_doc = "warn" 176 | missing_safety_doc = "warn" 177 | mixed_attributes_style = "warn" 178 | mut_mut = "warn" 179 | mutex_integer = "warn" 180 | needless_borrow = "warn" 181 | needless_continue = "warn" 182 | needless_for_each = "warn" 183 | needless_pass_by_ref_mut = "warn" 184 | needless_pass_by_value = "warn" 185 | negative_feature_names = "warn" 186 | nonstandard_macro_braces = "warn" 187 | option_as_ref_cloned = "warn" 188 | option_option = "warn" 189 | path_buf_push_overwrite = "warn" 190 | ptr_as_ptr = "warn" 191 | ptr_cast_constness = "warn" 192 | pub_underscore_fields = "warn" 193 | pub_without_shorthand = "warn" 194 | rc_mutex = "warn" 195 | readonly_write_lock = "warn" 196 | redundant_type_annotations = "warn" 197 | ref_as_ptr = "warn" 198 | ref_option_ref = "warn" 199 | rest_pat_in_fully_bound_structs = "warn" 200 | same_functions_in_if_condition = "warn" 201 | semicolon_if_nothing_returned = "warn" 202 | should_panic_without_expect = "warn" 203 | significant_drop_tightening = "warn" 204 | single_match_else = "warn" 205 | str_split_at_newline = "warn" 206 | str_to_string = "warn" 207 | string_add = "warn" 208 | string_add_assign = "warn" 209 | string_lit_as_bytes = "warn" 210 | string_lit_chars_any = "warn" 211 | string_to_string = "warn" 212 | suspicious_command_arg_space = "warn" 213 | suspicious_xor_used_as_pow = "warn" 214 | todo = "warn" 215 | too_many_lines = "warn" 216 | trailing_empty_array = "warn" 217 | trait_duplication_in_bounds = "warn" 218 | tuple_array_conversions = "warn" 219 | unchecked_duration_subtraction = "warn" 220 | undocumented_unsafe_blocks = "warn" 221 | unimplemented = "warn" 222 | uninhabited_references = "warn" 223 | uninlined_format_args = "warn" 224 | unnecessary_box_returns = "warn" 225 | unnecessary_safety_doc = "warn" 226 | unnecessary_struct_initialization = "warn" 227 | unnecessary_wraps = "warn" 228 | unnested_or_patterns = "warn" 229 | unused_peekable = "warn" 230 | unused_rounding = "warn" 231 | unused_self = "warn" 232 | unwrap_used = "warn" 233 | use_self = "warn" 234 | useless_transmute = "warn" 235 | verbose_file_reads = "warn" 236 | wildcard_dependencies = "warn" 237 | wildcard_imports = "warn" 238 | zero_sized_map_values = "warn" 239 | 240 | manual_range_contains = "allow" # this one is just worse imho 241 | ref_patterns = "allow" # It's nice to avoid ref pattern, but there are some situations that are hard (impossible?) to express without. 242 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Rerun Technologies AB 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `egui_tiles` 2 | 3 | [github](https://github.com/rerun-io/egui_tiles) 4 | [![Latest version](https://img.shields.io/crates/v/egui_tiles.svg)](https://crates.io/crates/egui_tiles) 5 | [![Documentation](https://docs.rs/egui_tiles/badge.svg)](https://docs.rs/egui_tiles) 6 | [![unsafe forbidden](https://img.shields.io/badge/unsafe-forbidden-success.svg)](https://github.com/rust-secure-code/safety-dance/) 7 | [![Build Status](https://github.com/rerun-io/egui_tiles/workflows/CI/badge.svg)](https://github.com/rerun-io/egui_tiles/actions?workflow=CI) 8 | [![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/rerun-io/egui_tiles/blob/master/LICENSE-MIT) 9 | [![Apache](https://img.shields.io/badge/license-Apache-blue.svg)](https://github.com/rerun-io/egui_tiles/blob/master/LICENSE-APACHE) 10 | 11 | Layouting and docking for [egui](https://github.com/rerun-io/egui). 12 | 13 | Supports: 14 | * Horizontal and vertical layouts 15 | * Grid layouts 16 | * Tabs 17 | * Drag-and-drop docking 18 | 19 | ![egui_tiles](https://github.com/rerun-io/egui_tiles/assets/1148717/f86bee40-2506-4484-8a82-37ffdc805b81) 20 | 21 | ### Trying it 22 | `cargo r --example simple` 23 | 24 | ### Comparison with [egui_dock](https://github.com/Adanos020/egui_dock) 25 | [egui_dock](https://github.com/Adanos020/egui_dock) is an excellent crate serving similar needs. `egui_tiles` aims to become a more flexible and feature-rich alternative to `egui_dock`. 26 | 27 | `egui_dock` only supports binary splits (left/right or top/bottom), while `egui_tiles` support full horizontal and vertical layouts, as well as grid layouts. `egui_tiles` also strives to be more customizable, enabling users to override the default style and behavior by implementing methods on a `Behavior` `trait`. 28 | 29 | `egui_dock` supports some features that `egui_tiles` does not yet support, such as close-buttons on each tab, and built-in scroll areas. 30 | 31 | --- 32 | 33 |
34 | 35 | 36 | `egui_tiles` development is sponsored by [Rerun](https://www.rerun.io/), a startup doing
37 | visualizations for computer vision and robotics. 38 |
39 | -------------------------------------------------------------------------------- /RELEASES.md: -------------------------------------------------------------------------------- 1 | # Release Checklist 2 | 3 | * [ ] Update `CHANGELOG.md` using `./scripts/generate_changelog.py --version 0.NEW.VERSION` 4 | * [ ] Bump version numbers in `Cargo.toml` and run `cargo check`. 5 | * [ ] `git commit -m 'Release 0.x.0 - summary'` 6 | * [ ] `cargo publish --quiet -p egui_tiles` 7 | * [ ] `git tag -a 0.x.0 -m 'Release 0.x.0 - summary'` 8 | * [ ] `git pull --tags ; git tag -d latest && git tag -a latest -m 'Latest release' && git push --tags origin latest --force ; git push --tags` 9 | * [ ] Do a GitHub release: https://github.com/rerun-io/egui_tiles/releases/new 10 | -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # This scripts runs various CI-like checks in a convenient way. 3 | 4 | set -eu 5 | script_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 6 | cd "$script_path" 7 | set -x 8 | 9 | export RUSTFLAGS="--deny warnings" 10 | export RUSTDOCFLAGS="--deny warnings" 11 | 12 | cargo fmt --all -- --check 13 | cargo clippy --quiet --all-targets --all-features -- --deny warnings 14 | cargo test --quiet --all-targets --all-features 15 | cargo test --quiet --doc --all-features # checks all doc-tests 16 | 17 | cargo doc --quiet --no-deps --all-features 18 | cargo doc --quiet --document-private-items --no-deps --all-features 19 | 20 | cargo deny --all-features --log-level error check 21 | 22 | typos # cargo install typos-cli 23 | 24 | echo "All checks passed!" 25 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/rerun-io/rerun_template 2 | # 3 | # There is also a scripts/clippy_wasm/clippy.toml which forbids some methods that are not available in wasm. 4 | 5 | # ----------------------------------------------------------------------------- 6 | # Section identical to scripts/clippy_wasm/clippy.toml: 7 | 8 | msrv = "1.81" 9 | 10 | allow-unwrap-in-tests = true 11 | 12 | # https://doc.rust-lang.org/nightly/clippy/lint_configuration.html#avoid-breaking-exported-api 13 | # We want suggestions, even if it changes public API. 14 | avoid-breaking-exported-api = false 15 | 16 | excessive-nesting-threshold = 10 # TODO(emilk): Lower this 17 | 18 | max-fn-params-bools = 1 19 | 20 | # https://rust-lang.github.io/rust-clippy/master/index.html#/large_include_file 21 | max-include-file-size = 1000000 22 | 23 | # https://rust-lang.github.io/rust-clippy/master/index.html#/large_stack_frames 24 | stack-size-threshold = 512000 25 | 26 | too-many-lines-threshold = 200 27 | 28 | # ----------------------------------------------------------------------------- 29 | 30 | # https://rust-lang.github.io/rust-clippy/master/index.html#disallowed_macros 31 | disallowed-macros = ['dbg'] 32 | 33 | # https://rust-lang.github.io/rust-clippy/master/index.html#disallowed_methods 34 | disallowed-methods = [ 35 | { path = "egui_extras::TableBody::row", reason = "`row` doesn't scale. Use `rows` instead." }, 36 | { path = "glam::Vec2::normalize", reason = "normalize() can create NaNs. Use try_normalize or normalize_or_zero" }, 37 | { path = "glam::Vec3::normalize", reason = "normalize() can create NaNs. Use try_normalize or normalize_or_zero" }, 38 | { path = "sha1::Digest::new", reason = "SHA1 is cryptographically broken" }, 39 | { path = "std::env::temp_dir", reason = "Use the tempdir crate instead" }, 40 | { path = "std::panic::catch_unwind", reason = "We compile with `panic = 'abort'`" }, 41 | { path = "std::thread::spawn", reason = "Use `std::thread::Builder` and name the thread" }, 42 | 43 | # There are many things that aren't allowed on wasm, 44 | # but we cannot disable them all here (because of e.g. https://github.com/rust-lang/rust-clippy/issues/10406) 45 | # so we do that in `scripts/clippy_wasm/clippy.toml` instead. 46 | ] 47 | 48 | # https://rust-lang.github.io/rust-clippy/master/index.html#disallowed_names 49 | disallowed-names = [] 50 | 51 | # https://rust-lang.github.io/rust-clippy/master/index.html#disallowed_types 52 | disallowed-types = [ 53 | { path = "ring::digest::SHA1_FOR_LEGACY_USE_ONLY", reason = "SHA1 is cryptographically broken" }, 54 | 55 | { path = "std::sync::Condvar", reason = "Use parking_lot instead" }, 56 | { path = "std::sync::Mutex", reason = "Use parking_lot instead" }, 57 | { path = "std::sync::RwLock", reason = "Use parking_lot instead" }, 58 | 59 | # "std::sync::Once", # enabled for now as the `log_once` macro uses it internally 60 | ] 61 | 62 | # Allow-list of words for markdown in docstrings https://rust-lang.github.io/rust-clippy/master/index.html#doc_markdown 63 | doc-valid-idents = [ 64 | # You must also update the same list in `scripts/clippy_wasm/clippy.toml`! 65 | "GitHub", 66 | "GLB", 67 | "GLTF", 68 | "iOS", 69 | "macOS", 70 | "NaN", 71 | "OBJ", 72 | "OpenGL", 73 | "PyPI", 74 | "sRGB", 75 | "sRGBA", 76 | "WebGL", 77 | "WebSocket", 78 | "WebSockets", 79 | ] 80 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/rerun-io/rerun_template 2 | # 3 | # https://github.com/EmbarkStudios/cargo-deny 4 | # 5 | # cargo-deny checks our dependency tree for copy-left licenses, 6 | # duplicate dependencies, and rustsec advisories (https://rustsec.org/advisories). 7 | # 8 | # Install: `cargo install cargo-deny` 9 | # Check: `cargo deny check`. 10 | 11 | 12 | # Note: running just `cargo deny check` without a `--target` can result in 13 | # false positives due to https://github.com/EmbarkStudios/cargo-deny/issues/324 14 | [graph] 15 | targets = [ 16 | { triple = "aarch64-apple-darwin" }, 17 | { triple = "i686-pc-windows-gnu" }, 18 | { triple = "i686-pc-windows-msvc" }, 19 | { triple = "i686-unknown-linux-gnu" }, 20 | { triple = "wasm32-unknown-unknown" }, 21 | { triple = "x86_64-apple-darwin" }, 22 | { triple = "x86_64-pc-windows-gnu" }, 23 | { triple = "x86_64-pc-windows-msvc" }, 24 | { triple = "x86_64-unknown-linux-gnu" }, 25 | { triple = "x86_64-unknown-linux-musl" }, 26 | { triple = "x86_64-unknown-redox" }, 27 | ] 28 | all-features = true 29 | 30 | 31 | [advisories] 32 | version = 2 33 | ignore = [] 34 | 35 | 36 | [bans] 37 | multiple-versions = "deny" 38 | wildcards = "deny" 39 | deny = [ 40 | { name = "openssl", reason = "Use rustls" }, 41 | { name = "openssl-sys", reason = "Use rustls" }, 42 | ] 43 | skip = [] 44 | skip-tree = [ 45 | { name = "eframe" }, # dev-dependency 46 | ] 47 | 48 | 49 | [licenses] 50 | version = 2 51 | private = { ignore = true } 52 | confidence-threshold = 0.93 # We want really high confidence when inferring licenses from text 53 | allow = [ 54 | "Apache-2.0 WITH LLVM-exception", # https://spdx.org/licenses/LLVM-exception.html 55 | "Apache-2.0", # https://tldrlegal.com/license/apache-license-2.0-(apache-2.0) 56 | "BSD-2-Clause", # https://tldrlegal.com/license/bsd-2-clause-license-(freebsd) 57 | "BSD-3-Clause", # https://tldrlegal.com/license/bsd-3-clause-license-(revised) 58 | "BSL-1.0", # https://tldrlegal.com/license/boost-software-license-1.0-explained 59 | "CC0-1.0", # https://creativecommons.org/publicdomain/zero/1.0/ 60 | "ISC", # https://www.tldrlegal.com/license/isc-license 61 | "MIT-0", # https://choosealicense.com/licenses/mit-0/ 62 | "MIT", # https://tldrlegal.com/license/mit-license 63 | "MPL-2.0", # https://www.mozilla.org/en-US/MPL/2.0/FAQ/ - see Q11. Used by webpki-roots on Linux. 64 | "OFL-1.1", # https://spdx.org/licenses/OFL-1.1.html 65 | "OpenSSL", # https://www.openssl.org/source/license.html - used on Linux 66 | "Ubuntu-font-1.0", # https://ubuntu.com/legal/font-licence 67 | "Unicode-3.0", # https://www.unicode.org/license.txt 68 | "Unicode-DFS-2016", # https://spdx.org/licenses/Unicode-DFS-2016.html 69 | "Zlib", # https://tldrlegal.com/license/zlib-libpng-license-(zlib) 70 | ] 71 | exceptions = [] 72 | 73 | [[licenses.clarify]] 74 | name = "webpki" 75 | expression = "ISC" 76 | license-files = [{ path = "LICENSE", hash = 0x001c7e6c }] 77 | 78 | [[licenses.clarify]] 79 | name = "ring" 80 | expression = "MIT AND ISC AND OpenSSL" 81 | license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] 82 | 83 | 84 | [sources] 85 | unknown-registry = "deny" 86 | unknown-git = "deny" 87 | 88 | [sources.allow-org] 89 | github = ["emilk", "rerun-io"] 90 | -------------------------------------------------------------------------------- /examples/advanced.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release 2 | 3 | use eframe::egui; 4 | use egui_tiles::{Tile, TileId, Tiles}; 5 | 6 | fn main() -> Result<(), eframe::Error> { 7 | env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). 8 | let options = eframe::NativeOptions { 9 | viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 600.0]), 10 | ..Default::default() 11 | }; 12 | eframe::run_native( 13 | "egui_tiles example", 14 | options, 15 | Box::new(|_cc| { 16 | #[cfg_attr(not(feature = "serde"), allow(unused_mut))] 17 | let mut app = MyApp::default(); 18 | #[cfg(feature = "serde")] 19 | if let Some(storage) = _cc.storage { 20 | if let Some(state) = eframe::get_value(storage, eframe::APP_KEY) { 21 | app = state; 22 | } 23 | } 24 | Ok(Box::new(app)) 25 | }), 26 | ) 27 | } 28 | 29 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] 30 | pub struct Pane { 31 | nr: usize, 32 | } 33 | 34 | impl std::fmt::Debug for Pane { 35 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 36 | f.debug_struct("View").field("nr", &self.nr).finish() 37 | } 38 | } 39 | 40 | impl Pane { 41 | pub fn with_nr(nr: usize) -> Self { 42 | Self { nr } 43 | } 44 | 45 | pub fn ui(&self, ui: &mut egui::Ui) -> egui_tiles::UiResponse { 46 | let color = egui::epaint::Hsva::new(0.103 * self.nr as f32, 0.5, 0.5, 1.0); 47 | ui.painter().rect_filled(ui.max_rect(), 0.0, color); 48 | let dragged = ui 49 | .allocate_rect(ui.max_rect(), egui::Sense::click_and_drag()) 50 | .on_hover_cursor(egui::CursorIcon::Grab) 51 | .dragged(); 52 | if dragged { 53 | egui_tiles::UiResponse::DragStarted 54 | } else { 55 | egui_tiles::UiResponse::None 56 | } 57 | } 58 | } 59 | 60 | struct TreeBehavior { 61 | simplification_options: egui_tiles::SimplificationOptions, 62 | tab_bar_height: f32, 63 | gap_width: f32, 64 | add_child_to: Option, 65 | } 66 | 67 | impl Default for TreeBehavior { 68 | fn default() -> Self { 69 | Self { 70 | simplification_options: Default::default(), 71 | tab_bar_height: 24.0, 72 | gap_width: 2.0, 73 | add_child_to: None, 74 | } 75 | } 76 | } 77 | 78 | impl TreeBehavior { 79 | fn ui(&mut self, ui: &mut egui::Ui) { 80 | let Self { 81 | simplification_options, 82 | tab_bar_height, 83 | gap_width, 84 | add_child_to: _, 85 | } = self; 86 | 87 | egui::Grid::new("behavior_ui") 88 | .num_columns(2) 89 | .show(ui, |ui| { 90 | ui.label("All panes must have tabs:"); 91 | ui.checkbox(&mut simplification_options.all_panes_must_have_tabs, ""); 92 | ui.end_row(); 93 | 94 | ui.label("Join nested containers:"); 95 | ui.checkbox( 96 | &mut simplification_options.join_nested_linear_containers, 97 | "", 98 | ); 99 | ui.end_row(); 100 | 101 | ui.label("Tab bar height:"); 102 | ui.add( 103 | egui::DragValue::new(tab_bar_height) 104 | .range(0.0..=100.0) 105 | .speed(1.0), 106 | ); 107 | ui.end_row(); 108 | 109 | ui.label("Gap width:"); 110 | ui.add(egui::DragValue::new(gap_width).range(0.0..=20.0).speed(1.0)); 111 | ui.end_row(); 112 | }); 113 | } 114 | } 115 | 116 | impl egui_tiles::Behavior for TreeBehavior { 117 | fn pane_ui( 118 | &mut self, 119 | ui: &mut egui::Ui, 120 | _tile_id: egui_tiles::TileId, 121 | view: &mut Pane, 122 | ) -> egui_tiles::UiResponse { 123 | view.ui(ui) 124 | } 125 | 126 | fn tab_title_for_pane(&mut self, view: &Pane) -> egui::WidgetText { 127 | format!("View {}", view.nr).into() 128 | } 129 | 130 | fn top_bar_right_ui( 131 | &mut self, 132 | _tiles: &egui_tiles::Tiles, 133 | ui: &mut egui::Ui, 134 | tile_id: egui_tiles::TileId, 135 | _tabs: &egui_tiles::Tabs, 136 | _scroll_offset: &mut f32, 137 | ) { 138 | if ui.button("➕").clicked() { 139 | self.add_child_to = Some(tile_id); 140 | } 141 | } 142 | 143 | // --- 144 | // Settings: 145 | 146 | fn tab_bar_height(&self, _style: &egui::Style) -> f32 { 147 | self.tab_bar_height 148 | } 149 | 150 | fn gap_width(&self, _style: &egui::Style) -> f32 { 151 | self.gap_width 152 | } 153 | 154 | fn simplification_options(&self) -> egui_tiles::SimplificationOptions { 155 | self.simplification_options 156 | } 157 | 158 | fn is_tab_closable(&self, _tiles: &Tiles, _tile_id: TileId) -> bool { 159 | true 160 | } 161 | 162 | fn on_tab_close(&mut self, tiles: &mut Tiles, tile_id: TileId) -> bool { 163 | if let Some(tile) = tiles.get(tile_id) { 164 | match tile { 165 | Tile::Pane(pane) => { 166 | // Single pane removal 167 | let tab_title = self.tab_title_for_pane(pane); 168 | log::debug!("Closing tab: {}, tile ID: {tile_id:?}", tab_title.text()); 169 | } 170 | Tile::Container(container) => { 171 | // Container removal 172 | log::debug!("Closing container: {:?}", container.kind()); 173 | let children_ids = container.children(); 174 | for child_id in children_ids { 175 | if let Some(Tile::Pane(pane)) = tiles.get(*child_id) { 176 | let tab_title = self.tab_title_for_pane(pane); 177 | log::debug!("Closing tab: {}, tile ID: {tile_id:?}", tab_title.text()); 178 | } 179 | } 180 | } 181 | } 182 | } 183 | 184 | // Proceed to removing the tab 185 | true 186 | } 187 | } 188 | 189 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] 190 | struct MyApp { 191 | tree: egui_tiles::Tree, 192 | 193 | #[cfg_attr(feature = "serde", serde(skip))] 194 | behavior: TreeBehavior, 195 | } 196 | 197 | impl Default for MyApp { 198 | fn default() -> Self { 199 | let mut next_view_nr = 0; 200 | let mut gen_view = || { 201 | let view = Pane::with_nr(next_view_nr); 202 | next_view_nr += 1; 203 | view 204 | }; 205 | 206 | let mut tiles = egui_tiles::Tiles::default(); 207 | 208 | let mut tabs = vec![]; 209 | let tab_tile = { 210 | let children = (0..7).map(|_| tiles.insert_pane(gen_view())).collect(); 211 | tiles.insert_tab_tile(children) 212 | }; 213 | tabs.push(tab_tile); 214 | tabs.push({ 215 | let children = (0..7).map(|_| tiles.insert_pane(gen_view())).collect(); 216 | tiles.insert_horizontal_tile(children) 217 | }); 218 | tabs.push({ 219 | let children = (0..7).map(|_| tiles.insert_pane(gen_view())).collect(); 220 | tiles.insert_vertical_tile(children) 221 | }); 222 | tabs.push({ 223 | let cells = (0..11).map(|_| tiles.insert_pane(gen_view())).collect(); 224 | tiles.insert_grid_tile(cells) 225 | }); 226 | tabs.push(tiles.insert_pane(gen_view())); 227 | 228 | let root = tiles.insert_tab_tile(tabs); 229 | 230 | let tree = egui_tiles::Tree::new("my_tree", root, tiles); 231 | 232 | Self { 233 | tree, 234 | behavior: Default::default(), 235 | } 236 | } 237 | } 238 | 239 | impl eframe::App for MyApp { 240 | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 241 | egui::SidePanel::left("tree").show(ctx, |ui| { 242 | if ui.button("Reset").clicked() { 243 | *self = Default::default(); 244 | } 245 | self.behavior.ui(ui); 246 | 247 | ui.separator(); 248 | 249 | ui.collapsing("Tree", |ui| { 250 | ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); 251 | let tree_debug = format!("{:#?}", self.tree); 252 | ui.monospace(&tree_debug); 253 | }); 254 | 255 | ui.separator(); 256 | 257 | ui.collapsing("Active tiles", |ui| { 258 | let active = self.tree.active_tiles(); 259 | for tile_id in active { 260 | use egui_tiles::Behavior as _; 261 | let name = self.behavior.tab_title_for_tile(&self.tree.tiles, tile_id); 262 | ui.label(format!("{} - {tile_id:?}", name.text())); 263 | } 264 | }); 265 | 266 | ui.separator(); 267 | 268 | if let Some(root) = self.tree.root() { 269 | tree_ui(ui, &mut self.behavior, &mut self.tree.tiles, root); 270 | } 271 | 272 | if let Some(parent) = self.behavior.add_child_to.take() { 273 | let new_child = self.tree.tiles.insert_pane(Pane::with_nr(100)); 274 | if let Some(egui_tiles::Tile::Container(egui_tiles::Container::Tabs(tabs))) = 275 | self.tree.tiles.get_mut(parent) 276 | { 277 | tabs.add_child(new_child); 278 | tabs.set_active(new_child); 279 | } 280 | } 281 | }); 282 | 283 | egui::CentralPanel::default().show(ctx, |ui| { 284 | self.tree.ui(&mut self.behavior, ui); 285 | }); 286 | } 287 | 288 | fn save(&mut self, _storage: &mut dyn eframe::Storage) { 289 | #[cfg(feature = "serde")] 290 | eframe::set_value(_storage, eframe::APP_KEY, &self); 291 | } 292 | } 293 | 294 | fn tree_ui( 295 | ui: &mut egui::Ui, 296 | behavior: &mut dyn egui_tiles::Behavior, 297 | tiles: &mut egui_tiles::Tiles, 298 | tile_id: egui_tiles::TileId, 299 | ) { 300 | // Get the name BEFORE we remove the tile below! 301 | let text = format!( 302 | "{} - {tile_id:?}", 303 | behavior.tab_title_for_tile(tiles, tile_id).text() 304 | ); 305 | 306 | // Temporarily remove the tile to circumvent the borrowchecker 307 | let Some(mut tile) = tiles.remove(tile_id) else { 308 | log::debug!("Missing tile {tile_id:?}"); 309 | return; 310 | }; 311 | 312 | let default_open = true; 313 | egui::collapsing_header::CollapsingState::load_with_default_open( 314 | ui.ctx(), 315 | ui.id().with((tile_id, "tree")), 316 | default_open, 317 | ) 318 | .show_header(ui, |ui| { 319 | ui.label(text); 320 | let mut visible = tiles.is_visible(tile_id); 321 | ui.checkbox(&mut visible, "Visible"); 322 | tiles.set_visible(tile_id, visible); 323 | }) 324 | .body(|ui| match &mut tile { 325 | egui_tiles::Tile::Pane(_) => {} 326 | egui_tiles::Tile::Container(container) => { 327 | let mut kind = container.kind(); 328 | egui::ComboBox::from_label("Kind") 329 | .selected_text(format!("{kind:?}")) 330 | .show_ui(ui, |ui| { 331 | for alternative in egui_tiles::ContainerKind::ALL { 332 | ui.selectable_value(&mut kind, alternative, format!("{alternative:?}")) 333 | .clicked(); 334 | } 335 | }); 336 | if kind != container.kind() { 337 | container.set_kind(kind); 338 | } 339 | 340 | for &child in container.children() { 341 | tree_ui(ui, behavior, tiles, child); 342 | } 343 | } 344 | }); 345 | 346 | // Put the tile back 347 | tiles.insert(tile_id, tile); 348 | } 349 | -------------------------------------------------------------------------------- /examples/simple.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release 2 | 3 | struct Pane { 4 | nr: usize, 5 | } 6 | 7 | struct TreeBehavior {} 8 | 9 | impl egui_tiles::Behavior for TreeBehavior { 10 | fn tab_title_for_pane(&mut self, pane: &Pane) -> egui::WidgetText { 11 | format!("Pane {}", pane.nr).into() 12 | } 13 | 14 | fn pane_ui( 15 | &mut self, 16 | ui: &mut egui::Ui, 17 | _tile_id: egui_tiles::TileId, 18 | pane: &mut Pane, 19 | ) -> egui_tiles::UiResponse { 20 | // Give each pane a unique color: 21 | let color = egui::epaint::Hsva::new(0.103 * pane.nr as f32, 0.5, 0.5, 1.0); 22 | ui.painter().rect_filled(ui.max_rect(), 0.0, color); 23 | 24 | ui.label(format!("The contents of pane {}.", pane.nr)); 25 | 26 | // You can make your pane draggable like so: 27 | if ui 28 | .add(egui::Button::new("Drag me!").sense(egui::Sense::drag())) 29 | .drag_started() 30 | { 31 | egui_tiles::UiResponse::DragStarted 32 | } else { 33 | egui_tiles::UiResponse::None 34 | } 35 | } 36 | } 37 | 38 | fn main() -> Result<(), eframe::Error> { 39 | env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). 40 | 41 | let options = eframe::NativeOptions { 42 | viewport: egui::ViewportBuilder::default().with_inner_size([320.0, 240.0]), 43 | ..Default::default() 44 | }; 45 | 46 | let mut tree = create_tree(); 47 | 48 | eframe::run_simple_native("My egui App", options, move |ctx, _frame| { 49 | egui::CentralPanel::default().show(ctx, |ui| { 50 | let mut behavior = TreeBehavior {}; 51 | tree.ui(&mut behavior, ui); 52 | }); 53 | }) 54 | } 55 | 56 | fn create_tree() -> egui_tiles::Tree { 57 | let mut next_view_nr = 0; 58 | let mut gen_pane = || { 59 | let pane = Pane { nr: next_view_nr }; 60 | next_view_nr += 1; 61 | pane 62 | }; 63 | 64 | let mut tiles = egui_tiles::Tiles::default(); 65 | 66 | let mut tabs = vec![]; 67 | tabs.push({ 68 | let children = (0..7).map(|_| tiles.insert_pane(gen_pane())).collect(); 69 | tiles.insert_horizontal_tile(children) 70 | }); 71 | tabs.push({ 72 | let cells = (0..11).map(|_| tiles.insert_pane(gen_pane())).collect(); 73 | tiles.insert_grid_tile(cells) 74 | }); 75 | tabs.push(tiles.insert_pane(gen_pane())); 76 | 77 | let root = tiles.insert_tab_tile(tabs); 78 | 79 | egui_tiles::Tree::new("my_tree", root, tiles) 80 | } 81 | -------------------------------------------------------------------------------- /lychee.toml: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/rerun-io/rerun_template 2 | 3 | ################################################################################ 4 | # Config for the link checker lychee. 5 | # 6 | # Download & learn more at: 7 | # https://github.com/lycheeverse/lychee 8 | # 9 | # Example config: 10 | # https://github.com/lycheeverse/lychee/blob/master/lychee.example.toml 11 | # 12 | # Run `lychee . --dump` to list all found links that are being checked. 13 | # 14 | # Note that by default lychee will only check markdown and html files, 15 | # to check any other files you have to point to them explicitly, e.g.: 16 | # `lychee **/*.rs` 17 | # To make things worse, `exclude_path` is ignored for these globs, 18 | # so local runs with lots of gitignored files will be slow. 19 | # (https://github.com/lycheeverse/lychee/issues/1405) 20 | # 21 | # This unfortunately doesn't list anything for non-glob checks. 22 | ################################################################################ 23 | 24 | # Maximum number of concurrent link checks. 25 | # Workaround for "too many open files" error on MacOS, see https://github.com/lycheeverse/lychee/issues/1248 26 | max_concurrency = 32 27 | 28 | # Check links inside `` and `
` blocks as well as Markdown code blocks.
29 | include_verbatim = true
30 | 
31 | # Proceed for server connections considered insecure (invalid TLS).
32 | insecure = true
33 | 
34 | # Exclude these filesystem paths from getting checked.
35 | exclude_path = [
36 |   # Unfortunately lychee doesn't yet read .gitignore https://github.com/lycheeverse/lychee/issues/1331
37 |   # The following entries are there because of that:
38 |   ".git",
39 |   "__pycache__",
40 |   "_deps/",
41 |   ".pixi",
42 |   "build",
43 |   "target_ra",
44 |   "target_wasm",
45 |   "target",
46 |   "venv",
47 | ]
48 | 
49 | # Exclude URLs and mail addresses from checking (supports regex).
50 | exclude = [
51 |   # Skip speculative links
52 |   '.*?speculative-link',
53 | 
54 |   # Strings with replacements.
55 |   '/__VIEWER_VERSION__/', # Replacement variable __VIEWER_VERSION__.
56 |   '/\$',                  # Replacement variable $.
57 |   '/GIT_HASH/',           # Replacement variable GIT_HASH.
58 |   '\{\}',                 # Ignore links with string interpolation.
59 |   '\$relpath\^',          # Relative paths as used by rerun_cpp's doc header.
60 |   '%7B.+%7D',             # Ignore strings that look like ready to use links but contain a replacement strings. The URL escaping is for '{.+}' (this seems to be needed for html embedded urls since lychee assumes they use this encoding).
61 |   '%7B%7D',               # Ignore links with string interpolation, escaped variant.
62 | 
63 |   # Local links that require further setup.
64 |   'http://127.0.0.1',
65 |   'http://localhost',
66 |   'recording:/',      # rrd recording link.
67 |   'ws:/',
68 |   're_viewer.js',     # Build artifact that html is linking to.
69 | 
70 |   # Api endpoints.
71 |   'https://fonts.googleapis.com/', # Font API entrypoint, not a link.
72 |   'https://fonts.gstatic.com/',    # Font API entrypoint, not a link.
73 |   'https://tel.rerun.io/',         # Analytics endpoint.
74 | 
75 |   # Avoid rate limiting.
76 |   'https://crates.io/crates/.*',                  # Avoid crates.io rate-limiting
77 |   'https://github.com/rerun-io/rerun/commit/\.*', # Ignore links to our own commits (typically in changelog).
78 |   'https://github.com/rerun-io/rerun/pull/\.*',   # Ignore links to our own pull requests (typically in changelog).
79 | ]
80 | 


--------------------------------------------------------------------------------
/rust-toolchain:
--------------------------------------------------------------------------------
 1 | # If you see this, run "rustup self update" to get rustup 1.23 or newer.
 2 | 
 3 | # NOTE: above comment is for older `rustup` (before TOML support was added),
 4 | # which will treat the first line as the toolchain name, and therefore show it
 5 | # to the user in the error, instead of "error: invalid channel name '[toolchain]'".
 6 | 
 7 | [toolchain]
 8 | channel = "1.81"  # Avoid specifying a patch version here; see https://github.com/emilk/eframe_template/issues/145
 9 | components = ["rustfmt", "clippy"]
10 | targets = ["wasm32-unknown-unknown"]
11 | 


--------------------------------------------------------------------------------
/scripts/clippy_wasm/clippy.toml:
--------------------------------------------------------------------------------
 1 | # Copied from https://github.com/rerun-io/rerun_template
 2 | 
 3 | # This is used by the CI so we can forbid some methods that are not available in wasm.
 4 | #
 5 | # We cannot forbid all these methods in the main `clippy.toml` because of
 6 | # https://github.com/rust-lang/rust-clippy/issues/10406
 7 | 
 8 | # -----------------------------------------------------------------------------
 9 | # Section identical to the main clippy.toml:
10 | 
11 | msrv = "1.81"
12 | 
13 | allow-unwrap-in-tests = true
14 | 
15 | # https://doc.rust-lang.org/nightly/clippy/lint_configuration.html#avoid-breaking-exported-api
16 | # We want suggestions, even if it changes public API.
17 | avoid-breaking-exported-api = false
18 | 
19 | excessive-nesting-threshold = 10 # TODO(emilk): Lower this
20 | 
21 | max-fn-params-bools = 1
22 | 
23 | # https://rust-lang.github.io/rust-clippy/master/index.html#/large_include_file
24 | max-include-file-size = 1000000
25 | 
26 | too-many-lines-threshold = 200
27 | 
28 | # -----------------------------------------------------------------------------
29 | 
30 | # https://rust-lang.github.io/rust-clippy/master/index.html#disallowed_methods
31 | disallowed-methods = [
32 |   { path = "crossbeam::channel::Receiver::into_iter", reason = "Cannot block on Web" },
33 |   { path = "crossbeam::channel::Receiver::iter", reason = "Cannot block on Web" },
34 |   { path = "crossbeam::channel::Receiver::recv_timeout", reason = "Cannot block on Web" },
35 |   { path = "crossbeam::channel::Receiver::recv", reason = "Cannot block on Web" },
36 |   { path = "poll_promise::Promise::block_and_take", reason = "Cannot block on Web" },
37 |   { path = "poll_promise::Promise::block_until_ready_mut", reason = "Cannot block on Web" },
38 |   { path = "poll_promise::Promise::block_until_ready", reason = "Cannot block on Web" },
39 |   { path = "rayon::spawn", reason = "Cannot spawn threads on wasm" },
40 |   { path = "std::sync::mpsc::Receiver::into_iter", reason = "Cannot block on Web" },
41 |   { path = "std::sync::mpsc::Receiver::iter", reason = "Cannot block on Web" },
42 |   { path = "std::sync::mpsc::Receiver::recv_timeout", reason = "Cannot block on Web" },
43 |   { path = "std::sync::mpsc::Receiver::recv", reason = "Cannot block on Web" },
44 |   { path = "std::thread::spawn", reason = "Cannot spawn threads on wasm" },
45 |   { path = "std::time::Duration::elapsed", reason = "use `web-time` crate instead for wasm/web compatibility" },
46 |   { path = "std::time::Instant::now", reason = "use `web-time` crate instead for wasm/web compatibility" },
47 |   { path = "std::time::SystemTime::now", reason = "use `web-time` or `time` crates instead for wasm/web compatibility" },
48 | ]
49 | 
50 | # https://rust-lang.github.io/rust-clippy/master/index.html#disallowed_types
51 | disallowed-types = [
52 |   { path = "instant::SystemTime", reason = "Known bugs. Use web-time." },
53 |   { path = "std::thread::Builder", reason = "Cannot spawn threads on wasm" },
54 |   # { path = "std::path::PathBuf", reason = "Can't read/write files on web" }, // Used in build.rs files (which is fine).
55 | ]
56 | 
57 | # Allow-list of words for markdown in docstrings https://rust-lang.github.io/rust-clippy/master/index.html#doc_markdown
58 | doc-valid-idents = [
59 |   # You must also update the same list in the root `clippy.toml`!
60 |   "..",
61 |   "GitHub",
62 |   "GLB",
63 |   "GLTF",
64 |   "iOS",
65 |   "macOS",
66 |   "NaN",
67 |   "OBJ",
68 |   "OpenGL",
69 |   "PyPI",
70 |   "sRGB",
71 |   "sRGBA",
72 |   "WebGL",
73 |   "WebSocket",
74 |   "WebSockets",
75 | ]
76 | 


--------------------------------------------------------------------------------
/scripts/generate_changelog.py:
--------------------------------------------------------------------------------
  1 | #!/usr/bin/env python3
  2 | # Copied from https://github.com/rerun-io/rerun_template
  3 | 
  4 | """
  5 | Summarizes recent PRs based on their GitHub labels.
  6 | 
  7 | The result can be copy-pasted into CHANGELOG.md,
  8 | though it often needs some manual editing too.
  9 | """
 10 | 
 11 | from __future__ import annotations
 12 | 
 13 | import argparse
 14 | import multiprocessing
 15 | import os
 16 | import re
 17 | import sys
 18 | from dataclasses import dataclass
 19 | from datetime import date
 20 | from typing import Any, Optional
 21 | 
 22 | import requests
 23 | from git import Repo  # pip install GitPython
 24 | from tqdm import tqdm
 25 | 
 26 | OWNER = "rerun-io"
 27 | REPO = "egui_tiles"
 28 | INCLUDE_LABELS = False  # It adds quite a bit of visual noise
 29 | 
 30 | 
 31 | @dataclass
 32 | class PrInfo:
 33 |     gh_user_name: str
 34 |     pr_title: str
 35 |     labels: list[str]
 36 | 
 37 | 
 38 | @dataclass
 39 | class CommitInfo:
 40 |     hexsha: str
 41 |     title: str
 42 |     pr_number: Optional[int]
 43 | 
 44 | 
 45 | def get_github_token() -> str:
 46 |     token = os.environ.get("GH_ACCESS_TOKEN", "")
 47 |     if token != "":
 48 |         return token
 49 | 
 50 |     home_dir = os.path.expanduser("~")
 51 |     token_file = os.path.join(home_dir, ".githubtoken")
 52 | 
 53 |     try:
 54 |         with open(token_file, encoding="utf8") as f:
 55 |             token = f.read().strip()
 56 |         return token
 57 |     except Exception:
 58 |         pass
 59 | 
 60 |     print("ERROR: expected a GitHub token in the environment variable GH_ACCESS_TOKEN or in ~/.githubtoken")
 61 |     sys.exit(1)
 62 | 
 63 | 
 64 | # Slow
 65 | def fetch_pr_info_from_commit_info(commit_info: CommitInfo) -> Optional[PrInfo]:
 66 |     if commit_info.pr_number is None:
 67 |         return None
 68 |     else:
 69 |         return fetch_pr_info(commit_info.pr_number)
 70 | 
 71 | 
 72 | # Slow
 73 | def fetch_pr_info(pr_number: int) -> Optional[PrInfo]:
 74 |     url = f"https://api.github.com/repos/{OWNER}/{REPO}/pulls/{pr_number}"
 75 |     gh_access_token = get_github_token()
 76 |     headers = {"Authorization": f"Token {gh_access_token}"}
 77 |     response = requests.get(url, headers=headers)
 78 |     json = response.json()
 79 | 
 80 |     # Check if the request was successful (status code 200)
 81 |     if response.status_code == 200:
 82 |         labels = [label["name"] for label in json["labels"]]
 83 |         gh_user_name = json["user"]["login"]
 84 |         return PrInfo(gh_user_name=gh_user_name, pr_title=json["title"], labels=labels)
 85 |     else:
 86 |         print(f"ERROR {url}: {response.status_code} - {json['message']}")
 87 |         return None
 88 | 
 89 | 
 90 | def get_commit_info(commit: Any) -> CommitInfo:
 91 |     # Squash-merge commits:
 92 |     if match := re.match(r"(.*) \(#(\d+)\)", commit.summary):
 93 |         title = str(match.group(1))
 94 |         pr_number = int(match.group(2))
 95 |         return CommitInfo(hexsha=commit.hexsha, title=title, pr_number=pr_number)
 96 | 
 97 |     # Normal merge commits:
 98 |     elif match := re.match(r"Merge pull request #(\d+) from (.*)", commit.summary):
 99 |         title = str(match.group(2))
100 |         pr_number = int(match.group(1))
101 |         return CommitInfo(hexsha=commit.hexsha, title=title, pr_number=pr_number)
102 | 
103 |     else:
104 |         return CommitInfo(hexsha=commit.hexsha, title=commit.summary, pr_number=None)
105 | 
106 | 
107 | def remove_prefix(text: str, prefix: str) -> str:
108 |     if text.startswith(prefix):
109 |         return text[len(prefix) :]
110 |     return text  # or whatever
111 | 
112 | 
113 | def print_section(crate: str, items: list[str]) -> None:
114 |     if 0 < len(items):
115 |         print(f"#### {crate}")
116 |         for line in items:
117 |             print(f"* {line}")
118 |     print()
119 | 
120 | 
121 | def calc_commit_range(new_version: str) -> str:
122 |     parts = new_version.split(".")
123 |     assert len(parts) == 3, "Expected version to be on the format X.Y.Z"
124 |     major = int(parts[0])
125 |     minor = int(parts[1])
126 |     patch = int(parts[2])
127 | 
128 |     if 0 < patch:
129 |         # A patch release.
130 |         # Include changes since last patch release.
131 |         # This assumes we've cherry-picked stuff for this release.
132 |         diff_since_version = f"0.{minor}.{patch - 1}"
133 |     elif 0 < minor:
134 |         # A minor release
135 |         # The diff should span everything since the last minor release.
136 |         # The script later excludes duplicated automatically, so we don't include stuff that
137 |         # was part of intervening patch releases.
138 |         diff_since_version = f"{major}.{minor - 1}.0"
139 |     else:
140 |         # A major release
141 |         # The diff should span everything since the last major release.
142 |         # The script later excludes duplicated automatically, so we don't include stuff that
143 |         # was part of intervening minor/patch releases.
144 |         diff_since_version = f"{major - 1}.{minor}.0"
145 | 
146 |     return f"{diff_since_version}..HEAD"
147 | 
148 | 
149 | def main() -> None:
150 |     parser = argparse.ArgumentParser(description="Generate a changelog.")
151 |     parser.add_argument("--version", required=True, help="The version of the new release, e.g. 0.42.0")
152 |     args = parser.parse_args()
153 | 
154 |     commit_range = calc_commit_range(args.version)
155 | 
156 |     repo = Repo(".")
157 |     commits = list(repo.iter_commits(commit_range))
158 |     commits.reverse()  # Most recent last
159 |     commit_infos = list(map(get_commit_info, commits))
160 | 
161 |     pool = multiprocessing.Pool()
162 |     pr_infos = list(
163 |         tqdm(
164 |             pool.imap(fetch_pr_info_from_commit_info, commit_infos),
165 |             total=len(commit_infos),
166 |             desc="Fetch PR info commits",
167 |         )
168 |     )
169 | 
170 |     prs = []
171 |     unsorted_commits = []
172 | 
173 |     for commit_info, pr_info in zip(commit_infos, pr_infos):
174 |         hexsha = commit_info.hexsha
175 |         title = commit_info.title
176 |         title = title.rstrip(".").strip()  # Some PR end with an unnecessary period
177 |         pr_number = commit_info.pr_number
178 | 
179 |         if pr_number is None:
180 |             # Someone committed straight to main:
181 |             summary = f"{title} [{hexsha[:7]}](https://github.com/{OWNER}/{REPO}/commit/{hexsha})"
182 |             unsorted_commits.append(summary)
183 |         else:
184 |             # We prefer the PR title if available
185 |             title = pr_info.pr_title if pr_info else title
186 |             labels = pr_info.labels if pr_info else []
187 | 
188 |             if "exclude from changelog" in labels:
189 |                 continue
190 |             if "typo" in labels:
191 |                 # We get so many typo PRs. Let's not flood the changelog with them.
192 |                 continue
193 | 
194 |             summary = f"{title} [#{pr_number}](https://github.com/{OWNER}/{REPO}/pull/{pr_number})"
195 | 
196 |             if INCLUDE_LABELS and 0 < len(labels):
197 |                 summary += f" ({', '.join(labels)})"
198 | 
199 |             if pr_info is not None:
200 |                 gh_user_name = pr_info.gh_user_name
201 |                 summary += f" by [@{gh_user_name}](https://github.com/{gh_user_name})"
202 | 
203 |             prs.append(summary)
204 | 
205 |     # Clean up:
206 |     for i in range(len(prs)):
207 |         line = prs[i]
208 |         line = line[0].upper() + line[1:]  # Upper-case first letter
209 |         prs[i] = line
210 | 
211 |     print(f"## {args.version} - {date.today()}")
212 |     print()
213 |     print(f"Full diff at https://github.com/{OWNER}/{REPO}/compare/{commit_range}")
214 |     print()
215 |     print_section("PRs", prs)
216 |     print_section("Unsorted commits", unsorted_commits)
217 | 
218 | 
219 | if __name__ == "__main__":
220 |     main()
221 | 


--------------------------------------------------------------------------------
/scripts/template_update.py:
--------------------------------------------------------------------------------
  1 | #!/usr/bin/env python3
  2 | # Copied from https://github.com/rerun-io/rerun_template
  3 | 
  4 | """
  5 | The script has two purposes.
  6 | 
  7 | After using `rerun_template` as a template, run this to clean out things you don't need.
  8 | Use `scripts/template_update.py init --languages cpp,rust,python` for this.
  9 | 
 10 | Update an existing repository with the latest changes from the template.
 11 | Use `scripts/template_update.py update --languages cpp,rust,python` for this.
 12 | 
 13 | In either case, make sure the list of languages matches the languages you want to support.
 14 | You can also use `--dry-run` to see what would happen without actually changing anything.
 15 | """
 16 | 
 17 | from __future__ import annotations
 18 | 
 19 | import argparse
 20 | import os
 21 | import shutil
 22 | import tempfile
 23 | 
 24 | from git import Repo  # pip install GitPython
 25 | 
 26 | OWNER = "rerun-io"
 27 | 
 28 | # Don't overwrite these when updating existing repository from the template
 29 | DO_NOT_OVERWRITE = {
 30 |     "Cargo.lock",
 31 |     "CHANGELOG.md",
 32 |     "main.py",
 33 |     "pixi.lock",
 34 |     "README.md",
 35 |     "requirements.txt",
 36 | }
 37 | 
 38 | # Files required by C++, but not by _both_ Python and Rust
 39 | CPP_FILES = {
 40 |     ".clang-format",
 41 |     ".github/workflows/cpp.yml",
 42 |     "CMakeLists.txt",
 43 |     "pixi.lock",  # Pixi is only C++ & Python - For Rust we only use cargo
 44 |     "pixi.toml",  # Pixi is only C++ & Python - For Rust we only use cargo
 45 |     "src/",
 46 |     "src/main.cpp",
 47 | }
 48 | 
 49 | # Files required by Python, but not by _both_ C++ and Rust
 50 | PYTHON_FILES = {
 51 |     ".github/workflows/python.yml",
 52 |     ".mypy.ini",
 53 |     "main.py",
 54 |     "pixi.lock",  # Pixi is only C++ & Python - For Rust we only use cargo
 55 |     "pixi.toml",  # Pixi is only C++ & Python - For Rust we only use cargo
 56 |     "pyproject.toml",
 57 |     "requirements.txt",
 58 | }
 59 | 
 60 | # Files required by Rust, but not by _both_ C++ and Python
 61 | RUST_FILES = {
 62 |     ".github/workflows/cargo_shear.yml",
 63 |     ".github/workflows/rust.yml",
 64 |     "bacon.toml",
 65 |     "Cargo.lock",
 66 |     "Cargo.toml",
 67 |     "CHANGELOG.md",  # We only keep a changelog for Rust crates at the moment
 68 |     "clippy.toml",
 69 |     "Cranky.toml",
 70 |     "deny.toml",
 71 |     "RELEASES.md",
 72 |     "rust-toolchain",
 73 |     "scripts/clippy_wasm/",
 74 |     "scripts/clippy_wasm/clippy.toml",
 75 |     "scripts/generate_changelog.py",  # We only keep a changelog for Rust crates at the moment
 76 |     "src/",
 77 |     "src/lib.rs",
 78 |     "src/main.rs",
 79 | }
 80 | 
 81 | # Files we used to have, but have been removed in never version of rerun_template
 82 | DEAD_FILES = ["bacon.toml", "Cranky.toml"]
 83 | 
 84 | 
 85 | def parse_languages(lang_str: str) -> set[str]:
 86 |     languages = lang_str.split(",") if lang_str else []
 87 |     for lang in languages:
 88 |         assert lang in ["cpp", "python", "rust"], f"Unsupported language: {lang}"
 89 |     return set(languages)
 90 | 
 91 | 
 92 | def calc_deny_set(languages: set[str]) -> set[str]:
 93 |     """The set of files to delete/ignore."""
 94 |     files_to_delete = CPP_FILES | PYTHON_FILES | RUST_FILES
 95 |     if "cpp" in languages:
 96 |         files_to_delete -= CPP_FILES
 97 |     if "python" in languages:
 98 |         files_to_delete -= PYTHON_FILES
 99 |     if "rust" in languages:
100 |         files_to_delete -= RUST_FILES
101 |     return files_to_delete
102 | 
103 | 
104 | def init(languages: set[str], dry_run: bool) -> None:
105 |     print("Removing all language-specific files not needed for languages {languages}.")
106 |     files_to_delete = calc_deny_set(languages)
107 |     delete_files_and_folder(files_to_delete, dry_run)
108 | 
109 | 
110 | def remove_file(filepath: str) -> None:
111 |     try:
112 |         os.remove(filepath)
113 |     except FileNotFoundError:
114 |         pass
115 | 
116 | 
117 | def delete_files_and_folder(paths: set[str], dry_run: bool) -> None:
118 |     repo_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
119 |     for path in paths:
120 |         full_path = os.path.join(repo_path, path)
121 |         if os.path.exists(full_path):
122 |             if os.path.isfile(full_path):
123 |                 print(f"Removing file {full_path}…")
124 |                 if not dry_run:
125 |                     remove_file(full_path)
126 |             elif os.path.isdir(full_path):
127 |                 print(f"Removing folder {full_path}…")
128 |                 if not dry_run:
129 |                     shutil.rmtree(full_path)
130 | 
131 | 
132 | def update(languages: set[str], dry_run: bool) -> None:
133 |     for file in DEAD_FILES:
134 |         print(f"Removing dead file {file}…")
135 |         if not dry_run:
136 |             remove_file(file)
137 | 
138 |     files_to_ignore = calc_deny_set(languages) | DO_NOT_OVERWRITE
139 |     repo_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
140 | 
141 |     with tempfile.TemporaryDirectory() as temp_dir:
142 |         Repo.clone_from("https://github.com/rerun-io/rerun_template.git", temp_dir)
143 |         for root, dirs, files in os.walk(temp_dir):
144 |             for file in files:
145 |                 src_path = os.path.join(root, file)
146 |                 rel_path = os.path.relpath(src_path, temp_dir)
147 | 
148 |                 if rel_path.startswith(".git/"):
149 |                     continue
150 |                 if rel_path.startswith("src/"):
151 |                     continue
152 |                 if rel_path in files_to_ignore:
153 |                     continue
154 | 
155 |                 dest_path = os.path.join(repo_path, rel_path)
156 | 
157 |                 print(f"Updating {rel_path}…")
158 |                 if not dry_run:
159 |                     os.makedirs(os.path.dirname(dest_path), exist_ok=True)
160 |                     shutil.copy2(src_path, dest_path)
161 | 
162 | 
163 | def main() -> None:
164 |     parser = argparse.ArgumentParser(description="Handle the Rerun template.")
165 |     subparsers = parser.add_subparsers(dest="command")
166 | 
167 |     init_parser = subparsers.add_parser("init", help="Initialize a new checkout of the template.")
168 |     init_parser.add_argument(
169 |         "--languages", default="", nargs="?", const="", help="The languages to support (e.g. `cpp,python,rust`)."
170 |     )
171 |     init_parser.add_argument("--dry-run", action="store_true", help="Don't actually delete any files.")
172 | 
173 |     update_parser = subparsers.add_parser(
174 |         "update", help="Update all existing Rerun repositories with the latest changes from the template"
175 |     )
176 |     update_parser.add_argument(
177 |         "--languages", default="", nargs="?", const="", help="The languages to support (e.g. `cpp,python,rust`)."
178 |     )
179 |     update_parser.add_argument("--dry-run", action="store_true", help="Don't actually delete any files.")
180 | 
181 |     args = parser.parse_args()
182 | 
183 |     if args.command == "init":
184 |         init(parse_languages(args.languages), args.dry_run)
185 |     elif args.command == "update":
186 |         update(parse_languages(args.languages), args.dry_run)
187 |     else:
188 |         parser.print_help()
189 |         exit(1)
190 | 
191 | 
192 | if __name__ == "__main__":
193 |     main()
194 | 


--------------------------------------------------------------------------------
/src/behavior.rs:
--------------------------------------------------------------------------------
  1 | use egui::{
  2 |     vec2, Color32, Id, Rect, Response, Rgba, Sense, Stroke, TextStyle, Ui, Vec2, Visuals,
  3 |     WidgetText,
  4 | };
  5 | 
  6 | use super::{ResizeState, SimplificationOptions, Tile, TileId, Tiles, UiResponse};
  7 | 
  8 | /// The kind of edit that triggered the call to [`Behavior::on_edit`].
  9 | #[derive(Clone, Debug, PartialEq, Eq)]
 10 | pub enum EditAction {
 11 |     /// A tile was resized by dragging or double-clicking a boundary.
 12 |     TileResized,
 13 | 
 14 |     /// A drag with a tile started.
 15 |     TileDragged,
 16 | 
 17 |     /// A tile was dropped and its position changed accordingly.
 18 |     TileDropped,
 19 | 
 20 |     /// A tab was selected by a click, or by hovering a dragged tile over it,
 21 |     /// or there was no active tab and egui picked an arbitrary one.
 22 |     TabSelected,
 23 | }
 24 | 
 25 | /// The state of a tab, used to inform the rendering of the tab.
 26 | #[derive(Clone, Debug, Default)]
 27 | pub struct TabState {
 28 |     /// Is the tab currently selected?
 29 |     pub active: bool,
 30 | 
 31 |     /// Is the tab currently being dragged?
 32 |     pub is_being_dragged: bool,
 33 | 
 34 |     /// Should the tab have a close button?
 35 |     pub closable: bool,
 36 | }
 37 | 
 38 | /// Trait defining how the [`super::Tree`] and its panes should be shown.
 39 | pub trait Behavior {
 40 |     /// Show a pane tile in the given [`egui::Ui`].
 41 |     ///
 42 |     /// You can make the pane draggable by returning [`UiResponse::DragStarted`]
 43 |     /// when the user drags some handle.
 44 |     fn pane_ui(&mut self, ui: &mut Ui, tile_id: TileId, pane: &mut Pane) -> UiResponse;
 45 | 
 46 |     /// The title of a pane tab.
 47 |     fn tab_title_for_pane(&mut self, pane: &Pane) -> WidgetText;
 48 | 
 49 |     /// Should the tab have a close-button?
 50 |     fn is_tab_closable(&self, _tiles: &Tiles, _tile_id: TileId) -> bool {
 51 |         false
 52 |     }
 53 | 
 54 |     /// Called when the close-button on a tab is pressed.
 55 |     ///
 56 |     /// Return `false` to abort the closing of a tab (e.g. after showing a message box).
 57 |     fn on_tab_close(&mut self, _tiles: &mut Tiles, _tile_id: TileId) -> bool {
 58 |         true
 59 |     }
 60 | 
 61 |     /// The size of the close button in the tab.
 62 |     fn close_button_outer_size(&self) -> f32 {
 63 |         12.0
 64 |     }
 65 | 
 66 |     /// How much smaller the visual part of the close-button will be
 67 |     /// compared to [`Self::close_button_outer_size`].
 68 |     fn close_button_inner_margin(&self) -> f32 {
 69 |         2.0
 70 |     }
 71 | 
 72 |     /// The title of a general tab.
 73 |     ///
 74 |     /// The default implementation calls [`Self::tab_title_for_pane`] for panes and
 75 |     /// uses the name of the [`crate::ContainerKind`] for [`crate::Container`]s.
 76 |     fn tab_title_for_tile(&mut self, tiles: &Tiles, tile_id: TileId) -> WidgetText {
 77 |         if let Some(tile) = tiles.get(tile_id) {
 78 |             match tile {
 79 |                 Tile::Pane(pane) => self.tab_title_for_pane(pane),
 80 |                 Tile::Container(container) => format!("{:?}", container.kind()).into(),
 81 |             }
 82 |         } else {
 83 |             "MISSING TILE".into()
 84 |         }
 85 |     }
 86 | 
 87 |     /// Show the ui for the a tab of some tile.
 88 |     ///
 89 |     /// The default implementation shows a clickable button with the title for that tile,
 90 |     /// gotten with [`Self::tab_title_for_tile`].
 91 |     /// The default implementation also calls [`Self::on_tab_button`].
 92 |     ///
 93 |     /// You can override the default implementation to add e.g. a close button.
 94 |     /// Make sure it is sensitive to clicks and drags (if you want to enable drag-and-drop of tabs).
 95 |     #[allow(clippy::fn_params_excessive_bools)]
 96 |     fn tab_ui(
 97 |         &mut self,
 98 |         tiles: &mut Tiles,
 99 |         ui: &mut Ui,
100 |         id: Id,
101 |         tile_id: TileId,
102 |         state: &TabState,
103 |     ) -> Response {
104 |         let text = self.tab_title_for_tile(tiles, tile_id);
105 |         let close_btn_size = Vec2::splat(self.close_button_outer_size());
106 |         let close_btn_left_padding = 4.0;
107 |         let font_id = TextStyle::Button.resolve(ui.style());
108 |         let galley = text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, font_id);
109 | 
110 |         let x_margin = self.tab_title_spacing(ui.visuals());
111 | 
112 |         let button_width = galley.size().x
113 |             + 2.0 * x_margin
114 |             + f32::from(state.closable) * (close_btn_left_padding + close_btn_size.x);
115 |         let (_, tab_rect) = ui.allocate_space(vec2(button_width, ui.available_height()));
116 | 
117 |         let tab_response = ui
118 |             .interact(tab_rect, id, Sense::click_and_drag())
119 |             .on_hover_cursor(egui::CursorIcon::Grab);
120 | 
121 |         // Show a gap when dragged
122 |         if ui.is_rect_visible(tab_rect) && !state.is_being_dragged {
123 |             let bg_color = self.tab_bg_color(ui.visuals(), tiles, tile_id, state);
124 |             let stroke = self.tab_outline_stroke(ui.visuals(), tiles, tile_id, state);
125 |             ui.painter().rect(
126 |                 tab_rect.shrink(0.5),
127 |                 0.0,
128 |                 bg_color,
129 |                 stroke,
130 |                 egui::StrokeKind::Inside,
131 |             );
132 | 
133 |             if state.active {
134 |                 // Make the tab name area connect with the tab ui area:
135 |                 ui.painter().hline(
136 |                     tab_rect.x_range(),
137 |                     tab_rect.bottom(),
138 |                     Stroke::new(stroke.width + 1.0, bg_color),
139 |                 );
140 |             }
141 | 
142 |             // Prepare title's text for rendering
143 |             let text_color = self.tab_text_color(ui.visuals(), tiles, tile_id, state);
144 |             let text_position = egui::Align2::LEFT_CENTER
145 |                 .align_size_within_rect(galley.size(), tab_rect.shrink(x_margin))
146 |                 .min;
147 | 
148 |             // Render the title
149 |             ui.painter().galley(text_position, galley, text_color);
150 | 
151 |             // Conditionally render the close button
152 |             if state.closable {
153 |                 let close_btn_rect = egui::Align2::RIGHT_CENTER
154 |                     .align_size_within_rect(close_btn_size, tab_rect.shrink(x_margin));
155 | 
156 |                 // Allocate
157 |                 let close_btn_id = ui.auto_id_with("tab_close_btn");
158 |                 let close_btn_response = ui
159 |                     .interact(close_btn_rect, close_btn_id, Sense::click_and_drag())
160 |                     .on_hover_cursor(egui::CursorIcon::Default);
161 | 
162 |                 let visuals = ui.style().interact(&close_btn_response);
163 | 
164 |                 // Scale based on the interaction visuals
165 |                 let rect = close_btn_rect
166 |                     .shrink(self.close_button_inner_margin())
167 |                     .expand(visuals.expansion);
168 |                 let stroke = visuals.fg_stroke;
169 | 
170 |                 // paint the crossed lines
171 |                 ui.painter() // paints \
172 |                     .line_segment([rect.left_top(), rect.right_bottom()], stroke);
173 |                 ui.painter() // paints /
174 |                     .line_segment([rect.right_top(), rect.left_bottom()], stroke);
175 | 
176 |                 // Give the user a chance to react to the close button being clicked
177 |                 // Only close if the user returns true (handled)
178 |                 if close_btn_response.clicked() {
179 |                     log::debug!("Tab close requested for tile: {tile_id:?}");
180 | 
181 |                     // Close the tab if the implementation wants to
182 |                     if self.on_tab_close(tiles, tile_id) {
183 |                         log::debug!("Implementation confirmed close request for tile: {tile_id:?}");
184 | 
185 |                         tiles.remove(tile_id);
186 |                     } else {
187 |                         log::debug!("Implementation denied close request for tile: {tile_id:?}");
188 |                     }
189 |                 }
190 |             }
191 |         }
192 | 
193 |         self.on_tab_button(tiles, tile_id, tab_response)
194 |     }
195 | 
196 |     /// Show the ui for the tab being dragged.
197 |     fn drag_ui(&mut self, tiles: &Tiles, ui: &mut Ui, tile_id: TileId) {
198 |         let mut frame = egui::Frame::popup(ui.style());
199 |         frame.fill = frame.fill.gamma_multiply(0.5); // Make see-through
200 |         frame.show(ui, |ui| {
201 |             // TODO(emilk): preview contents?
202 |             let text = self.tab_title_for_tile(tiles, tile_id);
203 |             ui.label(text);
204 |         });
205 |     }
206 | 
207 |     /// Called by the default implementation of [`Self::tab_ui`] for each added button
208 |     fn on_tab_button(
209 |         &mut self,
210 |         _tiles: &Tiles,
211 |         _tile_id: TileId,
212 |         button_response: Response,
213 |     ) -> Response {
214 |         button_response
215 |     }
216 | 
217 |     /// Return `false` if a given pane should be removed from its parent.
218 |     fn retain_pane(&mut self, _pane: &Pane) -> bool {
219 |         true
220 |     }
221 | 
222 |     /// Adds some UI to the top right of each tab bar.
223 |     ///
224 |     /// You can use this to, for instance, add a button for adding new tabs.
225 |     ///
226 |     /// The widgets will be added right-to-left.
227 |     ///
228 |     /// `_scroll_offset` is a mutable reference to the tab scroll value.
229 |     /// Adding to this value will scroll the tabs to the right, subtracting to the left.
230 |     fn top_bar_right_ui(
231 |         &mut self,
232 |         _tiles: &Tiles,
233 |         _ui: &mut Ui,
234 |         _tile_id: TileId,
235 |         _tabs: &crate::Tabs,
236 |         _scroll_offset: &mut f32,
237 |     ) {
238 |         // if ui.button("➕").clicked() {
239 |         // }
240 |     }
241 | 
242 |     /// The height of the bar holding tab titles.
243 |     fn tab_bar_height(&self, _style: &egui::Style) -> f32 {
244 |         24.0
245 |     }
246 | 
247 |     /// Width of the gap between tiles in a horizontal or vertical layout,
248 |     /// and between rows/columns in a grid layout.
249 |     fn gap_width(&self, _style: &egui::Style) -> f32 {
250 |         1.0
251 |     }
252 | 
253 |     /// No child should shrink below this width nor height.
254 |     fn min_size(&self) -> f32 {
255 |         32.0
256 |     }
257 | 
258 |     /// Show we preview panes that are being dragged,
259 |     /// i.e. show their ui in the region where they will end up?
260 |     fn preview_dragged_panes(&self) -> bool {
261 |         false
262 |     }
263 | 
264 |     /// Cover the tile that is being dragged with this color.
265 |     fn dragged_overlay_color(&self, visuals: &Visuals) -> Color32 {
266 |         visuals.panel_fill.gamma_multiply(0.5)
267 |     }
268 | 
269 |     /// What are the rules for simplifying the tree?
270 |     fn simplification_options(&self) -> SimplificationOptions {
271 |         SimplificationOptions::default()
272 |     }
273 | 
274 |     /// Add some custom painting on top of a tile (container or pane), e.g. draw an outline on top of it.
275 |     fn paint_on_top_of_tile(
276 |         &self,
277 |         _painter: &egui::Painter,
278 |         _style: &egui::Style,
279 |         _tile_id: TileId,
280 |         _rect: Rect,
281 |     ) {
282 |     }
283 | 
284 |     /// The stroke used for the lines in horizontal, vertical, and grid layouts.
285 |     fn resize_stroke(&self, style: &egui::Style, resize_state: ResizeState) -> Stroke {
286 |         match resize_state {
287 |             ResizeState::Idle => {
288 |                 Stroke::new(self.gap_width(style), self.tab_bar_color(&style.visuals))
289 |             }
290 |             ResizeState::Hovering => style.visuals.widgets.hovered.fg_stroke,
291 |             ResizeState::Dragging => style.visuals.widgets.active.fg_stroke,
292 |         }
293 |     }
294 | 
295 |     /// Extra spacing to left and right of tab titles.
296 |     fn tab_title_spacing(&self, _visuals: &Visuals) -> f32 {
297 |         8.0
298 |     }
299 | 
300 |     /// The background color of the tab bar.
301 |     fn tab_bar_color(&self, visuals: &Visuals) -> Color32 {
302 |         if visuals.dark_mode {
303 |             visuals.extreme_bg_color
304 |         } else {
305 |             (Rgba::from(visuals.panel_fill) * Rgba::from_gray(0.8)).into()
306 |         }
307 |     }
308 | 
309 |     /// The background color of a tab.
310 |     fn tab_bg_color(
311 |         &self,
312 |         visuals: &Visuals,
313 |         _tiles: &Tiles,
314 |         _tile_id: TileId,
315 |         state: &TabState,
316 |     ) -> Color32 {
317 |         if state.active {
318 |             visuals.panel_fill // same as the tab contents
319 |         } else {
320 |             Color32::TRANSPARENT // fade into background
321 |         }
322 |     }
323 | 
324 |     /// Stroke of the outline around a tab title.
325 |     fn tab_outline_stroke(
326 |         &self,
327 |         visuals: &Visuals,
328 |         _tiles: &Tiles,
329 |         _tile_id: TileId,
330 |         state: &TabState,
331 |     ) -> Stroke {
332 |         if state.active {
333 |             Stroke::new(1.0, visuals.widgets.active.bg_fill)
334 |         } else {
335 |             Stroke::NONE
336 |         }
337 |     }
338 | 
339 |     /// Stroke of the line separating the tab title bar and the content of the active tab.
340 |     fn tab_bar_hline_stroke(&self, visuals: &Visuals) -> Stroke {
341 |         Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color)
342 |     }
343 | 
344 |     /// The color of the title text of the tab.
345 |     ///
346 |     /// This is the fallback color used if [`Self::tab_title_for_tile`]
347 |     /// has no color.
348 |     fn tab_text_color(
349 |         &self,
350 |         visuals: &Visuals,
351 |         _tiles: &Tiles,
352 |         _tile_id: TileId,
353 |         state: &TabState,
354 |     ) -> Color32 {
355 |         if state.active {
356 |             visuals.widgets.active.text_color()
357 |         } else {
358 |             visuals.widgets.noninteractive.text_color()
359 |         }
360 |     }
361 | 
362 |     /// When drag-and-dropping a tile, the candidate area is drawn with this stroke.
363 |     fn drag_preview_stroke(&self, visuals: &Visuals) -> Stroke {
364 |         visuals.selection.stroke
365 |     }
366 | 
367 |     /// When drag-and-dropping a tile, the candidate area is drawn with this background color.
368 |     fn drag_preview_color(&self, visuals: &Visuals) -> Color32 {
369 |         visuals.selection.stroke.color.gamma_multiply(0.5)
370 |     }
371 | 
372 |     /// When drag-and-dropping a tile, how do we preview what is about to happen?
373 |     fn paint_drag_preview(
374 |         &self,
375 |         visuals: &Visuals,
376 |         painter: &egui::Painter,
377 |         parent_rect: Option,
378 |         preview_rect: Rect,
379 |     ) {
380 |         let preview_stroke = self.drag_preview_stroke(visuals);
381 |         let preview_color = self.drag_preview_color(visuals);
382 | 
383 |         if let Some(parent_rect) = parent_rect {
384 |             // Show which parent we will be dropped into
385 |             painter.rect_stroke(parent_rect, 1.0, preview_stroke, egui::StrokeKind::Inside);
386 |         }
387 | 
388 |         painter.rect(
389 |             preview_rect,
390 |             1.0,
391 |             preview_color,
392 |             preview_stroke,
393 |             egui::StrokeKind::Inside,
394 |         );
395 |     }
396 | 
397 |     /// How many columns should we use for a [`crate::Grid`] put into [`crate::GridLayout::Auto`]?
398 |     ///
399 |     /// The default heuristic tried to find a good column count that results in a per-tile aspect-ratio
400 |     /// of [`Self::ideal_tile_aspect_ratio`].
401 |     ///
402 |     /// The `rect` is the available space for the grid,
403 |     /// and `gap` is the distance between each column and row.
404 |     fn grid_auto_column_count(&self, num_visible_children: usize, rect: Rect, gap: f32) -> usize {
405 |         num_columns_heuristic(
406 |             num_visible_children,
407 |             rect.size(),
408 |             gap,
409 |             self.ideal_tile_aspect_ratio(),
410 |         )
411 |     }
412 | 
413 |     /// When using [`crate::GridLayout::Auto`], what is the ideal aspect ratio of a tile?
414 |     fn ideal_tile_aspect_ratio(&self) -> f32 {
415 |         4.0 / 3.0
416 |     }
417 | 
418 |     // Callbacks:
419 | 
420 |     /// Called if the user edits the tree somehow, e.g. changes the size of some container,
421 |     /// clicks a tab, or drags a tile.
422 |     fn on_edit(&mut self, _edit_action: EditAction) {}
423 | }
424 | 
425 | /// How many columns should we use to fit `n` children in a grid?
426 | fn num_columns_heuristic(n: usize, size: Vec2, gap: f32, desired_aspect: f32) -> usize {
427 |     let mut best_loss = f32::INFINITY;
428 |     let mut best_num_columns = 1;
429 | 
430 |     for ncols in 1..=n {
431 |         if 4 <= n && ncols == n - 1 {
432 |             // Don't suggest 7 columns when n=8 - that produces an ugly orphan on a single row.
433 |             continue;
434 |         }
435 | 
436 |         let nrows = (n + ncols - 1) / ncols;
437 | 
438 |         let cell_width = (size.x - gap * (ncols as f32 - 1.0)) / (ncols as f32);
439 |         let cell_height = (size.y - gap * (nrows as f32 - 1.0)) / (nrows as f32);
440 | 
441 |         let cell_aspect = cell_width / cell_height;
442 |         let aspect_diff = (desired_aspect - cell_aspect).abs();
443 |         let num_empty_cells = ncols * nrows - n;
444 | 
445 |         let loss = aspect_diff * n as f32 + 2.0 * num_empty_cells as f32;
446 | 
447 |         if loss < best_loss {
448 |             best_loss = loss;
449 |             best_num_columns = ncols;
450 |         }
451 |     }
452 | 
453 |     best_num_columns
454 | }
455 | 
456 | #[test]
457 | fn test_num_columns_heuristic() {
458 |     // Four tiles should always be in a 1x4, 2x2, or 4x1 grid - NEVER 2x3 or 3x2.
459 | 
460 |     let n = 4;
461 |     let gap = 0.0;
462 |     let ideal_tile_aspect_ratio = 4.0 / 3.0;
463 | 
464 |     for i in 0..=100 {
465 |         let size = Vec2::new(100.0, egui::remap(i as f32, 0.0..=100.0, 1.0..=1000.0));
466 | 
467 |         let ncols = num_columns_heuristic(n, size, gap, ideal_tile_aspect_ratio);
468 |         assert!(
469 |             ncols == 1 || ncols == 2 || ncols == 4,
470 |             "Size {size:?} got {ncols} columns"
471 |         );
472 |     }
473 | }
474 | 


--------------------------------------------------------------------------------
/src/container/grid.rs:
--------------------------------------------------------------------------------
  1 | use egui::{
  2 |     emath::{GuiRounding as _, Rangef},
  3 |     pos2, vec2, NumExt as _, Rect,
  4 | };
  5 | use itertools::Itertools as _;
  6 | 
  7 | use crate::behavior::EditAction;
  8 | use crate::{
  9 |     Behavior, ContainerInsertion, DropContext, InsertionPoint, ResizeState, SimplifyAction, TileId,
 10 |     Tiles, Tree,
 11 | };
 12 | 
 13 | /// How to lay out the children of a grid.
 14 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
 15 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
 16 | pub enum GridLayout {
 17 |     /// Place children in a grid, with a dynamic number of columns and rows.
 18 |     /// Resizing the window may change the number of columns and rows.
 19 |     #[default]
 20 |     Auto,
 21 | 
 22 |     /// Place children in a grid with this many columns,
 23 |     /// and as many rows as needed.
 24 |     Columns(usize),
 25 | }
 26 | 
 27 | /// A grid of tiles.
 28 | #[derive(Clone, Debug, Default)]
 29 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
 30 | pub struct Grid {
 31 |     /// The order of the children, row-major.
 32 |     ///
 33 |     /// We allow holes (for easier drag-dropping).
 34 |     /// We collapse all holes if they become too numerous.
 35 |     children: Vec>,
 36 | 
 37 |     /// Determines the number of columns.
 38 |     pub layout: GridLayout,
 39 | 
 40 |     /// Share of the available width assigned to each column.
 41 |     pub col_shares: Vec,
 42 | 
 43 |     /// Share of the available height assigned to each row.
 44 |     pub row_shares: Vec,
 45 | 
 46 |     /// ui point x ranges for each column, recomputed during layout
 47 |     #[cfg_attr(feature = "serde", serde(skip))]
 48 |     col_ranges: Vec,
 49 | 
 50 |     /// ui point y ranges for each row, recomputed during layout
 51 |     #[cfg_attr(feature = "serde", serde(skip))]
 52 |     row_ranges: Vec,
 53 | }
 54 | 
 55 | impl PartialEq for Grid {
 56 |     fn eq(&self, other: &Self) -> bool {
 57 |         let Self {
 58 |             children,
 59 |             layout,
 60 |             col_shares,
 61 |             row_shares,
 62 |             col_ranges: _, // ignored because they are recomputed each frame
 63 |             row_ranges: _, // ignored because they are recomputed each frame
 64 |         } = self;
 65 | 
 66 |         layout == &other.layout
 67 |             && children == &other.children
 68 |             && col_shares == &other.col_shares
 69 |             && row_shares == &other.row_shares
 70 |     }
 71 | }
 72 | 
 73 | impl Grid {
 74 |     pub fn new(children: Vec) -> Self {
 75 |         Self {
 76 |             children: children.into_iter().map(Some).collect(),
 77 |             ..Default::default()
 78 |         }
 79 |     }
 80 | 
 81 |     pub fn num_children(&self) -> usize {
 82 |         self.children().count()
 83 |     }
 84 | 
 85 |     /// Includes invisible children.
 86 |     pub fn children(&self) -> impl Iterator {
 87 |         self.children.iter().filter_map(|c| c.as_ref())
 88 |     }
 89 | 
 90 |     pub fn add_child(&mut self, child: TileId) {
 91 |         self.children.push(Some(child));
 92 |     }
 93 | 
 94 |     pub fn insert_at(&mut self, index: usize, child: TileId) {
 95 |         if let Some(slot) = self.children.get_mut(index) {
 96 |             if slot.is_none() {
 97 |                 // put it in the empty hole
 98 |                 slot.replace(child);
 99 |             } else {
100 |                 // put it before
101 |                 log::trace!("Inserting {child:?} into Grid at {index}");
102 |                 self.children.insert(index, Some(child));
103 |             }
104 |         } else {
105 |             // put it last
106 |             log::trace!("Pushing {child:?} last in Grid");
107 |             self.children.push(Some(child));
108 |         }
109 |     }
110 | 
111 |     /// Returns the child already at the given index, if any.
112 |     #[must_use]
113 |     pub fn replace_at(&mut self, index: usize, child: TileId) -> Option {
114 |         if let Some(slot) = self.children.get_mut(index) {
115 |             slot.replace(child)
116 |         } else {
117 |             // put it last
118 |             self.children.push(Some(child));
119 |             None
120 |         }
121 |     }
122 | 
123 |     fn collapse_holes(&mut self) {
124 |         log::trace!("Collaping grid holes");
125 |         self.children.retain(|child| child.is_some());
126 |     }
127 | 
128 |     fn visible_children_and_holes(&self, tiles: &Tiles) -> Vec> {
129 |         self.children
130 |             .iter()
131 |             .filter(|id| id.map_or(true, |id| tiles.is_visible(id)))
132 |             .copied()
133 |             .collect()
134 |     }
135 | 
136 |     pub(super) fn layout(
137 |         &mut self,
138 |         tiles: &mut Tiles,
139 |         style: &egui::Style,
140 |         behavior: &mut dyn Behavior,
141 |         rect: Rect,
142 |     ) {
143 |         // clean up any empty holes at the end
144 |         while self.children.last() == Some(&None) {
145 |             self.children.pop();
146 |         }
147 | 
148 |         let gap = behavior.gap_width(style);
149 | 
150 |         let visible_children_and_holes = self.visible_children_and_holes(tiles);
151 | 
152 |         // Calculate grid dimensions:
153 |         let (num_cols, num_rows) = {
154 |             let num_visible_children = visible_children_and_holes.len();
155 | 
156 |             let num_cols = match self.layout {
157 |                 GridLayout::Auto => {
158 |                     behavior.grid_auto_column_count(num_visible_children, rect, gap)
159 |                 }
160 |                 GridLayout::Columns(num_columns) => num_columns,
161 |             };
162 |             let num_cols = num_cols.at_least(1);
163 |             let num_rows = (num_visible_children + num_cols - 1) / num_cols;
164 |             (num_cols, num_rows)
165 |         };
166 | 
167 |         debug_assert!(
168 |             visible_children_and_holes.len() <= num_cols * num_rows,
169 |             "Bug in egui_tiles::Grid::layout"
170 |         );
171 | 
172 |         // Figure out where each column and row goes:
173 |         self.col_shares.resize(num_cols, 1.0);
174 |         self.row_shares.resize(num_rows, 1.0);
175 | 
176 |         let col_widths = sizes_from_shares(&self.col_shares, rect.width(), gap);
177 |         let row_heights = sizes_from_shares(&self.row_shares, rect.height(), gap);
178 | 
179 |         debug_assert_eq!(
180 |             col_widths.len(),
181 |             num_cols,
182 |             "Bug in egui_tiles::Grid::layout"
183 |         );
184 |         debug_assert_eq!(
185 |             row_heights.len(),
186 |             num_rows,
187 |             "Bug in egui_tiles::Grid::layout"
188 |         );
189 | 
190 |         {
191 |             let mut x = rect.left();
192 |             self.col_ranges.clear();
193 |             for &width in &col_widths {
194 |                 self.col_ranges.push(Rangef::new(x, x + width));
195 |                 x += width + gap;
196 |             }
197 |         }
198 |         {
199 |             let mut y = rect.top();
200 |             self.row_ranges.clear();
201 |             for &height in &row_heights {
202 |                 self.row_ranges.push(Rangef::new(y, y + height));
203 |                 y += height + gap;
204 |             }
205 |         }
206 | 
207 |         debug_assert_eq!(
208 |             self.col_ranges.len(),
209 |             num_cols,
210 |             "Bug in egui_tiles::Grid::layout"
211 |         );
212 |         debug_assert_eq!(
213 |             self.row_ranges.len(),
214 |             num_rows,
215 |             "Bug in egui_tiles::Grid::layout"
216 |         );
217 | 
218 |         // Layout each child:
219 |         for (i, &child) in visible_children_and_holes.iter().enumerate() {
220 |             if let Some(child) = child {
221 |                 let col = i % num_cols;
222 |                 let row = i / num_cols;
223 |                 let child_rect = Rect::from_x_y_ranges(self.col_ranges[col], self.row_ranges[row]);
224 |                 tiles.layout_tile(style, behavior, child_rect, child);
225 |             }
226 |         }
227 | 
228 |         // Check if we should collapse some holes:
229 |         {
230 |             let num_holes = visible_children_and_holes
231 |                 .iter()
232 |                 .filter(|c| c.is_none())
233 |                 .count()
234 |                 + (num_cols * num_rows - visible_children_and_holes.len());
235 | 
236 |             if num_cols.min(num_rows) <= num_holes {
237 |                 // More holes than there are columns or rows - let's collapse all holes
238 |                 // so that we can shrink for next frame:
239 |                 self.collapse_holes();
240 |             }
241 |         }
242 |     }
243 | 
244 |     pub(super) fn ui(
245 |         &mut self,
246 |         tree: &mut Tree,
247 |         behavior: &mut dyn Behavior,
248 |         drop_context: &mut DropContext,
249 |         ui: &egui::Ui,
250 |         tile_id: TileId,
251 |     ) {
252 |         for &child in &self.children {
253 |             if let Some(child) = child {
254 |                 if tree.is_visible(child) {
255 |                     tree.tile_ui(behavior, drop_context, ui, child);
256 |                     crate::cover_tile_if_dragged(tree, behavior, ui, child);
257 |                 }
258 |             }
259 |         }
260 | 
261 |         // Register drop-zones:
262 |         for i in 0..(self.col_ranges.len() * self.row_ranges.len()) {
263 |             let col = i % self.col_ranges.len();
264 |             let row = i / self.col_ranges.len();
265 |             let child_rect = Rect::from_x_y_ranges(self.col_ranges[col], self.row_ranges[row]);
266 |             drop_context.suggest_rect(
267 |                 InsertionPoint::new(tile_id, ContainerInsertion::Grid(i)),
268 |                 child_rect,
269 |             );
270 |         }
271 | 
272 |         self.resize_columns(&tree.tiles, behavior, ui, tile_id);
273 |         self.resize_rows(&tree.tiles, behavior, ui, tile_id);
274 |     }
275 | 
276 |     fn resize_columns(
277 |         &mut self,
278 |         tiles: &Tiles,
279 |         behavior: &mut dyn Behavior,
280 |         ui: &egui::Ui,
281 |         parent_id: TileId,
282 |     ) {
283 |         let parent_rect = tiles.rect_or_die(parent_id);
284 |         for (i, (left, right)) in self.col_ranges.iter().copied().tuple_windows().enumerate() {
285 |             let resize_id = ui.id().with((parent_id, "resize_col", i));
286 | 
287 |             let x = egui::lerp(left.max..=right.min, 0.5);
288 | 
289 |             let mut resize_state = ResizeState::Idle;
290 |             let line_rect = Rect::from_center_size(
291 |                 pos2(x, parent_rect.center().y),
292 |                 vec2(
293 |                     2.0 * ui.style().interaction.resize_grab_radius_side,
294 |                     parent_rect.height(),
295 |                 ),
296 |             );
297 |             let response = ui.interact(line_rect, resize_id, egui::Sense::click_and_drag());
298 |             // NOTE: Check for interaction with line_rect BEFORE entering the 'IF block' below,
299 |             // otherwise we miss the start of a drag event in certain cases (e.g. touchscreens).
300 |             if let Some(pointer) = ui.ctx().pointer_interact_pos() {
301 |                 resize_state = resize_interaction(
302 |                     behavior,
303 |                     &self.col_ranges,
304 |                     &mut self.col_shares,
305 |                     &response,
306 |                     pointer.round_to_pixels(ui.pixels_per_point()).x - x,
307 |                     i,
308 |                 );
309 | 
310 |                 if resize_state != ResizeState::Idle {
311 |                     ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
312 |                 }
313 |             }
314 | 
315 |             let stroke = behavior.resize_stroke(ui.style(), resize_state);
316 |             ui.painter().vline(x, parent_rect.y_range(), stroke);
317 |         }
318 |     }
319 | 
320 |     fn resize_rows(
321 |         &mut self,
322 |         tiles: &Tiles,
323 |         behavior: &mut dyn Behavior,
324 |         ui: &egui::Ui,
325 |         parent_id: TileId,
326 |     ) {
327 |         let parent_rect = tiles.rect_or_die(parent_id);
328 |         for (i, (top, bottom)) in self.row_ranges.iter().copied().tuple_windows().enumerate() {
329 |             let resize_id = ui.id().with((parent_id, "resize_row", i));
330 | 
331 |             let y = egui::lerp(top.max..=bottom.min, 0.5);
332 | 
333 |             let mut resize_state = ResizeState::Idle;
334 |             let line_rect = Rect::from_center_size(
335 |                 pos2(parent_rect.center().x, y),
336 |                 vec2(
337 |                     parent_rect.width(),
338 |                     2.0 * ui.style().interaction.resize_grab_radius_side,
339 |                 ),
340 |             );
341 |             let response = ui.interact(line_rect, resize_id, egui::Sense::click_and_drag());
342 |             // NOTE: Check for interaction with line_rect BEFORE entering the 'IF block' below,
343 |             // otherwise we miss the start of a drag event in certain cases (e.g. touchscreens).
344 |             if let Some(pointer) = ui.ctx().pointer_interact_pos() {
345 |                 resize_state = resize_interaction(
346 |                     behavior,
347 |                     &self.row_ranges,
348 |                     &mut self.row_shares,
349 |                     &response,
350 |                     pointer.round_to_pixels(ui.pixels_per_point()).y - y,
351 |                     i,
352 |                 );
353 | 
354 |                 if resize_state != ResizeState::Idle {
355 |                     ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
356 |                 }
357 |             }
358 | 
359 |             let stroke = behavior.resize_stroke(ui.style(), resize_state);
360 |             ui.painter().hline(parent_rect.x_range(), y, stroke);
361 |         }
362 |     }
363 | 
364 |     pub(super) fn simplify_children(&mut self, mut simplify: impl FnMut(TileId) -> SimplifyAction) {
365 |         for child_opt in &mut self.children {
366 |             if let Some(child) = *child_opt {
367 |                 match simplify(child) {
368 |                     SimplifyAction::Remove => {
369 |                         *child_opt = None;
370 |                     }
371 |                     SimplifyAction::Keep => {}
372 |                     SimplifyAction::Replace(new) => {
373 |                         *child_opt = Some(new);
374 |                     }
375 |                 }
376 |             }
377 |         }
378 |     }
379 | 
380 |     pub(super) fn retain(&mut self, mut retain: impl FnMut(TileId) -> bool) {
381 |         for child_opt in &mut self.children {
382 |             if let Some(child) = *child_opt {
383 |                 if !retain(child) {
384 |                     *child_opt = None;
385 |                 }
386 |             }
387 |         }
388 |     }
389 | 
390 |     /// Returns child index, if found.
391 |     pub(crate) fn remove_child(&mut self, needle: TileId) -> Option {
392 |         let index = self
393 |             .children
394 |             .iter()
395 |             .position(|&child| child == Some(needle))?;
396 |         self.children[index] = None;
397 |         Some(index)
398 |     }
399 | }
400 | 
401 | fn resize_interaction(
402 |     behavior: &mut dyn Behavior,
403 |     ranges: &[Rangef],
404 |     shares: &mut [f32],
405 |     splitter_response: &egui::Response,
406 |     dx: f32,
407 |     i: usize,
408 | ) -> ResizeState {
409 |     assert_eq!(ranges.len(), shares.len(), "Bug in egui_tiles::Grid");
410 |     let num = ranges.len();
411 |     let tile_width = |i: usize| ranges[i].span();
412 | 
413 |     let left = i;
414 |     let right = i + 1;
415 | 
416 |     if splitter_response.double_clicked() {
417 |         behavior.on_edit(EditAction::TileResized);
418 | 
419 |         // double-click to center the split between left and right:
420 |         let mean = 0.5 * (shares[left] + shares[right]);
421 |         shares[left] = mean;
422 |         shares[right] = mean;
423 |         ResizeState::Hovering
424 |     } else if splitter_response.dragged() {
425 |         behavior.on_edit(EditAction::TileResized);
426 | 
427 |         if dx < 0.0 {
428 |             // Expand right, shrink stuff to the left:
429 |             shares[right] += shrink_shares(
430 |                 behavior,
431 |                 shares,
432 |                 &(0..=i).rev().collect_vec(),
433 |                 dx.abs(),
434 |                 tile_width,
435 |             );
436 |         } else {
437 |             // Expand the left, shrink stuff to the right:
438 |             shares[left] += shrink_shares(
439 |                 behavior,
440 |                 shares,
441 |                 &(i + 1..num).collect_vec(),
442 |                 dx.abs(),
443 |                 tile_width,
444 |             );
445 |         }
446 |         ResizeState::Dragging
447 |     } else if splitter_response.hovered() {
448 |         ResizeState::Hovering
449 |     } else {
450 |         ResizeState::Idle
451 |     }
452 | }
453 | 
454 | /// Try shrink the children by a total of `target_in_points`,
455 | /// making sure no child gets smaller than its minimum size.
456 | fn shrink_shares(
457 |     behavior: &dyn Behavior,
458 |     shares: &mut [f32],
459 |     children: &[usize],
460 |     target_in_points: f32,
461 |     size_in_point: impl Fn(usize) -> f32,
462 | ) -> f32 {
463 |     if children.is_empty() {
464 |         return 0.0;
465 |     }
466 | 
467 |     let mut total_shares = 0.0;
468 |     let mut total_points = 0.0;
469 |     for &child in children {
470 |         total_shares += shares[child];
471 |         total_points += size_in_point(child);
472 |     }
473 | 
474 |     let shares_per_point = total_shares / total_points;
475 | 
476 |     let min_size_in_shares = shares_per_point * behavior.min_size();
477 | 
478 |     let target_in_shares = shares_per_point * target_in_points;
479 |     let mut total_shares_lost = 0.0;
480 | 
481 |     for &child in children {
482 |         let share = &mut shares[child];
483 |         let spare_share = (*share - min_size_in_shares).at_least(0.0);
484 |         let shares_needed = (target_in_shares - total_shares_lost).at_least(0.0);
485 |         let shrink_by = f32::min(spare_share, shares_needed);
486 | 
487 |         *share -= shrink_by;
488 |         total_shares_lost += shrink_by;
489 |     }
490 | 
491 |     total_shares_lost
492 | }
493 | 
494 | fn sizes_from_shares(shares: &[f32], available_size: f32, gap_width: f32) -> Vec {
495 |     if shares.is_empty() {
496 |         return vec![];
497 |     }
498 | 
499 |     let available_size = available_size - gap_width * (shares.len() - 1) as f32;
500 |     let available_size = available_size.at_least(0.0);
501 | 
502 |     let total_share: f32 = shares.iter().sum();
503 |     if total_share <= 0.0 {
504 |         vec![available_size / shares.len() as f32; shares.len()]
505 |     } else {
506 |         shares
507 |             .iter()
508 |             .map(|&share| share / total_share * available_size)
509 |             .collect()
510 |     }
511 | }
512 | 
513 | #[cfg(test)]
514 | mod tests {
515 |     use crate::{Container, Tile};
516 | 
517 |     use super::*;
518 | 
519 |     #[test]
520 |     fn test_grid_with_chaos_monkey() {
521 |         #[derive(Debug)]
522 |         struct Pane {}
523 | 
524 |         struct TestBehavior {}
525 | 
526 |         impl Behavior for TestBehavior {
527 |             fn pane_ui(
528 |                 &mut self,
529 |                 _ui: &mut egui::Ui,
530 |                 _tile_id: TileId,
531 |                 _pane: &mut Pane,
532 |             ) -> crate::UiResponse {
533 |                 panic!()
534 |             }
535 | 
536 |             fn tab_title_for_pane(&mut self, _pane: &Pane) -> egui::WidgetText {
537 |                 panic!()
538 |             }
539 | 
540 |             fn is_tab_closable(&self, _tiles: &Tiles, _tile_id: TileId) -> bool {
541 |                 panic!()
542 |             }
543 | 
544 |             fn on_tab_close(&mut self, _tiles: &mut Tiles, _tile_id: TileId) -> bool {
545 |                 panic!()
546 |             }
547 |         }
548 | 
549 |         let mut tree = {
550 |             let mut tiles = Tiles::default();
551 |             let panes: Vec = vec![tiles.insert_pane(Pane {}), tiles.insert_pane(Pane {})];
552 |             let root: TileId = tiles.insert_grid_tile(panes);
553 |             Tree::new("test_tree", root, tiles)
554 |         };
555 | 
556 |         let style = egui::Style::default();
557 |         let mut behavior = TestBehavior {};
558 |         let area = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1024.0, 768.0));
559 | 
560 |         // Go crazy on it to make sure we never crash:
561 |         let mut rng = Pcg64::new_seed(123_456_789_012);
562 | 
563 |         for _ in 0..1000 {
564 |             let root = tree.root.unwrap();
565 |             tree.tiles.layout_tile(&style, &mut behavior, area, root);
566 | 
567 |             // Add some tiles:
568 |             for _ in 0..rng.rand_u64() % 3 {
569 |                 if tree.tiles.len() < 100 {
570 |                     let pane = tree.tiles.insert_pane(Pane {});
571 |                     if let Some(Tile::Container(Container::Grid(grid))) = tree.tiles.get_mut(root) {
572 |                         grid.add_child(pane);
573 |                     } else {
574 |                         panic!()
575 |                     }
576 |                 }
577 |             }
578 | 
579 |             // Move a random child to then end of the grid:
580 |             for _ in 0..rng.rand_u64() % 2 {
581 |                 if let Some(Tile::Container(Container::Grid(grid))) = tree.tiles.get_mut(root) {
582 |                     if !grid.children.is_empty() {
583 |                         let child_idx = rng.rand_usize() % grid.children.len();
584 |                         let child = grid.children[child_idx].take();
585 |                         grid.children.push(child);
586 |                     }
587 |                 } else {
588 |                     panic!()
589 |                 }
590 |             }
591 | 
592 |             // Flip some visibilities:
593 |             for _ in 0..rng.rand_u64() % 2 {
594 |                 let children =
595 |                     if let Some(Tile::Container(Container::Grid(grid))) = tree.tiles.get(root) {
596 |                         grid.visible_children_and_holes(&tree.tiles)
597 |                             .iter()
598 |                             .copied()
599 |                             .flatten()
600 |                             .collect_vec()
601 |                     } else {
602 |                         panic!()
603 |                     };
604 | 
605 |                 if !children.is_empty() {
606 |                     let child_idx = rng.rand_usize() % children.len();
607 |                     tree.tiles.toggle_visibility(children[child_idx]);
608 |                 }
609 |             }
610 | 
611 |             // Remove some tiles:
612 |             for _ in 0..rng.rand_u64() % 2 {
613 |                 let children =
614 |                     if let Some(Tile::Container(Container::Grid(grid))) = tree.tiles.get(root) {
615 |                         grid.visible_children_and_holes(&tree.tiles)
616 |                             .iter()
617 |                             .copied()
618 |                             .flatten()
619 |                             .collect_vec()
620 |                     } else {
621 |                         panic!()
622 |                     };
623 | 
624 |                 if !children.is_empty() {
625 |                     let child_id = children[rng.rand_usize() % children.len()];
626 |                     let (parent, _) = tree.remove_tile_id_from_parent(child_id).unwrap();
627 |                     assert_eq!(parent, root);
628 |                     tree.tiles.remove(child_id).unwrap();
629 |                 }
630 |             }
631 |         }
632 |     }
633 | 
634 |     // We want a simple RNG, but don't want to pull in any deps just for a test.
635 |     // Code from adapted from https://docs.rs/nanorand/latest/src/nanorand/rand/pcg64.rs.html#15-19
636 |     pub struct Pcg64 {
637 |         seed: u128,
638 |         state: u128,
639 |         inc: u128,
640 |     }
641 | 
642 |     impl Pcg64 {
643 |         pub const fn new_seed(seed: u128) -> Self {
644 |             Self {
645 |                 seed,
646 |                 inc: 0,
647 |                 state: 0,
648 |             }
649 |         }
650 | 
651 |         fn step(&mut self) {
652 |             const PCG_DEFAULT_MULTIPLIER_128: u128 = 47026247687942121848144207491837523525;
653 | 
654 |             self.state = self
655 |                 .state
656 |                 .wrapping_mul(PCG_DEFAULT_MULTIPLIER_128)
657 |                 .wrapping_add(self.inc);
658 |         }
659 | 
660 |         fn rand_u64(&mut self) -> u64 {
661 |             self.state = 0;
662 |             self.inc = self.seed.wrapping_shl(1) | 1;
663 |             self.step();
664 |             self.state = self.state.wrapping_add(self.seed);
665 |             self.step();
666 |             self.step();
667 |             self.state.wrapping_shr(64) as u64 ^ self.state as u64
668 |         }
669 | 
670 |         fn rand_usize(&mut self) -> usize {
671 |             self.rand_u64() as usize
672 |         }
673 |     }
674 | }
675 | 


--------------------------------------------------------------------------------
/src/container/linear.rs:
--------------------------------------------------------------------------------
  1 | #![allow(clippy::tuple_array_conversions)]
  2 | 
  3 | use egui::{emath::GuiRounding as _, pos2, vec2, NumExt, Rect};
  4 | use itertools::Itertools as _;
  5 | 
  6 | use crate::behavior::EditAction;
  7 | use crate::{
  8 |     is_being_dragged, Behavior, ContainerInsertion, DropContext, InsertionPoint, ResizeState,
  9 |     SimplifyAction, TileId, Tiles, Tree,
 10 | };
 11 | 
 12 | // ----------------------------------------------------------------------------
 13 | 
 14 | /// How large of a share of space each child has, on a 1D axis.
 15 | ///
 16 | /// Used for [`Linear`] containers (horizontal and vertical).
 17 | ///
 18 | /// Also contains the shares for currently invisible tiles.
 19 | #[derive(Clone, Debug, Default, PartialEq)]
 20 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
 21 | pub struct Shares {
 22 |     /// How large of a share each child has.
 23 |     ///
 24 |     /// For instance, the shares `[1, 2, 3]` means that the first child gets 1/6 of the space,
 25 |     /// the second gets 2/6 and the third gets 3/6.
 26 |     shares: ahash::HashMap,
 27 | }
 28 | 
 29 | impl Shares {
 30 |     pub fn iter(&self) -> impl Iterator {
 31 |         self.shares.iter()
 32 |     }
 33 | 
 34 |     pub fn replace_with(&mut self, remove: TileId, new: TileId) {
 35 |         if let Some(share) = self.shares.remove(&remove) {
 36 |             self.shares.insert(new, share);
 37 |         }
 38 |     }
 39 | 
 40 |     pub fn set_share(&mut self, id: TileId, share: f32) {
 41 |         self.shares.insert(id, share);
 42 |     }
 43 | 
 44 |     /// Split the given width based on the share of the children.
 45 |     pub fn split(&self, children: &[TileId], available_width: f32) -> Vec {
 46 |         let mut num_shares = 0.0;
 47 |         for &child in children {
 48 |             num_shares += self[child];
 49 |         }
 50 |         if num_shares == 0.0 {
 51 |             num_shares = 1.0;
 52 |         }
 53 |         children
 54 |             .iter()
 55 |             .map(|&child| available_width * self[child] / num_shares)
 56 |             .collect()
 57 |     }
 58 | 
 59 |     pub fn retain(&mut self, keep: impl Fn(TileId) -> bool) {
 60 |         self.shares.retain(|&child, _| keep(child));
 61 |     }
 62 | }
 63 | 
 64 | impl<'a> IntoIterator for &'a Shares {
 65 |     type Item = (&'a TileId, &'a f32);
 66 |     type IntoIter = std::collections::hash_map::Iter<'a, TileId, f32>;
 67 | 
 68 |     #[inline]
 69 |     fn into_iter(self) -> Self::IntoIter {
 70 |         self.shares.iter()
 71 |     }
 72 | }
 73 | 
 74 | impl std::ops::Index for Shares {
 75 |     type Output = f32;
 76 | 
 77 |     #[inline]
 78 |     fn index(&self, id: TileId) -> &Self::Output {
 79 |         self.shares.get(&id).unwrap_or(&1.0)
 80 |     }
 81 | }
 82 | 
 83 | impl std::ops::IndexMut for Shares {
 84 |     #[inline]
 85 |     fn index_mut(&mut self, id: TileId) -> &mut Self::Output {
 86 |         self.shares.entry(id).or_insert(1.0)
 87 |     }
 88 | }
 89 | 
 90 | // ----------------------------------------------------------------------------
 91 | 
 92 | /// The direction of a [`Linear`] container. Either horizontal or vertical.
 93 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
 94 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
 95 | pub enum LinearDir {
 96 |     #[default]
 97 |     Horizontal,
 98 |     Vertical,
 99 | }
100 | 
101 | /// Horizontal or vertical container.
102 | #[derive(Clone, Debug, Default, PartialEq)]
103 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
104 | pub struct Linear {
105 |     pub children: Vec,
106 |     pub dir: LinearDir,
107 |     pub shares: Shares,
108 | }
109 | 
110 | impl Linear {
111 |     pub fn new(dir: LinearDir, children: Vec) -> Self {
112 |         Self {
113 |             children,
114 |             dir,
115 |             ..Default::default()
116 |         }
117 |     }
118 | 
119 |     fn visible_children(&self, tiles: &Tiles) -> Vec {
120 |         self.children
121 |             .iter()
122 |             .copied()
123 |             .filter(|&child_id| tiles.is_visible(child_id))
124 |             .collect()
125 |     }
126 | 
127 |     /// Create a binary split with the given split ratio in the 0.0 - 1.0 range.
128 |     ///
129 |     /// The `fraction` is the fraction of the total width that the first child should get.
130 |     pub fn new_binary(dir: LinearDir, children: [TileId; 2], fraction: f32) -> Self {
131 |         debug_assert!(
132 |             (0.0..=1.0).contains(&fraction),
133 |             "Fraction should be in 0.0..=1.0"
134 |         );
135 |         let mut slf = Self {
136 |             children: children.into(),
137 |             dir,
138 |             ..Default::default()
139 |         };
140 |         // We multiply the shares with 2.0 because the default share size is 1.0,
141 |         // and so we want the total share to be the same as the number of children.
142 |         slf.shares[children[0]] = 2.0 * (fraction);
143 |         slf.shares[children[1]] = 2.0 * (1.0 - fraction);
144 |         slf
145 |     }
146 | 
147 |     pub fn add_child(&mut self, child: TileId) {
148 |         self.children.push(child);
149 |     }
150 | 
151 |     pub fn layout(
152 |         &mut self,
153 |         tiles: &mut Tiles,
154 |         style: &egui::Style,
155 |         behavior: &mut dyn Behavior,
156 |         rect: Rect,
157 |     ) {
158 |         // GC:
159 |         let child_set: ahash::HashSet = self.children.iter().copied().collect();
160 |         self.shares.retain(|id| child_set.contains(&id));
161 | 
162 |         match self.dir {
163 |             LinearDir::Horizontal => {
164 |                 self.layout_horizontal(tiles, style, behavior, rect);
165 |             }
166 |             LinearDir::Vertical => self.layout_vertical(tiles, style, behavior, rect),
167 |         }
168 |     }
169 | 
170 |     fn layout_horizontal(
171 |         &self,
172 |         tiles: &mut Tiles,
173 |         style: &egui::Style,
174 |         behavior: &mut dyn Behavior,
175 |         rect: Rect,
176 |     ) {
177 |         let visible_children = self.visible_children(tiles);
178 | 
179 |         let num_gaps = visible_children.len().saturating_sub(1);
180 |         let gap_width = behavior.gap_width(style);
181 |         let total_gap_width = gap_width * num_gaps as f32;
182 |         let available_width = (rect.width() - total_gap_width).at_least(0.0);
183 | 
184 |         let widths = self.shares.split(&visible_children, available_width);
185 | 
186 |         let mut x = rect.min.x;
187 |         for (child, width) in visible_children.iter().zip(widths) {
188 |             let child_rect = Rect::from_min_size(pos2(x, rect.min.y), vec2(width, rect.height()));
189 |             tiles.layout_tile(style, behavior, child_rect, *child);
190 |             x += width + gap_width;
191 |         }
192 |     }
193 | 
194 |     fn layout_vertical(
195 |         &self,
196 |         tiles: &mut Tiles,
197 |         style: &egui::Style,
198 |         behavior: &mut dyn Behavior,
199 |         rect: Rect,
200 |     ) {
201 |         let visible_children = self.visible_children(tiles);
202 | 
203 |         let num_gaps = visible_children.len().saturating_sub(1);
204 |         let gap_height = behavior.gap_width(style);
205 |         let total_gap_height = gap_height * num_gaps as f32;
206 |         let available_height = (rect.height() - total_gap_height).at_least(0.0);
207 | 
208 |         let heights = self.shares.split(&visible_children, available_height);
209 | 
210 |         let mut y = rect.min.y;
211 |         for (child, height) in visible_children.iter().zip(heights) {
212 |             let child_rect = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), height));
213 |             tiles.layout_tile(style, behavior, child_rect, *child);
214 |             y += height + gap_height;
215 |         }
216 |     }
217 | 
218 |     pub(super) fn ui(
219 |         &mut self,
220 |         tree: &mut Tree,
221 |         behavior: &mut dyn Behavior,
222 |         drop_context: &mut DropContext,
223 |         ui: &egui::Ui,
224 |         tile_id: TileId,
225 |     ) {
226 |         match self.dir {
227 |             LinearDir::Horizontal => self.horizontal_ui(tree, behavior, drop_context, ui, tile_id),
228 |             LinearDir::Vertical => self.vertical_ui(tree, behavior, drop_context, ui, tile_id),
229 |         }
230 |     }
231 | 
232 |     fn horizontal_ui(
233 |         &mut self,
234 |         tree: &mut Tree,
235 |         behavior: &mut dyn Behavior,
236 |         drop_context: &mut DropContext,
237 |         ui: &egui::Ui,
238 |         parent_id: TileId,
239 |     ) {
240 |         let visible_children = self.visible_children(&tree.tiles);
241 | 
242 |         for &child in &visible_children {
243 |             tree.tile_ui(behavior, drop_context, ui, child);
244 |             crate::cover_tile_if_dragged(tree, behavior, ui, child);
245 |         }
246 | 
247 |         linear_drop_zones(ui.ctx(), tree, &self.children, self.dir, |rect, i| {
248 |             drop_context.suggest_rect(
249 |                 InsertionPoint::new(parent_id, ContainerInsertion::Horizontal(i)),
250 |                 rect,
251 |             );
252 |         });
253 | 
254 |         // ------------------------
255 |         // resizing:
256 | 
257 |         let parent_rect = tree.tiles.rect_or_die(parent_id);
258 |         for (i, (left, right)) in visible_children.iter().copied().tuple_windows().enumerate() {
259 |             let resize_id = ui.id().with((parent_id, "resize", i));
260 | 
261 |             let left_rect = tree.tiles.rect_or_die(left);
262 |             let right_rect = tree.tiles.rect_or_die(right);
263 |             let x = egui::lerp(left_rect.right()..=right_rect.left(), 0.5);
264 | 
265 |             let mut resize_state = ResizeState::Idle;
266 |             let line_rect = Rect::from_center_size(
267 |                 pos2(x, parent_rect.center().y),
268 |                 vec2(
269 |                     2.0 * ui.style().interaction.resize_grab_radius_side,
270 |                     parent_rect.height(),
271 |                 ),
272 |             );
273 |             let response = ui.interact(line_rect, resize_id, egui::Sense::click_and_drag());
274 |             // NOTE: Check for interaction with line_rect BEFORE entering the 'IF block' below,
275 |             // otherwise we miss the start of a drag event in certain cases (e.g. touchscreens).
276 |             if let Some(pointer) = ui.ctx().pointer_interact_pos() {
277 |                 resize_state = resize_interaction(
278 |                     behavior,
279 |                     &mut self.shares,
280 |                     &visible_children,
281 |                     &response,
282 |                     [left, right],
283 |                     pointer.round_to_pixels(ui.pixels_per_point()).x - x,
284 |                     i,
285 |                     |tile_id: TileId| tree.tiles.rect_or_die(tile_id).width(),
286 |                 );
287 | 
288 |                 if resize_state != ResizeState::Idle {
289 |                     ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
290 |                 }
291 |             }
292 | 
293 |             let stroke = behavior.resize_stroke(ui.style(), resize_state);
294 |             ui.painter().vline(x, parent_rect.y_range(), stroke);
295 |         }
296 |     }
297 | 
298 |     fn vertical_ui(
299 |         &mut self,
300 |         tree: &mut Tree,
301 |         behavior: &mut dyn Behavior,
302 |         drop_context: &mut DropContext,
303 |         ui: &egui::Ui,
304 |         parent_id: TileId,
305 |     ) {
306 |         let visible_children = self.visible_children(&tree.tiles);
307 | 
308 |         for &child in &visible_children {
309 |             tree.tile_ui(behavior, drop_context, ui, child);
310 |             crate::cover_tile_if_dragged(tree, behavior, ui, child);
311 |         }
312 | 
313 |         linear_drop_zones(ui.ctx(), tree, &self.children, self.dir, |rect, i| {
314 |             drop_context.suggest_rect(
315 |                 InsertionPoint::new(parent_id, ContainerInsertion::Vertical(i)),
316 |                 rect,
317 |             );
318 |         });
319 | 
320 |         // ------------------------
321 |         // resizing:
322 | 
323 |         let parent_rect = tree.tiles.rect_or_die(parent_id);
324 |         for (i, (top, bottom)) in visible_children.iter().copied().tuple_windows().enumerate() {
325 |             let resize_id = ui.id().with((parent_id, "resize", i));
326 | 
327 |             let top_rect = tree.tiles.rect_or_die(top);
328 |             let bottom_rect = tree.tiles.rect_or_die(bottom);
329 |             let y = egui::lerp(top_rect.bottom()..=bottom_rect.top(), 0.5);
330 | 
331 |             let mut resize_state = ResizeState::Idle;
332 |             let line_rect = Rect::from_center_size(
333 |                 pos2(parent_rect.center().x, y),
334 |                 vec2(
335 |                     parent_rect.width(),
336 |                     2.0 * ui.style().interaction.resize_grab_radius_side,
337 |                 ),
338 |             );
339 |             let response = ui.interact(line_rect, resize_id, egui::Sense::click_and_drag());
340 |             // NOTE: Check for interaction with line_rect BEFORE entering the 'IF block' below,
341 |             // otherwise we miss the start of a drag event in certain cases (e.g. touchscreens).
342 |             if let Some(pointer) = ui.ctx().pointer_interact_pos() {
343 |                 resize_state = resize_interaction(
344 |                     behavior,
345 |                     &mut self.shares,
346 |                     &visible_children,
347 |                     &response,
348 |                     [top, bottom],
349 |                     pointer.round_to_pixels(ui.pixels_per_point()).y - y,
350 |                     i,
351 |                     |tile_id: TileId| tree.tiles.rect_or_die(tile_id).height(),
352 |                 );
353 | 
354 |                 if resize_state != ResizeState::Idle {
355 |                     ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
356 |                 }
357 |             }
358 | 
359 |             let stroke = behavior.resize_stroke(ui.style(), resize_state);
360 |             ui.painter().hline(parent_rect.x_range(), y, stroke);
361 |         }
362 |     }
363 | 
364 |     pub(super) fn simplify_children(&mut self, mut simplify: impl FnMut(TileId) -> SimplifyAction) {
365 |         self.children.retain_mut(|child| match simplify(*child) {
366 |             SimplifyAction::Remove => false,
367 |             SimplifyAction::Keep => true,
368 |             SimplifyAction::Replace(new) => {
369 |                 self.shares.replace_with(*child, new);
370 |                 *child = new;
371 |                 true
372 |             }
373 |         });
374 |     }
375 | 
376 |     /// Returns child index, if found.
377 |     pub(crate) fn remove_child(&mut self, needle: TileId) -> Option {
378 |         let index = self.children.iter().position(|&child| child == needle)?;
379 |         self.children.remove(index);
380 |         Some(index)
381 |     }
382 | }
383 | 
384 | #[allow(clippy::too_many_arguments)]
385 | fn resize_interaction(
386 |     behavior: &mut dyn Behavior,
387 |     shares: &mut Shares,
388 |     children: &[TileId],
389 |     splitter_response: &egui::Response,
390 |     [left, right]: [TileId; 2],
391 |     dx: f32,
392 |     i: usize,
393 |     tile_width: impl Fn(TileId) -> f32,
394 | ) -> ResizeState {
395 |     if splitter_response.double_clicked() {
396 |         behavior.on_edit(EditAction::TileResized);
397 | 
398 |         // double-click to center the split between left and right:
399 |         let mean = 0.5 * (shares[left] + shares[right]);
400 |         shares[left] = mean;
401 |         shares[right] = mean;
402 |         ResizeState::Hovering
403 |     } else if splitter_response.dragged() {
404 |         behavior.on_edit(EditAction::TileResized);
405 | 
406 |         if dx < 0.0 {
407 |             // Expand right, shrink stuff to the left:
408 |             shares[right] += shrink_shares(
409 |                 behavior,
410 |                 shares,
411 |                 &children[0..=i].iter().copied().rev().collect_vec(),
412 |                 dx.abs(),
413 |                 tile_width,
414 |             );
415 |         } else {
416 |             // Expand the left, shrink stuff to the right:
417 |             shares[left] +=
418 |                 shrink_shares(behavior, shares, &children[i + 1..], dx.abs(), tile_width);
419 |         }
420 |         ResizeState::Dragging
421 |     } else if splitter_response.hovered() {
422 |         ResizeState::Hovering
423 |     } else {
424 |         ResizeState::Idle
425 |     }
426 | }
427 | 
428 | /// Try shrink the children by a total of `target_in_points`,
429 | /// making sure no child gets smaller than its minimum size.
430 | fn shrink_shares(
431 |     behavior: &dyn Behavior,
432 |     shares: &mut Shares,
433 |     children: &[TileId],
434 |     target_in_points: f32,
435 |     size_in_point: impl Fn(TileId) -> f32,
436 | ) -> f32 {
437 |     if children.is_empty() {
438 |         return 0.0;
439 |     }
440 | 
441 |     let mut total_shares = 0.0;
442 |     let mut total_points = 0.0;
443 |     for &child in children {
444 |         total_shares += shares[child];
445 |         total_points += size_in_point(child);
446 |     }
447 | 
448 |     let shares_per_point = total_shares / total_points;
449 | 
450 |     let min_size_in_shares = shares_per_point * behavior.min_size();
451 | 
452 |     let target_in_shares = shares_per_point * target_in_points;
453 |     let mut total_shares_lost = 0.0;
454 | 
455 |     for &child in children {
456 |         let share = &mut shares[child];
457 |         let spare_share = (*share - min_size_in_shares).at_least(0.0);
458 |         let shares_needed = (target_in_shares - total_shares_lost).at_least(0.0);
459 |         let shrink_by = f32::min(spare_share, shares_needed);
460 | 
461 |         *share -= shrink_by;
462 |         total_shares_lost += shrink_by;
463 |     }
464 | 
465 |     total_shares_lost
466 | }
467 | 
468 | fn linear_drop_zones(
469 |     egui_ctx: &egui::Context,
470 |     tree: &Tree,
471 |     children: &[TileId],
472 |     dir: LinearDir,
473 |     add_drop_drect: impl FnMut(Rect, usize),
474 | ) {
475 |     let preview_thickness = 12.0;
476 |     let dragged_index = children
477 |         .iter()
478 |         .position(|&child| is_being_dragged(egui_ctx, tree.id, child));
479 | 
480 |     let after_rect = |rect: Rect| match dir {
481 |         LinearDir::Horizontal => Rect::from_min_max(
482 |             rect.right_top() - vec2(preview_thickness, 0.0),
483 |             rect.right_bottom(),
484 |         ),
485 |         LinearDir::Vertical => Rect::from_min_max(
486 |             rect.left_bottom() - vec2(0.0, preview_thickness),
487 |             rect.right_bottom(),
488 |         ),
489 |     };
490 | 
491 |     drop_zones(
492 |         preview_thickness,
493 |         children,
494 |         dragged_index,
495 |         dir,
496 |         |tile_id| tree.tiles.rect(tile_id),
497 |         add_drop_drect,
498 |         after_rect,
499 |     );
500 | }
501 | 
502 | /// Register drop-zones for a linear container.
503 | ///
504 | /// `get_rect`: return `None` for invisible tiles.
505 | pub(super) fn drop_zones(
506 |     preview_thickness: f32,
507 |     children: &[TileId],
508 |     dragged_index: Option,
509 |     dir: LinearDir,
510 |     get_rect: impl Fn(TileId) -> Option,
511 |     mut add_drop_drect: impl FnMut(Rect, usize),
512 |     after_rect: impl Fn(Rect) -> Rect,
513 | ) {
514 |     let before_rect = |rect: Rect| match dir {
515 |         LinearDir::Horizontal => Rect::from_min_max(
516 |             rect.left_top(),
517 |             rect.left_bottom() + vec2(preview_thickness, 0.0),
518 |         ),
519 |         LinearDir::Vertical => Rect::from_min_max(
520 |             rect.left_top(),
521 |             rect.right_top() + vec2(0.0, preview_thickness),
522 |         ),
523 |     };
524 |     let between_rects = |a: Rect, b: Rect| match dir {
525 |         LinearDir::Horizontal => Rect::from_center_size(
526 |             a.right_center().lerp(b.left_center(), 0.5),
527 |             vec2(preview_thickness, a.height()),
528 |         ),
529 |         LinearDir::Vertical => Rect::from_center_size(
530 |             a.center_bottom().lerp(b.center_top(), 0.5),
531 |             vec2(a.width(), preview_thickness),
532 |         ),
533 |     };
534 | 
535 |     let mut prev_rect: Option = None;
536 | 
537 |     for (i, &child) in children.iter().enumerate() {
538 |         let Some(rect) = get_rect(child) else {
539 |             // skip invisible child
540 |             continue;
541 |         };
542 | 
543 |         if Some(i) == dragged_index {
544 |             // Suggest hole as a drop-target:
545 |             add_drop_drect(rect, i);
546 |         } else if let Some(prev_rect) = prev_rect {
547 |             if Some(i - 1) != dragged_index {
548 |                 // Suggest dropping between the rects:
549 |                 add_drop_drect(between_rects(prev_rect, rect), i);
550 |             }
551 |         } else {
552 |             // Suggest dropping before the first child:
553 |             add_drop_drect(before_rect(rect), 0);
554 |         }
555 | 
556 |         prev_rect = Some(rect);
557 |     }
558 | 
559 |     if let Some(last_rect) = prev_rect {
560 |         // Suggest dropping after the last child (unless that's the one being dragged):
561 |         if dragged_index != Some(children.len() - 1) {
562 |             add_drop_drect(after_rect(last_rect), children.len());
563 |         }
564 |     }
565 | }
566 | 


--------------------------------------------------------------------------------
/src/container/mod.rs:
--------------------------------------------------------------------------------
  1 | use egui::Rect;
  2 | 
  3 | use crate::Tree;
  4 | 
  5 | use super::{Behavior, DropContext, SimplifyAction, TileId, Tiles};
  6 | 
  7 | mod grid;
  8 | mod linear;
  9 | mod tabs;
 10 | 
 11 | pub use grid::{Grid, GridLayout};
 12 | pub use linear::{Linear, LinearDir, Shares};
 13 | pub use tabs::Tabs;
 14 | 
 15 | // ----------------------------------------------------------------------------
 16 | 
 17 | /// The layout type of a [`Container`].
 18 | ///
 19 | /// This is used to describe a [`Container`], and to change it to a different layout type.
 20 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
 21 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
 22 | pub enum ContainerKind {
 23 |     /// Each child in an individual tab.
 24 |     #[default]
 25 |     Tabs,
 26 | 
 27 |     /// Left-to-right
 28 |     Horizontal,
 29 | 
 30 |     /// Top-down
 31 |     Vertical,
 32 | 
 33 |     /// In a grid, laied out row-wise, left-to-right, top-down.
 34 |     Grid,
 35 | }
 36 | 
 37 | impl ContainerKind {
 38 |     pub const ALL: [Self; 4] = [Self::Tabs, Self::Horizontal, Self::Vertical, Self::Grid];
 39 | }
 40 | 
 41 | // ----------------------------------------------------------------------------
 42 | 
 43 | /// A container of several [`super::Tile`]s.
 44 | #[derive(Clone, Debug, PartialEq)]
 45 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
 46 | pub enum Container {
 47 |     Tabs(Tabs),
 48 |     Linear(Linear),
 49 |     Grid(Grid),
 50 | }
 51 | 
 52 | impl From for Container {
 53 |     #[inline]
 54 |     fn from(tabs: Tabs) -> Self {
 55 |         Self::Tabs(tabs)
 56 |     }
 57 | }
 58 | 
 59 | impl From for Container {
 60 |     #[inline]
 61 |     fn from(linear: Linear) -> Self {
 62 |         Self::Linear(linear)
 63 |     }
 64 | }
 65 | 
 66 | impl From for Container {
 67 |     #[inline]
 68 |     fn from(grid: Grid) -> Self {
 69 |         Self::Grid(grid)
 70 |     }
 71 | }
 72 | 
 73 | impl Container {
 74 |     pub fn new(kind: ContainerKind, children: Vec) -> Self {
 75 |         match kind {
 76 |             ContainerKind::Tabs => Self::new_tabs(children),
 77 |             ContainerKind::Horizontal => Self::new_horizontal(children),
 78 |             ContainerKind::Vertical => Self::new_vertical(children),
 79 |             ContainerKind::Grid => Self::new_grid(children),
 80 |         }
 81 |     }
 82 | 
 83 |     pub fn new_linear(dir: LinearDir, children: Vec) -> Self {
 84 |         Self::Linear(Linear::new(dir, children))
 85 |     }
 86 | 
 87 |     pub fn new_horizontal(children: Vec) -> Self {
 88 |         Self::new_linear(LinearDir::Horizontal, children)
 89 |     }
 90 | 
 91 |     pub fn new_vertical(children: Vec) -> Self {
 92 |         Self::new_linear(LinearDir::Vertical, children)
 93 |     }
 94 | 
 95 |     pub fn new_tabs(children: Vec) -> Self {
 96 |         Self::Tabs(Tabs::new(children))
 97 |     }
 98 | 
 99 |     pub fn new_grid(children: Vec) -> Self {
100 |         Self::Grid(Grid::new(children))
101 |     }
102 | 
103 |     pub fn is_empty(&self) -> bool {
104 |         self.num_children() == 0
105 |     }
106 | 
107 |     pub fn num_children(&self) -> usize {
108 |         match self {
109 |             Self::Tabs(tabs) => tabs.children.len(),
110 |             Self::Linear(linear) => linear.children.len(),
111 |             Self::Grid(grid) => grid.num_children(),
112 |         }
113 |     }
114 | 
115 |     /// All the childrens of this container.
116 |     pub fn children(&self) -> impl Iterator {
117 |         match self {
118 |             Self::Tabs(tabs) => itertools::Either::Left(tabs.children.iter()),
119 |             Self::Linear(linear) => itertools::Either::Left(linear.children.iter()),
120 |             Self::Grid(grid) => itertools::Either::Right(grid.children()),
121 |         }
122 |     }
123 | 
124 |     /// All the active childrens of this container.
125 |     ///
126 |     /// For tabs, this is just the active tab.
127 |     /// For other containers, it is all children.
128 |     pub fn active_children(&self) -> impl Iterator {
129 |         match self {
130 |             Self::Tabs(tabs) => {
131 |                 itertools::Either::Left(itertools::Either::Left(tabs.active.iter()))
132 |             }
133 |             Self::Linear(linear) => {
134 |                 itertools::Either::Left(itertools::Either::Right(linear.children.iter()))
135 |             }
136 |             Self::Grid(grid) => itertools::Either::Right(grid.children()),
137 |         }
138 |     }
139 | 
140 |     /// If we have exactly one child, return it
141 |     pub fn only_child(&self) -> Option {
142 |         let mut only_child = None;
143 |         for &child in self.children() {
144 |             if only_child.is_none() {
145 |                 only_child = Some(child);
146 |             } else {
147 |                 return None;
148 |             }
149 |         }
150 |         only_child
151 |     }
152 | 
153 |     pub fn children_vec(&self) -> Vec {
154 |         self.children().copied().collect()
155 |     }
156 | 
157 |     pub fn has_child(&self, needle: TileId) -> bool {
158 |         self.children().any(|&t| t == needle)
159 |     }
160 | 
161 |     pub fn add_child(&mut self, child: TileId) {
162 |         match self {
163 |             Self::Tabs(tabs) => tabs.add_child(child),
164 |             Self::Linear(linear) => linear.add_child(child),
165 |             Self::Grid(grid) => grid.add_child(child),
166 |         }
167 |     }
168 | 
169 |     /// Iterate through all children in order, and keep only those for which the closure returns `true`.
170 |     pub fn retain(&mut self, mut retain: impl FnMut(TileId) -> bool) {
171 |         match self {
172 |             Self::Tabs(tabs) => tabs.children.retain(|tile_id: &TileId| retain(*tile_id)),
173 |             Self::Linear(linear) => linear.children.retain(|tile_id: &TileId| retain(*tile_id)),
174 |             Self::Grid(grid) => grid.retain(retain),
175 |         }
176 |     }
177 | 
178 |     /// Returns child index, if found.
179 |     pub fn remove_child(&mut self, child: TileId) -> Option {
180 |         match self {
181 |             Self::Tabs(tabs) => tabs.remove_child(child),
182 |             Self::Linear(linear) => linear.remove_child(child),
183 |             Self::Grid(grid) => grid.remove_child(child),
184 |         }
185 |     }
186 | 
187 |     pub fn kind(&self) -> ContainerKind {
188 |         match self {
189 |             Self::Tabs(_) => ContainerKind::Tabs,
190 |             Self::Linear(linear) => match linear.dir {
191 |                 LinearDir::Horizontal => ContainerKind::Horizontal,
192 |                 LinearDir::Vertical => ContainerKind::Vertical,
193 |             },
194 |             Self::Grid(_) => ContainerKind::Grid,
195 |         }
196 |     }
197 | 
198 |     pub fn set_kind(&mut self, kind: ContainerKind) {
199 |         if kind == self.kind() {
200 |             return;
201 |         }
202 | 
203 |         *self = match kind {
204 |             ContainerKind::Tabs => Self::Tabs(Tabs::new(self.children_vec())),
205 |             ContainerKind::Horizontal => {
206 |                 Self::Linear(Linear::new(LinearDir::Horizontal, self.children_vec()))
207 |             }
208 |             ContainerKind::Vertical => {
209 |                 Self::Linear(Linear::new(LinearDir::Vertical, self.children_vec()))
210 |             }
211 |             ContainerKind::Grid => Self::Grid(Grid::new(self.children_vec())),
212 |         };
213 |     }
214 | 
215 |     pub(super) fn simplify_children(&mut self, simplify: impl FnMut(TileId) -> SimplifyAction) {
216 |         match self {
217 |             Self::Tabs(tabs) => tabs.simplify_children(simplify),
218 |             Self::Linear(linear) => linear.simplify_children(simplify),
219 |             Self::Grid(grid) => grid.simplify_children(simplify),
220 |         }
221 |     }
222 | 
223 |     pub(super) fn layout(
224 |         &mut self,
225 |         tiles: &mut Tiles,
226 |         style: &egui::Style,
227 |         behavior: &mut dyn Behavior,
228 |         rect: Rect,
229 |     ) {
230 |         if self.is_empty() {
231 |             return;
232 |         }
233 | 
234 |         match self {
235 |             Self::Tabs(tabs) => tabs.layout(tiles, style, behavior, rect),
236 |             Self::Linear(linear) => {
237 |                 linear.layout(tiles, style, behavior, rect);
238 |             }
239 |             Self::Grid(grid) => grid.layout(tiles, style, behavior, rect),
240 |         }
241 |     }
242 | 
243 |     pub(super) fn ui(
244 |         &mut self,
245 |         tree: &mut Tree,
246 |         behavior: &mut dyn Behavior,
247 |         drop_context: &mut DropContext,
248 |         ui: &mut egui::Ui,
249 |         rect: Rect,
250 |         tile_id: TileId,
251 |     ) {
252 |         match self {
253 |             Self::Tabs(tabs) => {
254 |                 tabs.ui(tree, behavior, drop_context, ui, rect, tile_id);
255 |             }
256 |             Self::Linear(linear) => {
257 |                 linear.ui(tree, behavior, drop_context, ui, tile_id);
258 |             }
259 |             Self::Grid(grid) => {
260 |                 grid.ui(tree, behavior, drop_context, ui, tile_id);
261 |             }
262 |         }
263 |     }
264 | }
265 | 


--------------------------------------------------------------------------------
/src/container/tabs.rs:
--------------------------------------------------------------------------------
  1 | use egui::{scroll_area::ScrollBarVisibility, vec2, NumExt, Rect, Vec2};
  2 | 
  3 | use crate::behavior::{EditAction, TabState};
  4 | use crate::{
  5 |     is_being_dragged, Behavior, ContainerInsertion, DropContext, InsertionPoint, SimplifyAction,
  6 |     TileId, Tiles, Tree,
  7 | };
  8 | 
  9 | /// Fixed size icons for `⏴` and `⏵`
 10 | const SCROLL_ARROW_SIZE: Vec2 = Vec2::splat(20.0);
 11 | 
 12 | /// A container with tabs. Only one tab is open (active) at a time.
 13 | #[derive(Clone, Debug, Default, PartialEq, Eq)]
 14 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
 15 | pub struct Tabs {
 16 |     /// The tabs, in order.
 17 |     pub children: Vec,
 18 | 
 19 |     /// The currently open tab.
 20 |     pub active: Option,
 21 | }
 22 | 
 23 | /// The current tab scrolling state
 24 | #[derive(Clone, Copy, Debug, Default)]
 25 | struct ScrollState {
 26 |     /// The current horizontal scroll offset.
 27 |     ///
 28 |     /// Positive: scroll right.
 29 |     /// Negatie: scroll left.
 30 |     pub offset: f32,
 31 | 
 32 |     /// Outstanding offset to apply smoothly over the next few frames.
 33 |     /// This is what the buttons update.
 34 |     pub offset_debt: f32,
 35 | 
 36 |     /// The size of all the tabs last frame.
 37 |     pub content_size: Vec2,
 38 | 
 39 |     /// The available size for the tabs.
 40 |     pub available: Vec2,
 41 | 
 42 |     /// Show the left scroll-arrow this frame?
 43 |     pub show_left_arrow: bool,
 44 | 
 45 |     /// Show the right scroll-arrow this frame?
 46 |     pub show_right_arrow: bool,
 47 | 
 48 |     /// Did we show the left scroll-arrow last frame?
 49 |     pub showed_left_arrow_prev: bool,
 50 | 
 51 |     /// Did we show the right scroll-arrow last frame?
 52 |     pub showed_right_arrow_prev: bool,
 53 | }
 54 | 
 55 | impl ScrollState {
 56 |     /// Returns the space left for the tabs after the scroll arrows.
 57 |     pub fn update(&mut self, ui: &egui::Ui) -> f32 {
 58 |         let mut scroll_area_width = ui.available_width();
 59 | 
 60 |         let button_and_spacing_width = SCROLL_ARROW_SIZE.x + ui.spacing().item_spacing.x;
 61 | 
 62 |         let margin = 0.1;
 63 | 
 64 |         self.show_left_arrow = SCROLL_ARROW_SIZE.x < self.offset;
 65 | 
 66 |         if self.show_left_arrow {
 67 |             scroll_area_width -= button_and_spacing_width;
 68 |         }
 69 | 
 70 |         self.show_right_arrow = self.offset + scroll_area_width + margin < self.content_size.x;
 71 | 
 72 |         // Compensate for showing/hiding of arrow:
 73 |         self.offset += button_and_spacing_width
 74 |             * ((self.show_left_arrow as i32 as f32) - (self.showed_left_arrow_prev as i32 as f32));
 75 | 
 76 |         if self.show_right_arrow {
 77 |             scroll_area_width -= button_and_spacing_width;
 78 |         }
 79 | 
 80 |         self.showed_left_arrow_prev = self.show_left_arrow;
 81 |         self.showed_right_arrow_prev = self.show_right_arrow;
 82 | 
 83 |         if self.offset_debt != 0.0 {
 84 |             const SPEED: f32 = 500.0;
 85 | 
 86 |             let dt = ui.input(|i| i.stable_dt).min(0.1);
 87 |             let max_movement = dt * SPEED;
 88 |             if self.offset_debt.abs() <= max_movement {
 89 |                 self.offset += self.offset_debt;
 90 |                 self.offset_debt = 0.0;
 91 |             } else {
 92 |                 let movement = self.offset_debt.signum() * max_movement;
 93 |                 self.offset += movement;
 94 |                 self.offset_debt -= movement;
 95 |                 ui.ctx().request_repaint();
 96 |             }
 97 |         }
 98 | 
 99 |         scroll_area_width
100 |     }
101 | 
102 |     fn scroll_increment(&self) -> f32 {
103 |         (self.available.x / 3.0).at_least(20.0)
104 |     }
105 | 
106 |     pub fn left_arrow(&mut self, ui: &mut egui::Ui) {
107 |         if !self.show_left_arrow {
108 |             return;
109 |         }
110 | 
111 |         if ui
112 |             .add_sized(SCROLL_ARROW_SIZE, egui::Button::new("⏴"))
113 |             .clicked()
114 |         {
115 |             self.offset_debt -= self.scroll_increment();
116 |         }
117 |     }
118 | 
119 |     pub fn right_arrow(&mut self, ui: &mut egui::Ui) {
120 |         if !self.show_right_arrow {
121 |             return;
122 |         }
123 | 
124 |         if ui
125 |             .add_sized(SCROLL_ARROW_SIZE, egui::Button::new("⏵"))
126 |             .clicked()
127 |         {
128 |             self.offset_debt += self.scroll_increment();
129 |         }
130 |     }
131 | }
132 | 
133 | impl Tabs {
134 |     pub fn new(children: Vec) -> Self {
135 |         let active = children.first().copied();
136 |         Self { children, active }
137 |     }
138 | 
139 |     pub fn add_child(&mut self, child: TileId) {
140 |         self.children.push(child);
141 |     }
142 | 
143 |     pub fn set_active(&mut self, child: TileId) {
144 |         self.active = Some(child);
145 |     }
146 | 
147 |     pub fn is_active(&self, child: TileId) -> bool {
148 |         Some(child) == self.active
149 |     }
150 | 
151 |     pub(super) fn layout(
152 |         &mut self,
153 |         tiles: &mut Tiles,
154 |         style: &egui::Style,
155 |         behavior: &mut dyn Behavior,
156 |         rect: Rect,
157 |     ) {
158 |         let prev_active = self.active;
159 |         self.ensure_active(tiles);
160 |         if prev_active != self.active {
161 |             behavior.on_edit(EditAction::TabSelected);
162 |         }
163 | 
164 |         let mut active_rect = rect;
165 |         active_rect.min.y += behavior.tab_bar_height(style);
166 | 
167 |         if let Some(active) = self.active {
168 |             // Only lay out the active tab (saves CPU):
169 |             tiles.layout_tile(style, behavior, active_rect, active);
170 |         }
171 |     }
172 | 
173 |     /// Make sure we have an active tab (or no visible tabs).
174 |     pub fn ensure_active(&mut self, tiles: &Tiles) {
175 |         if let Some(active) = self.active {
176 |             if !tiles.is_visible(active) {
177 |                 self.active = None;
178 |             }
179 |         }
180 | 
181 |         if !self.children.iter().any(|&child| self.is_active(child)) {
182 |             // Make sure something is active:
183 |             self.active = self
184 |                 .children
185 |                 .iter()
186 |                 .copied()
187 |                 .find(|&child_id| tiles.is_visible(child_id));
188 |         }
189 |     }
190 | 
191 |     pub(super) fn ui(
192 |         &mut self,
193 |         tree: &mut Tree,
194 |         behavior: &mut dyn Behavior,
195 |         drop_context: &mut DropContext,
196 |         ui: &mut egui::Ui,
197 |         rect: Rect,
198 |         tile_id: TileId,
199 |     ) {
200 |         let next_active = self.tab_bar_ui(tree, behavior, ui, rect, drop_context, tile_id);
201 | 
202 |         if let Some(active) = self.active {
203 |             tree.tile_ui(behavior, drop_context, ui, active);
204 |             crate::cover_tile_if_dragged(tree, behavior, ui, active);
205 |         }
206 | 
207 |         // We have only laid out the active tab, so we need to switch active tab _after_ the ui pass above:
208 |         self.active = next_active;
209 |     }
210 | 
211 |     /// Returns the next active tab (e.g. the one clicked, or the current).
212 |     #[allow(clippy::too_many_lines)]
213 |     fn tab_bar_ui(
214 |         &self,
215 |         tree: &mut Tree,
216 |         behavior: &mut dyn Behavior,
217 |         ui: &mut egui::Ui,
218 |         rect: Rect,
219 |         drop_context: &mut DropContext,
220 |         tile_id: TileId,
221 |     ) -> Option {
222 |         let mut next_active = self.active;
223 | 
224 |         let tab_bar_height = behavior.tab_bar_height(ui.style());
225 |         let tab_bar_rect = rect.split_top_bottom_at_y(rect.top() + tab_bar_height).0;
226 |         let mut ui = ui.new_child(egui::UiBuilder::new().max_rect(tab_bar_rect));
227 | 
228 |         let mut button_rects = ahash::HashMap::default();
229 |         let mut dragged_index = None;
230 | 
231 |         ui.painter()
232 |             .rect_filled(ui.max_rect(), 0.0, behavior.tab_bar_color(ui.visuals()));
233 | 
234 |         ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
235 |             let scroll_state_id = ui.make_persistent_id(tile_id);
236 |             let mut scroll_state = ui.ctx().memory_mut(|m| {
237 |                 m.data
238 |                     .get_temp::(scroll_state_id)
239 |                     .unwrap_or_default()
240 |             });
241 | 
242 |             // Allow user to add buttons such as "add new tab".
243 |             // They can also read and modify the scroll state if they want.
244 |             behavior.top_bar_right_ui(&tree.tiles, ui, tile_id, self, &mut scroll_state.offset);
245 | 
246 |             let scroll_area_width = scroll_state.update(ui);
247 | 
248 |             // We're in a right-to-left layout, so start with the right scroll-arrow:
249 |             scroll_state.right_arrow(ui);
250 | 
251 |             ui.allocate_ui_with_layout(
252 |                 ui.available_size(),
253 |                 egui::Layout::left_to_right(egui::Align::Center),
254 |                 |ui| {
255 |                     scroll_state.left_arrow(ui);
256 | 
257 |                     // Prepare to show the scroll area with the tabs:
258 | 
259 |                     scroll_state.offset = scroll_state
260 |                         .offset
261 |                         .at_most(scroll_state.content_size.x - ui.available_width());
262 |                     scroll_state.offset = scroll_state.offset.at_least(0.0);
263 | 
264 |                     let scroll_area = egui::ScrollArea::horizontal()
265 |                         .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden)
266 |                         .max_width(scroll_area_width)
267 |                         .auto_shrink([false; 2])
268 |                         .horizontal_scroll_offset(scroll_state.offset);
269 | 
270 |                     let output = scroll_area.show(ui, |ui| {
271 |                         if !tree.is_root(tile_id) {
272 |                             // Make the background behind the buttons draggable (to drag the parent container tile).
273 |                             // We also sense clicks to avoid eager-dragging on mouse-down.
274 |                             let sense = egui::Sense::click_and_drag();
275 |                             if ui
276 |                                 .interact(ui.max_rect(), ui.id().with("background"), sense)
277 |                                 .on_hover_cursor(egui::CursorIcon::Grab)
278 |                                 .drag_started()
279 |                             {
280 |                                 behavior.on_edit(EditAction::TileDragged);
281 |                                 ui.ctx().set_dragged_id(tile_id.egui_id(tree.id));
282 |                             }
283 |                         }
284 | 
285 |                         ui.spacing_mut().item_spacing.x = 0.0; // Tabs have spacing built-in
286 | 
287 |                         for (i, &child_id) in self.children.iter().enumerate() {
288 |                             if !tree.is_visible(child_id) {
289 |                                 continue;
290 |                             }
291 | 
292 |                             let is_being_dragged = is_being_dragged(ui.ctx(), tree.id, child_id);
293 | 
294 |                             let selected = self.is_active(child_id);
295 |                             let id = child_id.egui_id(tree.id);
296 |                             let tab_state = TabState {
297 |                                 active: selected,
298 |                                 is_being_dragged,
299 |                                 closable: behavior.is_tab_closable(&tree.tiles, child_id),
300 |                             };
301 | 
302 |                             let response =
303 |                                 behavior.tab_ui(&mut tree.tiles, ui, id, child_id, &tab_state);
304 | 
305 |                             if response.clicked() {
306 |                                 behavior.on_edit(EditAction::TabSelected);
307 |                                 next_active = Some(child_id);
308 |                             }
309 | 
310 |                             if let Some(mouse_pos) = drop_context.mouse_pos {
311 |                                 if drop_context.dragged_tile_id.is_some()
312 |                                     && response.rect.contains(mouse_pos)
313 |                                 {
314 |                                     // Expand this tab - maybe the user wants to drop something into it!
315 |                                     behavior.on_edit(EditAction::TabSelected);
316 |                                     next_active = Some(child_id);
317 |                                 }
318 |                             }
319 | 
320 |                             button_rects.insert(child_id, response.rect);
321 |                             if is_being_dragged {
322 |                                 dragged_index = Some(i);
323 |                             }
324 |                         }
325 |                     });
326 | 
327 |                     scroll_state.offset = output.state.offset.x;
328 |                     scroll_state.content_size = output.content_size;
329 |                     scroll_state.available = output.inner_rect.size();
330 |                 },
331 |             );
332 | 
333 |             ui.ctx()
334 |                 .data_mut(|data| data.insert_temp(scroll_state_id, scroll_state));
335 |         });
336 | 
337 |         // -----------
338 |         // Drop zones:
339 | 
340 |         let preview_thickness = 6.0;
341 |         let after_rect = |rect: Rect| {
342 |             let dragged_size = if let Some(dragged_index) = dragged_index {
343 |                 // We actually know the size of this thing
344 |                 button_rects[&self.children[dragged_index]].size()
345 |             } else {
346 |                 rect.size() // guess that the size is the same as the last button
347 |             };
348 |             Rect::from_min_size(
349 |                 rect.right_top() + vec2(ui.spacing().item_spacing.x, 0.0),
350 |                 dragged_size,
351 |             )
352 |         };
353 |         super::linear::drop_zones(
354 |             preview_thickness,
355 |             &self.children,
356 |             dragged_index,
357 |             super::LinearDir::Horizontal,
358 |             |tile_id| button_rects.get(&tile_id).copied(),
359 |             |rect, i| {
360 |                 drop_context.suggest_rect(
361 |                     InsertionPoint::new(tile_id, ContainerInsertion::Tabs(i)),
362 |                     rect,
363 |                 );
364 |             },
365 |             after_rect,
366 |         );
367 | 
368 |         next_active
369 |     }
370 | 
371 |     pub(super) fn simplify_children(&mut self, mut simplify: impl FnMut(TileId) -> SimplifyAction) {
372 |         self.children.retain_mut(|child| match simplify(*child) {
373 |             SimplifyAction::Remove => false,
374 |             SimplifyAction::Keep => true,
375 |             SimplifyAction::Replace(new) => {
376 |                 if self.active == Some(*child) {
377 |                     self.active = Some(new);
378 |                 }
379 |                 *child = new;
380 |                 true
381 |             }
382 |         });
383 |     }
384 | 
385 |     /// Returns child index, if found.
386 |     pub(crate) fn remove_child(&mut self, needle: TileId) -> Option {
387 |         let index = self.children.iter().position(|&child| child == needle)?;
388 |         self.children.remove(index);
389 |         Some(index)
390 |     }
391 | }
392 | 


--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
  1 | //! # [egui](https://github.com/emilk/egui) hierarchial tile manager
  2 | //! Tiles that can be arranges in horizontal, vertical, and grid-layouts, or put in tabs.
  3 | //! The tiles can be resized and re-arranged by drag-and-drop.
  4 | //!
  5 | //! ## Overview
  6 | //! The fundamental unit is the [`Tile`] which is either a [`Container`] or a `Pane` (a leaf).
  7 | //! The [`Tile`]s are put into a [`Tree`].
  8 | //! Everything is generic over the type of panes, leaving up to the user what to store in the tree.
  9 | //!
 10 | //! Each [`Tile`] is identified by a (random) [`TileId`].
 11 | //! The tiles are stored in [`Tiles`].
 12 | //!
 13 | //! The entire state is stored in a single [`Tree`] struct which consists of a [`Tiles`] and a root [`TileId`].
 14 | //!
 15 | //! The behavior and the look of the [`Tree`] is controlled by the [`Behavior`] `trait`.
 16 | //! The user needs to implement this in order to specify the `ui` of each `Pane` and
 17 | //! the tab name of panes (if there are tab tiles).
 18 | //!
 19 | //! ## Example
 20 | //! See [`Tree`] for how to construct a tree.
 21 | //!
 22 | //! ```
 23 | //! // This specifies how you want to represent your panes in memory.
 24 | //! // Implementing serde is optional, but will make the entire tree serializable.
 25 | //! #[derive(serde::Serialize, serde::Deserialize)]
 26 | //! enum Pane {
 27 | //!     Settings,
 28 | //!     Text(String),
 29 | //! }
 30 | //!
 31 | //! fn tree_ui(ui: &mut egui::Ui, tree: &mut egui_tiles::Tree, settings: &mut Settings) {
 32 | //!     let mut behavior = MyBehavior { settings };
 33 | //!     tree.ui(&mut behavior, ui);
 34 | //! }
 35 | //!
 36 | //! struct MyBehavior<'a> {
 37 | //!     settings: &'a mut Settings
 38 | //! }
 39 | //!
 40 | //! impl<'a> egui_tiles::Behavior for MyBehavior<'a> {
 41 | //!     fn tab_title_for_pane(&mut self, pane: &Pane) -> egui::WidgetText {
 42 | //!         match pane {
 43 | //!             Pane::Settings => "Settings".into(),
 44 | //!             Pane::Text(text) => text.clone().into(),
 45 | //!         }
 46 | //!     }
 47 | //!
 48 | //!     fn pane_ui(
 49 | //!         &mut self,
 50 | //!         ui: &mut egui::Ui,
 51 | //!         _tile_id: egui_tiles::TileId,
 52 | //!         pane: &mut Pane,
 53 | //!     ) -> egui_tiles::UiResponse {
 54 | //!         match pane {
 55 | //!             Pane::Settings => self.settings.ui(ui),
 56 | //!             Pane::Text(text) => {
 57 | //!                 ui.text_edit_singleline(text);
 58 | //!             },
 59 | //!         }
 60 | //!
 61 | //!         Default::default()
 62 | //!     }
 63 | //!
 64 | //!     // you can override more methods to customize the behavior further
 65 | //! }
 66 | //!
 67 | //! struct Settings {
 68 | //!     checked: bool,
 69 | //! }
 70 | //!
 71 | //! impl Settings {
 72 | //!     fn ui(&mut self, ui: &mut egui::Ui) {
 73 | //!         ui.checkbox(&mut self.checked, "Checked");
 74 | //!     }
 75 | //! }
 76 | //! ```
 77 | //!
 78 | //! ## Invisible tiles
 79 | //! Tiles can be made invisible with [`Tree::set_visible`] and [`Tiles::set_visible`].
 80 | //! Invisible tiles still retain their ordering in the container their in until
 81 | //! they are made visible again.
 82 | //!
 83 | //! ## Shares
 84 | //! The relative sizes of linear layout (horizontal or vertical) and grid columns and rows are specified by _shares_.
 85 | //! If the shares are `1,2,3` it means the first element gets `1/6` of the space, the second `2/6`, and the third `3/6`.
 86 | //! The default share size is `1`, and when resizing the shares are restributed so that
 87 | //! the total shares are always approximately the same as the number of rows/columns.
 88 | //! This makes it easy to add new rows/columns.
 89 | //!
 90 | //! ## Shortcomings
 91 | //! The implementation is recursive, so if your trees get too deep you will get a stack overflow.
 92 | //!
 93 | //! ## Future improvements
 94 | //! * Easy per-tab close-buttons
 95 | //! * Scrolling of tab-bar
 96 | //! * Vertical tab bar
 97 | 
 98 | // ## Implementation notes
 99 | // In many places we want to recursively visit all tiles, while also mutating them.
100 | // In order to not get into trouble with the borrow checker a trick is used:
101 | // each [`Tile`] is removed, mutated, recursed, and then re-added.
102 | // You'll see this pattern many times reading the following code.
103 | //
104 | // Each frame consists of two passes: layout, and ui.
105 | // The layout pass figures out where each tile should be placed.
106 | // The ui pass does all the painting.
107 | // These two passes could be combined into one pass if we wanted to,
108 | // but having them split up makes the code slightly simpler, and
109 | // leaves the door open for more complex layout (e.g. min/max sizes per tile).
110 | //
111 | // Everything is quite dynamic, so we have a bunch of defensive coding that call `warn!` on failure.
112 | // These situations should not happen in normal use, but could happen if the user messes with
113 | // the internals of the tree, putting it in an invalid state.
114 | 
115 | #![forbid(unsafe_code)]
116 | 
117 | use egui::{Pos2, Rect};
118 | 
119 | mod behavior;
120 | mod container;
121 | mod tile;
122 | mod tiles;
123 | mod tree;
124 | 
125 | pub use behavior::{Behavior, EditAction, TabState};
126 | pub use container::{Container, ContainerKind, Grid, GridLayout, Linear, LinearDir, Shares, Tabs};
127 | pub use tile::{Tile, TileId};
128 | pub use tiles::Tiles;
129 | pub use tree::Tree;
130 | 
131 | // ----------------------------------------------------------------------------
132 | 
133 | /// The response from [`Behavior::pane_ui`] for a pane.
134 | #[must_use]
135 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
136 | pub enum UiResponse {
137 |     #[default]
138 |     None,
139 | 
140 |     /// The viewer is being dragged via some element in the Pane
141 |     DragStarted,
142 | }
143 | 
144 | /// What are the rules for simplifying the tree?
145 | ///
146 | /// Drag-dropping tiles can often leave containers empty, or with only a single child.
147 | /// The [`SimplificationOptions`] specifies what simplifications are allowed.
148 | ///
149 | /// The [`Tree`] will run a simplification pass each frame.
150 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
151 | pub struct SimplificationOptions {
152 |     /// Remove empty [`Tabs`] containers?
153 |     pub prune_empty_tabs: bool,
154 | 
155 |     /// Remove empty containers (that aren't [`Tabs`])?
156 |     pub prune_empty_containers: bool,
157 | 
158 |     /// Remove [`Tabs`] containers with only a single child?
159 |     ///
160 |     /// Even if `true`, [`Self::all_panes_must_have_tabs`] will be respected.
161 |     pub prune_single_child_tabs: bool,
162 | 
163 |     /// Prune containers (that aren't [`Tabs`]) with only a single child?
164 |     pub prune_single_child_containers: bool,
165 | 
166 |     /// If true, each pane will have a [`Tabs`] container as a parent.
167 |     ///
168 |     /// This will win out over [`Self::prune_single_child_tabs`].
169 |     pub all_panes_must_have_tabs: bool,
170 | 
171 |     /// If a horizontal container contain another horizontal container, join them?
172 |     /// Same for vertical containers. Does NOT apply to grid container or tab containers.
173 |     pub join_nested_linear_containers: bool,
174 | }
175 | 
176 | impl SimplificationOptions {
177 |     /// [`SimplificationOptions`] with all simplifications turned off.
178 |     ///
179 |     /// This makes it easy to run a single simplification type on a tree:
180 |     /// ```
181 |     /// # use egui_tiles::*;
182 |     /// # let mut tree: Tree<()> = Tree::empty("tree");
183 |     /// tree.simplify(&SimplificationOptions {
184 |     ///     prune_empty_tabs: true,
185 |     ///     ..SimplificationOptions::OFF
186 |     /// });
187 |     ///
188 |     pub const OFF: Self = Self {
189 |         prune_empty_tabs: false,
190 |         prune_empty_containers: false,
191 |         prune_single_child_tabs: false,
192 |         prune_single_child_containers: false,
193 |         all_panes_must_have_tabs: false,
194 |         join_nested_linear_containers: false,
195 |     };
196 | }
197 | 
198 | impl Default for SimplificationOptions {
199 |     fn default() -> Self {
200 |         Self {
201 |             prune_empty_tabs: true,
202 |             prune_single_child_tabs: true,
203 |             prune_empty_containers: true,
204 |             prune_single_child_containers: true,
205 |             all_panes_must_have_tabs: false,
206 |             join_nested_linear_containers: true,
207 |         }
208 |     }
209 | }
210 | 
211 | /// The current state of a resize handle.
212 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
213 | pub enum ResizeState {
214 |     Idle,
215 | 
216 |     /// The user is hovering over the resize handle.
217 |     Hovering,
218 | 
219 |     /// The user is dragging the resize handle.
220 |     Dragging,
221 | }
222 | 
223 | // ----------------------------------------------------------------------------
224 | 
225 | /// An insertion point in a specific container.
226 | ///
227 | /// Specifies the expected container layout type, and where to insert.
228 | #[derive(Clone, Copy, Debug, PartialEq, Eq)]
229 | enum ContainerInsertion {
230 |     Tabs(usize),
231 |     Horizontal(usize),
232 |     Vertical(usize),
233 |     Grid(usize),
234 | }
235 | 
236 | impl ContainerInsertion {
237 |     /// Where in the parent (in what order among its children).
238 |     fn index(self) -> usize {
239 |         match self {
240 |             Self::Tabs(index)
241 |             | Self::Horizontal(index)
242 |             | Self::Vertical(index)
243 |             | Self::Grid(index) => index,
244 |         }
245 |     }
246 | 
247 |     fn kind(self) -> ContainerKind {
248 |         match self {
249 |             Self::Tabs(_) => ContainerKind::Tabs,
250 |             Self::Horizontal(_) => ContainerKind::Horizontal,
251 |             Self::Vertical(_) => ContainerKind::Vertical,
252 |             Self::Grid(_) => ContainerKind::Grid,
253 |         }
254 |     }
255 | }
256 | 
257 | /// Where in the tree to insert a tile.
258 | #[derive(Clone, Copy, Debug)]
259 | struct InsertionPoint {
260 |     pub parent_id: TileId,
261 | 
262 |     /// Where in the parent?
263 |     pub insertion: ContainerInsertion,
264 | }
265 | 
266 | impl InsertionPoint {
267 |     pub fn new(parent_id: TileId, insertion: ContainerInsertion) -> Self {
268 |         Self {
269 |             parent_id,
270 |             insertion,
271 |         }
272 |     }
273 | }
274 | 
275 | #[derive(PartialEq, Eq)]
276 | enum GcAction {
277 |     Keep,
278 |     Remove,
279 | }
280 | 
281 | #[must_use]
282 | enum SimplifyAction {
283 |     Remove,
284 |     Keep,
285 |     Replace(TileId),
286 | }
287 | 
288 | pub(crate) fn is_being_dragged(ctx: &egui::Context, tree_id: egui::Id, tile_id: TileId) -> bool {
289 |     let dragged_id = ctx.dragged_id().or(ctx.drag_stopped_id());
290 |     dragged_id == Some(tile_id.egui_id(tree_id))
291 | }
292 | 
293 | /// If this tile is currently being dragged, cover it with a semi-transparent overlay ([`Behavior::dragged_overlay_color`]).
294 | fn cover_tile_if_dragged(
295 |     tree: &Tree,
296 |     behavior: &dyn Behavior,
297 |     ui: &egui::Ui,
298 |     tile_id: TileId,
299 | ) {
300 |     if is_being_dragged(ui.ctx(), tree.id, tile_id) {
301 |         if let Some(child_rect) = tree.tiles.rect(tile_id) {
302 |             let overlay_color = behavior.dragged_overlay_color(ui.visuals());
303 |             ui.painter().rect_filled(child_rect, 0.0, overlay_color);
304 |         }
305 |     }
306 | }
307 | 
308 | // ----------------------------------------------------------------------------
309 | 
310 | /// Context used for drag-and-dropping of tiles.
311 | ///
312 | /// This is passed down during the `ui` pass.
313 | /// Each tile registers itself with this context.
314 | struct DropContext {
315 |     enabled: bool,
316 |     dragged_tile_id: Option,
317 |     mouse_pos: Option,
318 | 
319 |     best_insertion: Option,
320 |     best_dist_sq: f32,
321 |     preview_rect: Option,
322 | }
323 | 
324 | impl DropContext {
325 |     fn on_tile(
326 |         &mut self,
327 |         behavior: &dyn Behavior,
328 |         style: &egui::Style,
329 |         parent_id: TileId,
330 |         rect: Rect,
331 |         tile: &Tile,
332 |     ) {
333 |         if !self.enabled {
334 |             return;
335 |         }
336 | 
337 |         if tile.kind() != Some(ContainerKind::Horizontal) {
338 |             self.suggest_rect(
339 |                 InsertionPoint::new(parent_id, ContainerInsertion::Horizontal(0)),
340 |                 rect.split_left_right_at_fraction(0.5).0,
341 |             );
342 |             self.suggest_rect(
343 |                 InsertionPoint::new(parent_id, ContainerInsertion::Horizontal(usize::MAX)),
344 |                 rect.split_left_right_at_fraction(0.5).1,
345 |             );
346 |         }
347 | 
348 |         if tile.kind() != Some(ContainerKind::Vertical) {
349 |             self.suggest_rect(
350 |                 InsertionPoint::new(parent_id, ContainerInsertion::Vertical(0)),
351 |                 rect.split_top_bottom_at_fraction(0.5).0,
352 |             );
353 |             self.suggest_rect(
354 |                 InsertionPoint::new(parent_id, ContainerInsertion::Vertical(usize::MAX)),
355 |                 rect.split_top_bottom_at_fraction(0.5).1,
356 |             );
357 |         }
358 | 
359 |         self.suggest_rect(
360 |             InsertionPoint::new(parent_id, ContainerInsertion::Tabs(usize::MAX)),
361 |             rect.split_top_bottom_at_y(rect.top() + behavior.tab_bar_height(style))
362 |                 .1,
363 |         );
364 |     }
365 | 
366 |     fn suggest_rect(&mut self, insertion: InsertionPoint, preview_rect: Rect) {
367 |         if !self.enabled {
368 |             return;
369 |         }
370 |         let target_point = preview_rect.center();
371 |         if let Some(mouse_pos) = self.mouse_pos {
372 |             let dist_sq = mouse_pos.distance_sq(target_point);
373 |             if dist_sq < self.best_dist_sq {
374 |                 self.best_dist_sq = dist_sq;
375 |                 self.best_insertion = Some(insertion);
376 |                 self.preview_rect = Some(preview_rect);
377 |             }
378 |         }
379 |     }
380 | }
381 | 


--------------------------------------------------------------------------------
/src/tile.rs:
--------------------------------------------------------------------------------
 1 | use crate::{Container, ContainerKind};
 2 | 
 3 | /// An identifier for a [`Tile`] in the tree, be it a [`Container`] or a pane.
 4 | ///
 5 | /// This id is unique within the tree, but not across trees.
 6 | #[derive(Clone, Copy, Hash, PartialEq, Eq)]
 7 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
 8 | pub struct TileId(pub u64);
 9 | 
10 | impl TileId {
11 |     #[inline]
12 |     pub fn from_u64(n: u64) -> Self {
13 |         Self(n)
14 |     }
15 | 
16 |     /// Corresponding [`egui::Id`], used for tracking dragging of tiles.
17 |     pub fn egui_id(&self, tree_id: egui::Id) -> egui::Id {
18 |         tree_id.with(("tile", self))
19 |     }
20 | }
21 | 
22 | impl std::fmt::Debug for TileId {
23 |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 |         write!(f, "#{}", self.0)
25 |     }
26 | }
27 | 
28 | // ----------------------------------------------------------------------------
29 | 
30 | /// A tile in the tree. Either a pane (leaf) or a [`Container`] of more tiles.
31 | #[derive(Clone, Debug, PartialEq)]
32 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
33 | pub enum Tile {
34 |     /// A leaf. This is where the user puts their UI, using the [`crate::Behavior`] trait.
35 |     Pane(Pane),
36 | 
37 |     /// A container of more tiles, e.g. a horizontal layout or a tab layout.
38 |     Container(Container),
39 | }
40 | 
41 | impl From for Tile {
42 |     #[inline]
43 |     fn from(container: Container) -> Self {
44 |         Self::Container(container)
45 |     }
46 | }
47 | 
48 | impl Tile {
49 |     /// Returns `None` if this is a [`Self::Pane`].
50 |     #[inline]
51 |     pub fn kind(&self) -> Option {
52 |         match self {
53 |             Self::Pane(_) => None,
54 |             Self::Container(container) => Some(container.kind()),
55 |         }
56 |     }
57 | 
58 |     #[inline]
59 |     pub fn is_pane(&self) -> bool {
60 |         matches!(self, Self::Pane(_))
61 |     }
62 | 
63 |     #[inline]
64 |     pub fn is_container(&self) -> bool {
65 |         matches!(self, Self::Container(_))
66 |     }
67 | 
68 |     #[inline]
69 |     pub fn container_kind(&self) -> Option {
70 |         match self {
71 |             Self::Pane(_) => None,
72 |             Self::Container(container) => Some(container.kind()),
73 |         }
74 |     }
75 | }
76 | 


--------------------------------------------------------------------------------
/src/tiles.rs:
--------------------------------------------------------------------------------
  1 | use egui::{Pos2, Rect};
  2 | 
  3 | use super::{
  4 |     Behavior, Container, ContainerInsertion, ContainerKind, GcAction, Grid, InsertionPoint, Linear,
  5 |     LinearDir, SimplificationOptions, SimplifyAction, Tabs, Tile, TileId,
  6 | };
  7 | 
  8 | /// Contains all tile state, but no root.
  9 | ///
 10 | /// ```
 11 | /// use egui_tiles::{Tiles, TileId, Tree};
 12 | ///
 13 | /// struct Pane { } // put some state here
 14 | ///
 15 | /// let mut tiles = Tiles::default();
 16 | /// let tabs: Vec = vec![tiles.insert_pane(Pane { }), tiles.insert_pane(Pane { })];
 17 | /// let root: TileId = tiles.insert_tab_tile(tabs);
 18 | ///
 19 | /// let tree = Tree::new("my_tree", root, tiles);
 20 | /// ```
 21 | #[derive(Clone, Debug)]
 22 | #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
 23 | pub struct Tiles {
 24 |     next_tile_id: u64,
 25 | 
 26 |     tiles: ahash::HashMap>,
 27 | 
 28 |     /// Tiles are visible by default, so we only store the invisible ones.
 29 |     invisible: ahash::HashSet,
 30 | 
 31 |     /// Filled in by the layout step at the start of each frame.
 32 |     #[cfg_attr(feature = "serde", serde(default, skip))]
 33 |     pub(super) rects: ahash::HashMap,
 34 | }
 35 | 
 36 | impl PartialEq for Tiles {
 37 |     fn eq(&self, other: &Self) -> bool {
 38 |         let Self {
 39 |             next_tile_id: _, // ignored
 40 |             tiles,
 41 |             invisible,
 42 |             rects: _, // ignore transient state
 43 |         } = self;
 44 |         tiles == &other.tiles && invisible == &other.invisible
 45 |     }
 46 | }
 47 | 
 48 | impl Default for Tiles {
 49 |     fn default() -> Self {
 50 |         Self {
 51 |             next_tile_id: 1,
 52 |             tiles: Default::default(),
 53 |             invisible: Default::default(),
 54 |             rects: Default::default(),
 55 |         }
 56 |     }
 57 | }
 58 | 
 59 | // ----------------------------------------------------------------------------
 60 | 
 61 | impl Tiles {
 62 |     #[inline]
 63 |     pub fn is_empty(&self) -> bool {
 64 |         self.tiles.is_empty()
 65 |     }
 66 | 
 67 |     /// The number of tiles, including invisible tiles.
 68 |     #[inline]
 69 |     pub fn len(&self) -> usize {
 70 |         self.tiles.len()
 71 |     }
 72 | 
 73 |     pub fn get(&self, tile_id: TileId) -> Option<&Tile> {
 74 |         self.tiles.get(&tile_id)
 75 |     }
 76 | 
 77 |     /// Get the pane instance for a given [`TileId`]
 78 |     pub fn get_pane(&self, tile_id: &TileId) -> Option<&Pane> {
 79 |         match self.tiles.get(tile_id)? {
 80 |             Tile::Pane(pane) => Some(pane),
 81 |             Tile::Container(_) => None,
 82 |         }
 83 |     }
 84 | 
 85 |     /// Get the container instance for a given [`TileId`]
 86 |     pub fn get_container(&self, tile_id: TileId) -> Option<&Container> {
 87 |         match self.tiles.get(&tile_id)? {
 88 |             Tile::Container(container) => Some(container),
 89 |             Tile::Pane(_) => None,
 90 |         }
 91 |     }
 92 | 
 93 |     pub fn get_mut(&mut self, tile_id: TileId) -> Option<&mut Tile> {
 94 |         self.tiles.get_mut(&tile_id)
 95 |     }
 96 | 
 97 |     /// Get the screen-space rectangle of where a tile is shown.
 98 |     ///
 99 |     /// This is updated by [`crate::Tree::ui`], so you need to call that first.
100 |     ///
101 |     /// If the tile isn't visible, or is in an inactive tab, this return `None`.
102 |     pub fn rect(&self, tile_id: TileId) -> Option {
103 |         if self.is_visible(tile_id) {
104 |             self.rects.get(&tile_id).copied()
105 |         } else {
106 |             None
107 |         }
108 |     }
109 | 
110 |     pub(super) fn rect_or_die(&self, tile_id: TileId) -> Rect {
111 |         let rect = self.rect(tile_id);
112 |         debug_assert!(rect.is_some(), "Failed to find rect for {tile_id:?}");
113 |         rect.unwrap_or(egui::Rect::from_min_max(Pos2::ZERO, Pos2::ZERO))
114 |     }
115 | 
116 |     /// All tiles, in arbitrary order
117 |     pub fn iter(&self) -> impl Iterator)> + '_ {
118 |         self.tiles.iter()
119 |     }
120 | 
121 |     /// All tiles, in arbitrary order
122 |     pub fn iter_mut(&mut self) -> impl Iterator)> + '_ {
123 |         self.tiles.iter_mut()
124 |     }
125 | 
126 |     /// All [`TileId`]s, in arbitrary order
127 |     pub fn tile_ids(&self) -> impl Iterator + '_ {
128 |         self.tiles.keys().copied()
129 |     }
130 | 
131 |     /// All [`Tile`]s in arbitrary order
132 |     pub fn tiles(&self) -> impl Iterator> + '_ {
133 |         self.tiles.values()
134 |     }
135 | 
136 |     /// All [`Tile`]s in arbitrary order
137 |     pub fn tiles_mut(&mut self) -> impl Iterator> + '_ {
138 |         self.tiles.values_mut()
139 |     }
140 | 
141 |     /// Tiles are visible by default.
142 |     ///
143 |     /// Invisible tiles still retain their place in the tile hierarchy.
144 |     pub fn is_visible(&self, tile_id: TileId) -> bool {
145 |         !self.invisible.contains(&tile_id)
146 |     }
147 | 
148 |     /// Tiles are visible by default.
149 |     ///
150 |     /// Invisible tiles still retain their place in the tile hierarchy.
151 |     pub fn set_visible(&mut self, tile_id: TileId, visible: bool) {
152 |         if visible {
153 |             self.invisible.remove(&tile_id);
154 |         } else {
155 |             self.invisible.insert(tile_id);
156 |         }
157 |     }
158 | 
159 |     pub fn toggle_visibility(&mut self, tile_id: TileId) {
160 |         self.set_visible(tile_id, !self.is_visible(tile_id));
161 |     }
162 | 
163 |     /// This excludes all tiles that invisible or are inactive tabs, recursively.
164 |     pub(crate) fn collect_acticve_tiles(&self, tile_id: TileId, tiles: &mut Vec) {
165 |         if !self.is_visible(tile_id) {
166 |             return;
167 |         }
168 |         tiles.push(tile_id);
169 | 
170 |         if let Some(Tile::Container(container)) = self.get(tile_id) {
171 |             for &child_id in container.active_children() {
172 |                 self.collect_acticve_tiles(child_id, tiles);
173 |             }
174 |         }
175 |     }
176 | 
177 |     pub fn insert(&mut self, id: TileId, tile: Tile) {
178 |         self.tiles.insert(id, tile);
179 |     }
180 | 
181 |     /// Remove the tile with the given id from the tiles container.
182 |     ///
183 |     /// Note that this does not actually remove the tile from the tree and may
184 |     /// leave dangling references. If you want to permanently remove the tile
185 |     /// consider calling [`crate::Tree::remove_recursively`].
186 |     pub fn remove(&mut self, id: TileId) -> Option> {
187 |         self.tiles.remove(&id)
188 |     }
189 | 
190 |     pub fn next_free_id(&mut self) -> TileId {
191 |         let mut id = TileId::from_u64(self.next_tile_id);
192 | 
193 |         // Make sure it doesn't collide with an existing id
194 |         while self.tiles.contains_key(&id) {
195 |             self.next_tile_id += 1;
196 |             id = TileId::from_u64(self.next_tile_id);
197 |         }
198 | 
199 |         // Final increment the next_id
200 |         self.next_tile_id += 1;
201 | 
202 |         id
203 |     }
204 | 
205 |     #[must_use]
206 |     pub fn insert_new(&mut self, tile: Tile) -> TileId {
207 |         let id = self.next_free_id();
208 |         self.tiles.insert(id, tile);
209 |         id
210 |     }
211 | 
212 |     #[must_use]
213 |     pub fn insert_pane(&mut self, pane: Pane) -> TileId {
214 |         self.insert_new(Tile::Pane(pane))
215 |     }
216 | 
217 |     #[must_use]
218 |     pub fn insert_container(&mut self, container: impl Into) -> TileId {
219 |         self.insert_new(Tile::Container(container.into()))
220 |     }
221 | 
222 |     #[must_use]
223 |     pub fn insert_tab_tile(&mut self, children: Vec) -> TileId {
224 |         self.insert_new(Tile::Container(Container::new_tabs(children)))
225 |     }
226 | 
227 |     #[must_use]
228 |     pub fn insert_horizontal_tile(&mut self, children: Vec) -> TileId {
229 |         self.insert_new(Tile::Container(Container::new_linear(
230 |             LinearDir::Horizontal,
231 |             children,
232 |         )))
233 |     }
234 | 
235 |     #[must_use]
236 |     pub fn insert_vertical_tile(&mut self, children: Vec) -> TileId {
237 |         self.insert_new(Tile::Container(Container::new_linear(
238 |             LinearDir::Vertical,
239 |             children,
240 |         )))
241 |     }
242 | 
243 |     #[must_use]
244 |     pub fn insert_grid_tile(&mut self, children: Vec) -> TileId {
245 |         self.insert_new(Tile::Container(Container::new_grid(children)))
246 |     }
247 | 
248 |     pub fn parent_of(&self, child_id: TileId) -> Option {
249 |         #[allow(clippy::iter_over_hash_type)] // Each tile can only have one parent
250 |         for (tile_id, tile) in &self.tiles {
251 |             if let Tile::Container(container) = tile {
252 |                 if container.has_child(child_id) {
253 |                     return Some(*tile_id);
254 |                 }
255 |             }
256 |         }
257 |         None
258 |     }
259 | 
260 |     pub fn is_root(&self, tile_id: TileId) -> bool {
261 |         self.parent_of(tile_id).is_none()
262 |     }
263 | 
264 |     pub(super) fn insert_at(&mut self, insertion_point: InsertionPoint, inserted_id: TileId) {
265 |         let InsertionPoint {
266 |             parent_id,
267 |             insertion,
268 |         } = insertion_point;
269 | 
270 |         let Some(mut parent_tile) = self.tiles.remove(&parent_id) else {
271 |             log::debug!("Failed to insert: could not find parent {parent_id:?}");
272 |             return;
273 |         };
274 | 
275 |         match insertion {
276 |             ContainerInsertion::Tabs(index) => {
277 |                 if let Tile::Container(Container::Tabs(tabs)) = &mut parent_tile {
278 |                     let index = index.min(tabs.children.len());
279 |                     tabs.children.insert(index, inserted_id);
280 |                     tabs.set_active(inserted_id);
281 |                     self.tiles.insert(parent_id, parent_tile);
282 |                 } else {
283 |                     let new_tile_id = self.insert_new(parent_tile);
284 |                     let mut tabs = Tabs::new(vec![new_tile_id]);
285 |                     tabs.children.insert(index.min(1), inserted_id);
286 |                     tabs.set_active(inserted_id);
287 |                     self.tiles
288 |                         .insert(parent_id, Tile::Container(Container::Tabs(tabs)));
289 |                 }
290 |             }
291 |             ContainerInsertion::Horizontal(index) => {
292 |                 if let Tile::Container(Container::Linear(Linear {
293 |                     dir: LinearDir::Horizontal,
294 |                     children,
295 |                     ..
296 |                 })) = &mut parent_tile
297 |                 {
298 |                     let index = index.min(children.len());
299 |                     children.insert(index, inserted_id);
300 |                     self.tiles.insert(parent_id, parent_tile);
301 |                 } else {
302 |                     let new_tile_id = self.insert_new(parent_tile);
303 |                     let mut linear = Linear::new(LinearDir::Horizontal, vec![new_tile_id]);
304 |                     linear.children.insert(index.min(1), inserted_id);
305 |                     self.tiles
306 |                         .insert(parent_id, Tile::Container(Container::Linear(linear)));
307 |                 }
308 |             }
309 |             ContainerInsertion::Vertical(index) => {
310 |                 if let Tile::Container(Container::Linear(Linear {
311 |                     dir: LinearDir::Vertical,
312 |                     children,
313 |                     ..
314 |                 })) = &mut parent_tile
315 |                 {
316 |                     let index = index.min(children.len());
317 |                     children.insert(index, inserted_id);
318 |                     self.tiles.insert(parent_id, parent_tile);
319 |                 } else {
320 |                     let new_tile_id = self.insert_new(parent_tile);
321 |                     let mut linear = Linear::new(LinearDir::Vertical, vec![new_tile_id]);
322 |                     linear.children.insert(index.min(1), inserted_id);
323 |                     self.tiles
324 |                         .insert(parent_id, Tile::Container(Container::Linear(linear)));
325 |                 }
326 |             }
327 |             ContainerInsertion::Grid(index) => {
328 |                 if let Tile::Container(Container::Grid(grid)) = &mut parent_tile {
329 |                     grid.insert_at(index, inserted_id);
330 |                     self.tiles.insert(parent_id, parent_tile);
331 |                 } else {
332 |                     let new_tile_id = self.insert_new(parent_tile);
333 |                     let grid = Grid::new(vec![new_tile_id, inserted_id]);
334 |                     self.tiles
335 |                         .insert(parent_id, Tile::Container(Container::Grid(grid)));
336 |                 }
337 |             }
338 |         }
339 |     }
340 | 
341 |     /// Detect cycles, duplications, and other invalid state, and fix it.
342 |     ///
343 |     /// Will also call [`Behavior::retain_pane`] to check if a users wants to remove a pane.
344 |     ///
345 |     /// Finally free up any tiles that are no longer reachable from the root.
346 |     pub(super) fn gc_root(&mut self, behavior: &mut dyn Behavior, root_id: Option) {
347 |         let mut visited = Default::default();
348 | 
349 |         if let Some(root_id) = root_id {
350 |             // We ignore the returned root action, because we will never remove the root.
351 |             let _root_action = self.gc_tile_id(behavior, &mut visited, root_id);
352 |         }
353 | 
354 |         if visited.len() < self.tiles.len() {
355 |             // This should only happen if the user set up the tree in a bad state,
356 |             // or if it was restored from a bad state via serde.
357 |             // …or if there is a bug somewhere 😜
358 |             log::debug!(
359 |                 "GC collecting tiles: {:?}",
360 |                 self.tiles
361 |                     .keys()
362 |                     .filter(|id| !visited.contains(id))
363 |                     .collect::>()
364 |             );
365 |         }
366 | 
367 |         self.invisible.retain(|tile_id| visited.contains(tile_id));
368 |         self.tiles.retain(|tile_id, _| visited.contains(tile_id));
369 |     }
370 | 
371 |     /// Detect cycles, duplications, and other invalid state, and remove them.
372 |     fn gc_tile_id(
373 |         &mut self,
374 |         behavior: &mut dyn Behavior,
375 |         visited: &mut ahash::HashSet,
376 |         tile_id: TileId,
377 |     ) -> GcAction {
378 |         let Some(mut tile) = self.tiles.remove(&tile_id) else {
379 |             return GcAction::Remove;
380 |         };
381 |         if !visited.insert(tile_id) {
382 |             log::warn!("Cycle or duplication detected");
383 |             return GcAction::Remove;
384 |         }
385 | 
386 |         match &mut tile {
387 |             Tile::Pane(pane) => {
388 |                 if !behavior.retain_pane(pane) {
389 |                     return GcAction::Remove;
390 |                 }
391 |             }
392 |             Tile::Container(container) => {
393 |                 container
394 |                     .retain(|child| self.gc_tile_id(behavior, visited, child) == GcAction::Keep);
395 |             }
396 |         }
397 |         self.tiles.insert(tile_id, tile);
398 |         GcAction::Keep
399 |     }
400 | 
401 |     pub(super) fn layout_tile(
402 |         &mut self,
403 |         style: &egui::Style,
404 |         behavior: &mut dyn Behavior,
405 |         rect: Rect,
406 |         tile_id: TileId,
407 |     ) {
408 |         let Some(mut tile) = self.tiles.remove(&tile_id) else {
409 |             log::debug!("Failed to find tile {tile_id:?} during layout");
410 |             return;
411 |         };
412 |         self.rects.insert(tile_id, rect);
413 | 
414 |         if let Tile::Container(container) = &mut tile {
415 |             container.layout(self, style, behavior, rect);
416 |         }
417 | 
418 |         self.tiles.insert(tile_id, tile);
419 |     }
420 | 
421 |     /// Simplify the tree, perhaps culling empty containers,
422 |     /// and/or merging single-child containers into their parent.
423 |     ///
424 |     /// Drag-dropping tiles can often leave containers empty, or with only a single child.
425 |     /// This is often undesired, so this function can be used to clean up the tree.
426 |     ///
427 |     /// What simplifications are allowed is controlled by the [`SimplificationOptions`].
428 |     pub(super) fn simplify(
429 |         &mut self,
430 |         options: &SimplificationOptions,
431 |         it: TileId,
432 |         parent_kind: Option,
433 |     ) -> SimplifyAction {
434 |         let Some(mut tile) = self.tiles.remove(&it) else {
435 |             log::debug!("Failed to find tile {it:?} during simplify");
436 |             return SimplifyAction::Remove;
437 |         };
438 | 
439 |         if let Tile::Container(container) = &mut tile {
440 |             let kind = container.kind();
441 |             container.simplify_children(|child| self.simplify(options, child, Some(kind)));
442 | 
443 |             if kind == ContainerKind::Tabs {
444 |                 if options.prune_empty_tabs && container.is_empty() {
445 |                     log::trace!("Simplify: removing empty tabs container");
446 |                     return SimplifyAction::Remove;
447 |                 }
448 | 
449 |                 if options.prune_single_child_tabs {
450 |                     if let Some(only_child) = container.only_child() {
451 |                         let child_is_pane = matches!(self.get(only_child), Some(Tile::Pane(_)));
452 | 
453 |                         if options.all_panes_must_have_tabs
454 |                             && child_is_pane
455 |                             && parent_kind != Some(ContainerKind::Tabs)
456 |                         {
457 |                             // Keep it, even though we only have one child
458 |                         } else {
459 |                             log::trace!("Simplify: collapsing single-child tabs container");
460 |                             return SimplifyAction::Replace(only_child);
461 |                         }
462 |                     }
463 |                 }
464 |             } else {
465 |                 if options.join_nested_linear_containers {
466 |                     if let Container::Linear(parent) = container {
467 |                         let mut new_children = Vec::with_capacity(parent.children.len());
468 |                         for child_id in parent.children.drain(..) {
469 |                             if let Some(Tile::Container(Container::Linear(child))) =
470 |                                 &mut self.get_mut(child_id)
471 |                             {
472 |                                 if parent.dir == child.dir {
473 |                                     // absorb the child
474 |                                     log::trace!(
475 |                                         "Simplify: absorbing nested linear container with {} children",
476 |                                         child.children.len()
477 |                                     );
478 | 
479 |                                     let mut child_share_sum = 0.0;
480 |                                     for &grandchild in &child.children {
481 |                                         child_share_sum += child.shares[grandchild];
482 |                                     }
483 |                                     let share_normalizer =
484 |                                         parent.shares[child_id] / child_share_sum;
485 |                                     for &grandchild in &child.children {
486 |                                         new_children.push(grandchild);
487 |                                         parent.shares[grandchild] =
488 |                                             child.shares[grandchild] * share_normalizer;
489 |                                     }
490 | 
491 |                                     self.tiles.remove(&child_id);
492 |                                 } else {
493 |                                     // keep the child
494 |                                     new_children.push(child_id);
495 |                                 }
496 |                             } else {
497 |                                 new_children.push(child_id);
498 |                             }
499 |                         }
500 |                         parent.children = new_children;
501 |                     }
502 |                 }
503 | 
504 |                 if options.prune_empty_containers && container.is_empty() {
505 |                     log::trace!("Simplify: removing empty container tile");
506 |                     return SimplifyAction::Remove;
507 |                 }
508 |                 if options.prune_single_child_containers {
509 |                     if let Some(only_child) = container.only_child() {
510 |                         log::trace!("Simplify: collapsing single-child container tile");
511 |                         return SimplifyAction::Replace(only_child);
512 |                     }
513 |                 }
514 |             }
515 |         }
516 | 
517 |         self.tiles.insert(it, tile);
518 |         SimplifyAction::Keep
519 |     }
520 | 
521 |     pub(super) fn make_all_panes_children_of_tabs(&mut self, parent_is_tabs: bool, it: TileId) {
522 |         let Some(mut tile) = self.tiles.remove(&it) else {
523 |             log::debug!("Failed to find tile {it:?} during make_all_panes_children_of_tabs");
524 |             return;
525 |         };
526 | 
527 |         match &mut tile {
528 |             Tile::Pane(_) => {
529 |                 if !parent_is_tabs {
530 |                     // Add tabs to this pane:
531 |                     log::trace!("Auto-adding Tabs-parent to pane {it:?}");
532 |                     let new_id = self.insert_new(tile);
533 |                     self.tiles
534 |                         .insert(it, Tile::Container(Container::new_tabs(vec![new_id])));
535 |                     return;
536 |                 }
537 |             }
538 |             Tile::Container(container) => {
539 |                 let is_tabs = container.kind() == ContainerKind::Tabs;
540 |                 for &child in container.children() {
541 |                     self.make_all_panes_children_of_tabs(is_tabs, child);
542 |                 }
543 |             }
544 |         }
545 | 
546 |         self.tiles.insert(it, tile);
547 |     }
548 | 
549 |     /// Returns true if the active tile was found in this tree.
550 |     pub(super) fn make_active(
551 |         &mut self,
552 |         it: TileId,
553 |         should_activate: &mut dyn FnMut(TileId, &Tile) -> bool,
554 |     ) -> bool {
555 |         let Some(mut tile) = self.tiles.remove(&it) else {
556 |             log::debug!("Failed to find tile {it:?} during make_active");
557 |             return false;
558 |         };
559 | 
560 |         let mut activate = should_activate(it, &tile);
561 | 
562 |         if let Tile::Container(container) = &mut tile {
563 |             let mut active_child = None;
564 |             for &child in container.children() {
565 |                 if self.make_active(child, should_activate) {
566 |                     active_child = Some(child);
567 |                 }
568 |             }
569 | 
570 |             if let Some(active_child) = active_child {
571 |                 if let Container::Tabs(tabs) = container {
572 |                     tabs.set_active(active_child);
573 |                 }
574 |             }
575 | 
576 |             activate |= active_child.is_some();
577 |         }
578 | 
579 |         self.tiles.insert(it, tile);
580 |         activate
581 |     }
582 | }
583 | 
584 | impl Tiles {
585 |     /// Find the tile with the given pane.
586 |     pub fn find_pane(&self, needle: &Pane) -> Option {
587 |         self.tiles
588 |             .iter()
589 |             .find(|(_, tile)| {
590 |                 if let Tile::Pane(pane) = *tile {
591 |                     pane == needle
592 |                 } else {
593 |                     false
594 |                 }
595 |             })
596 |             .map(|(tile_id, _)| *tile_id)
597 |     }
598 | }
599 | 


--------------------------------------------------------------------------------
/tests/serialize.rs:
--------------------------------------------------------------------------------
 1 | #![cfg(feature = "serde")]
 2 | 
 3 | use egui_tiles::{Tiles, Tree};
 4 | 
 5 | #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
 6 | struct Pane {
 7 |     nr: usize,
 8 | }
 9 | 
10 | fn create_tree() -> Tree {
11 |     let mut next_view_nr = 0;
12 |     let mut gen_pane = || {
13 |         let pane = Pane { nr: next_view_nr };
14 |         next_view_nr += 1;
15 |         pane
16 |     };
17 | 
18 |     let mut tiles = Tiles::default();
19 | 
20 |     let mut tabs = vec![];
21 |     tabs.push({
22 |         let children = (0..7).map(|_| tiles.insert_pane(gen_pane())).collect();
23 |         tiles.insert_horizontal_tile(children)
24 |     });
25 |     tabs.push({
26 |         let cells = (0..11).map(|_| tiles.insert_pane(gen_pane())).collect();
27 |         tiles.insert_grid_tile(cells)
28 |     });
29 |     tabs.push(tiles.insert_pane(gen_pane()));
30 | 
31 |     let root = tiles.insert_tab_tile(tabs);
32 | 
33 |     Tree::new("my_tree", root, tiles)
34 | }
35 | 
36 | #[test]
37 | fn test_serialize_json() {
38 |     let original = create_tree();
39 |     let json = serde_json::to_string(&original).expect("json serialize");
40 |     let restored = serde_json::from_str(&json).expect("json deserialize");
41 |     assert_eq!(original, restored, "JSON did not round-trip");
42 | }
43 | 
44 | #[test]
45 | fn test_serialize_ron() {
46 |     let original = create_tree();
47 |     let ron = ron::to_string(&original).expect("ron serialize");
48 |     let restored = ron::from_str(&ron).expect("ron deserialize");
49 |     assert_eq!(original, restored, "RON did not round-trip");
50 | }
51 | 


--------------------------------------------------------------------------------