├── .gitattributes ├── .github ├── pull_request_template.md └── workflows │ ├── cargo_machete.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 ├── clippy.toml ├── deny.toml ├── examples_ ├── README.md ├── basic_integration.rs └── querying.rs ├── lychee.toml ├── rust-toolchain ├── scripts ├── clippy_wasm │ └── clippy.toml ├── generate_changelog.py └── template_update.py └── src ├── event.rs ├── filter.rs ├── lib.rs ├── node.rs ├── query.rs └── state.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_machete.yml: -------------------------------------------------------------------------------- 1 | name: Cargo Machete 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | types: [ opened, synchronize ] 9 | 10 | jobs: 11 | cargo-machete: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: dtolnay/rust-toolchain@master 15 | with: 16 | toolchain: 1.85 17 | - name: Machete install 18 | run: cargo install cargo-machete --locked 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Machete Check 22 | run: cargo machete 23 | -------------------------------------------------------------------------------- /.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' && false # TODO: enable if eframe is part of the project, otherwise remove 30 | uses: awalsh128/cache-apt-pkgs-action@v1.4.3 31 | with: 32 | # some deps used by eframe, if that is part of the project 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 | uses: actions-rs/cargo@v1 42 | with: 43 | command: fmt 44 | args: --all -- --check 45 | 46 | - name: check --all-features 47 | uses: actions-rs/cargo@v1 48 | with: 49 | command: check 50 | args: --all-features --all-targets 51 | 52 | - name: check default features 53 | uses: actions-rs/cargo@v1 54 | with: 55 | command: check 56 | args: --all-targets 57 | 58 | - name: check --no-default-features 59 | uses: actions-rs/cargo@v1 60 | with: 61 | command: check 62 | args: --no-default-features --lib --all-targets 63 | 64 | - name: Test doc-tests 65 | uses: actions-rs/cargo@v1 66 | with: 67 | command: test 68 | args: --doc --all-features 69 | 70 | - name: cargo doc --lib 71 | uses: actions-rs/cargo@v1 72 | with: 73 | command: doc 74 | args: --lib --no-deps --all-features 75 | 76 | - name: cargo doc --document-private-items 77 | uses: actions-rs/cargo@v1 78 | with: 79 | command: doc 80 | args: --document-private-items --no-deps --all-features 81 | 82 | - name: Build tests 83 | uses: actions-rs/cargo@v1 84 | with: 85 | command: test 86 | args: --all-features --no-run 87 | 88 | - name: Run test 89 | uses: actions-rs/cargo@v1 90 | with: 91 | command: test 92 | args: --all-features 93 | 94 | - name: Clippy 95 | uses: actions-rs/cargo@v1 96 | with: 97 | command: clippy 98 | args: --all-targets --all-features -- -D warnings 99 | 100 | # Disabled due to accesskit version mismatch between this repo and egui. 101 | # See also examples_/README.md 102 | # - name: Run basic_integration example 103 | # uses: actions-rs/cargo@v1 104 | # with: 105 | # command: run 106 | # args: --example basic_integration 107 | 108 | # - name: Run querying example 109 | # uses: actions-rs/cargo@v1 110 | # with: 111 | # command: run 112 | # args: --example querying 113 | 114 | # --------------------------------------------------------------------------- 115 | 116 | check_wasm: 117 | name: Check wasm32 118 | runs-on: ubuntu-latest 119 | steps: 120 | - uses: actions/checkout@v4 121 | - uses: actions-rs/toolchain@v1 122 | with: 123 | profile: minimal 124 | toolchain: 1.81.0 125 | target: wasm32-unknown-unknown 126 | override: true 127 | components: clippy 128 | 129 | - name: Set up cargo cache 130 | uses: Swatinem/rust-cache@v2 131 | 132 | - name: Check wasm32 133 | uses: actions-rs/cargo@v1 134 | with: 135 | command: check 136 | args: --target wasm32-unknown-unknown --lib 137 | 138 | - name: Clippy wasm32 139 | env: 140 | CLIPPY_CONF_DIR: "scripts/clippy_wasm" # Use scripts/clippy_wasm/clippy.toml 141 | run: cargo clippy --target wasm32-unknown-unknown --lib -- -D warnings 142 | 143 | # --------------------------------------------------------------------------- 144 | 145 | cargo-deny: 146 | name: Check Rust dependencies (cargo-deny) 147 | runs-on: ubuntu-latest 148 | steps: 149 | - uses: actions/checkout@v3 150 | - uses: EmbarkStudios/cargo-deny-action@v1 151 | with: 152 | rust-version: "1.81.0" 153 | log-level: warn 154 | command: check 155 | -------------------------------------------------------------------------------- /.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 | # C++ build directory 5 | build 6 | 7 | # Rust compile target directories: 8 | target 9 | target_ra 10 | target_wasm 11 | 12 | # https://github.com/lycheeverse/lychee 13 | .lycheecache 14 | 15 | # Pixi environment 16 | .pixi 17 | 18 | # Python stuff: 19 | __pycache__ 20 | .mypy_cache 21 | .ruff_cache 22 | venv 23 | 24 | .idea 25 | -------------------------------------------------------------------------------- /.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 | "gaborv.flatbuffers", 7 | "github.vscode-github-actions", 8 | "josetr.cmake-language-support-vscode", 9 | "ms-python.mypy-type-checker", 10 | "ms-python.python", 11 | "ms-vscode.cmake-tools", 12 | "ms-vscode.cpptools-extension-pack", 13 | "ms-vsliveshare.vsliveshare", 14 | "polymeilex.wgsl", 15 | "rust-lang.rust-analyzer", 16 | "serayuzgur.crates", 17 | "streetsidesoftware.code-spell-checker", 18 | "tamasfe.even-better-toml", 19 | "vadimcn.vscode-lldb", 20 | "wayou.vscode-todo-highlight", 21 | "webfreak.debug", 22 | "xaver.clang-format", // C++ formatter 23 | "zxh404.vscode-proto3", 24 | "esbenp.prettier-vscode" 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 | // Python 8 | { 9 | "name": "Python Debugger: Current File", 10 | "type": "debugpy", 11 | "request": "launch", 12 | "program": "${file}", 13 | "console": "integratedTerminal" 14 | }, 15 | // Rust: 16 | { 17 | "name": "Debug 'PROJ_NAME'", 18 | "type": "lldb", 19 | "request": "launch", 20 | "cargo": { 21 | "args": [ 22 | "build" 23 | ], 24 | "filter": { 25 | "name": "PROJ_NAME", 26 | "kind": "bin" 27 | } 28 | }, 29 | "args": [], 30 | "cwd": "${workspaceFolder}", 31 | "env": { 32 | "RUST_LOG": "debug" 33 | } 34 | }, 35 | { 36 | "name": "Launch Rust tests", 37 | "type": "lldb", 38 | "request": "launch", 39 | "cargo": { 40 | "args": [ 41 | "test", 42 | "--no-run", 43 | "--lib", 44 | "--all-features" 45 | ], 46 | "filter": { 47 | "kind": "lib" 48 | } 49 | }, 50 | "cwd": "${workspaceFolder}", 51 | "env": { 52 | "RUST_LOG": "debug" 53 | } 54 | }, 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /.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.format.args": [ 42 | "--config=pyproject.toml" 43 | ], 44 | "ruff.lint.args": [ 45 | "--config=pyproject.toml" 46 | ], 47 | "prettier.requireConfig": true, 48 | "prettier.configPath": ".prettierrc.toml", 49 | "[python]": { 50 | "editor.defaultFormatter": "charliermarsh.ruff" 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.0 - 2024-12-16 - Initial release 4 | -------------------------------------------------------------------------------- /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.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "ab_glyph" 7 | version = "0.2.28" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "79faae4620f45232f599d9bc7b290f88247a0834162c4495ab2f02d60004adfb" 10 | dependencies = [ 11 | "ab_glyph_rasterizer", 12 | "owned_ttf_parser", 13 | ] 14 | 15 | [[package]] 16 | name = "ab_glyph_rasterizer" 17 | version = "0.1.8" 18 | source = "registry+https://github.com/rust-lang/crates.io-index" 19 | checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046" 20 | 21 | [[package]] 22 | name = "accesskit" 23 | version = "0.17.1" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | checksum = "d3d3b8f9bae46a948369bc4a03e815d4ed6d616bd00de4051133a5019dc31c5a" 26 | 27 | [[package]] 28 | name = "accesskit" 29 | version = "0.19.0" 30 | source = "registry+https://github.com/rust-lang/crates.io-index" 31 | checksum = "e25ae84c0260bdf5df07796d7cc4882460de26a2b406ec0e6c42461a723b271b" 32 | 33 | [[package]] 34 | name = "accesskit_consumer" 35 | version = "0.28.0" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "8bfae7c152994a31dc7d99b8eeac7784a919f71d1b306f4b83217e110fd3824c" 38 | dependencies = [ 39 | "accesskit 0.19.0", 40 | "hashbrown", 41 | ] 42 | 43 | [[package]] 44 | name = "ahash" 45 | version = "0.8.11" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" 48 | dependencies = [ 49 | "cfg-if", 50 | "once_cell", 51 | "version_check", 52 | "zerocopy", 53 | ] 54 | 55 | [[package]] 56 | name = "autocfg" 57 | version = "1.3.0" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" 60 | 61 | [[package]] 62 | name = "bitflags" 63 | version = "2.6.0" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 66 | 67 | [[package]] 68 | name = "cfg-if" 69 | version = "1.0.0" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 72 | 73 | [[package]] 74 | name = "ecolor" 75 | version = "0.29.1" 76 | source = "git+https://github.com/emilk/egui?rev=84cc1572b175d49a64f1b323a6d7e56b1f1fba66#84cc1572b175d49a64f1b323a6d7e56b1f1fba66" 77 | dependencies = [ 78 | "emath", 79 | ] 80 | 81 | [[package]] 82 | name = "egui" 83 | version = "0.29.1" 84 | source = "git+https://github.com/emilk/egui?rev=84cc1572b175d49a64f1b323a6d7e56b1f1fba66#84cc1572b175d49a64f1b323a6d7e56b1f1fba66" 85 | dependencies = [ 86 | "accesskit 0.17.1", 87 | "ahash", 88 | "emath", 89 | "epaint", 90 | "nohash-hasher", 91 | ] 92 | 93 | [[package]] 94 | name = "emath" 95 | version = "0.29.1" 96 | source = "git+https://github.com/emilk/egui?rev=84cc1572b175d49a64f1b323a6d7e56b1f1fba66#84cc1572b175d49a64f1b323a6d7e56b1f1fba66" 97 | 98 | [[package]] 99 | name = "epaint" 100 | version = "0.29.1" 101 | source = "git+https://github.com/emilk/egui?rev=84cc1572b175d49a64f1b323a6d7e56b1f1fba66#84cc1572b175d49a64f1b323a6d7e56b1f1fba66" 102 | dependencies = [ 103 | "ab_glyph", 104 | "ahash", 105 | "ecolor", 106 | "emath", 107 | "epaint_default_fonts", 108 | "nohash-hasher", 109 | "parking_lot", 110 | ] 111 | 112 | [[package]] 113 | name = "epaint_default_fonts" 114 | version = "0.29.1" 115 | source = "git+https://github.com/emilk/egui?rev=84cc1572b175d49a64f1b323a6d7e56b1f1fba66#84cc1572b175d49a64f1b323a6d7e56b1f1fba66" 116 | 117 | [[package]] 118 | name = "foldhash" 119 | version = "0.1.4" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" 122 | 123 | [[package]] 124 | name = "hashbrown" 125 | version = "0.15.2" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 128 | dependencies = [ 129 | "foldhash", 130 | ] 131 | 132 | [[package]] 133 | name = "kittest" 134 | version = "0.1.0" 135 | dependencies = [ 136 | "accesskit 0.19.0", 137 | "accesskit_consumer", 138 | "egui", 139 | "parking_lot", 140 | ] 141 | 142 | [[package]] 143 | name = "libc" 144 | version = "0.2.159" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" 147 | 148 | [[package]] 149 | name = "lock_api" 150 | version = "0.4.12" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 153 | dependencies = [ 154 | "autocfg", 155 | "scopeguard", 156 | ] 157 | 158 | [[package]] 159 | name = "nohash-hasher" 160 | version = "0.2.0" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" 163 | 164 | [[package]] 165 | name = "once_cell" 166 | version = "1.19.0" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 169 | 170 | [[package]] 171 | name = "owned_ttf_parser" 172 | version = "0.24.0" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "490d3a563d3122bf7c911a59b0add9389e5ec0f5f0c3ac6b91ff235a0e6a7f90" 175 | dependencies = [ 176 | "ttf-parser", 177 | ] 178 | 179 | [[package]] 180 | name = "parking_lot" 181 | version = "0.12.3" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 184 | dependencies = [ 185 | "lock_api", 186 | "parking_lot_core", 187 | ] 188 | 189 | [[package]] 190 | name = "parking_lot_core" 191 | version = "0.9.10" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 194 | dependencies = [ 195 | "cfg-if", 196 | "libc", 197 | "redox_syscall", 198 | "smallvec", 199 | "windows-targets", 200 | ] 201 | 202 | [[package]] 203 | name = "proc-macro2" 204 | version = "1.0.86" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 207 | dependencies = [ 208 | "unicode-ident", 209 | ] 210 | 211 | [[package]] 212 | name = "quote" 213 | version = "1.0.37" 214 | source = "registry+https://github.com/rust-lang/crates.io-index" 215 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 216 | dependencies = [ 217 | "proc-macro2", 218 | ] 219 | 220 | [[package]] 221 | name = "redox_syscall" 222 | version = "0.5.5" 223 | source = "registry+https://github.com/rust-lang/crates.io-index" 224 | checksum = "62871f2d65009c0256aed1b9cfeeb8ac272833c404e13d53d400cd0dad7a2ac0" 225 | dependencies = [ 226 | "bitflags", 227 | ] 228 | 229 | [[package]] 230 | name = "scopeguard" 231 | version = "1.2.0" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 234 | 235 | [[package]] 236 | name = "smallvec" 237 | version = "1.13.2" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" 240 | 241 | [[package]] 242 | name = "syn" 243 | version = "2.0.77" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" 246 | dependencies = [ 247 | "proc-macro2", 248 | "quote", 249 | "unicode-ident", 250 | ] 251 | 252 | [[package]] 253 | name = "ttf-parser" 254 | version = "0.24.1" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" 257 | 258 | [[package]] 259 | name = "unicode-ident" 260 | version = "1.0.13" 261 | source = "registry+https://github.com/rust-lang/crates.io-index" 262 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 263 | 264 | [[package]] 265 | name = "version_check" 266 | version = "0.9.5" 267 | source = "registry+https://github.com/rust-lang/crates.io-index" 268 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 269 | 270 | [[package]] 271 | name = "windows-targets" 272 | version = "0.52.6" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 275 | dependencies = [ 276 | "windows_aarch64_gnullvm", 277 | "windows_aarch64_msvc", 278 | "windows_i686_gnu", 279 | "windows_i686_gnullvm", 280 | "windows_i686_msvc", 281 | "windows_x86_64_gnu", 282 | "windows_x86_64_gnullvm", 283 | "windows_x86_64_msvc", 284 | ] 285 | 286 | [[package]] 287 | name = "windows_aarch64_gnullvm" 288 | version = "0.52.6" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 291 | 292 | [[package]] 293 | name = "windows_aarch64_msvc" 294 | version = "0.52.6" 295 | source = "registry+https://github.com/rust-lang/crates.io-index" 296 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 297 | 298 | [[package]] 299 | name = "windows_i686_gnu" 300 | version = "0.52.6" 301 | source = "registry+https://github.com/rust-lang/crates.io-index" 302 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 303 | 304 | [[package]] 305 | name = "windows_i686_gnullvm" 306 | version = "0.52.6" 307 | source = "registry+https://github.com/rust-lang/crates.io-index" 308 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 309 | 310 | [[package]] 311 | name = "windows_i686_msvc" 312 | version = "0.52.6" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 315 | 316 | [[package]] 317 | name = "windows_x86_64_gnu" 318 | version = "0.52.6" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 321 | 322 | [[package]] 323 | name = "windows_x86_64_gnullvm" 324 | version = "0.52.6" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 327 | 328 | [[package]] 329 | name = "windows_x86_64_msvc" 330 | version = "0.52.6" 331 | source = "registry+https://github.com/rust-lang/crates.io-index" 332 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 333 | 334 | [[package]] 335 | name = "zerocopy" 336 | version = "0.7.35" 337 | source = "registry+https://github.com/rust-lang/crates.io-index" 338 | checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" 339 | dependencies = [ 340 | "zerocopy-derive", 341 | ] 342 | 343 | [[package]] 344 | name = "zerocopy-derive" 345 | version = "0.7.35" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" 348 | dependencies = [ 349 | "proc-macro2", 350 | "quote", 351 | "syn", 352 | ] 353 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Lucas Meurer", "rerun.io "] 3 | categories = ["gui", "development-tools::testing", "accessibility"] 4 | description = "Automated GUI testing using AccessKit" 5 | edition = "2021" 6 | homepage = "https://github.com/rerun-io/kittest" 7 | include = ["LICENSE-APACHE", "LICENSE-MIT", "**/*.rs", "Cargo.toml"] 8 | keywords = ["gui", "testing", "accessibility", "egui", "accesskit"] 9 | license = "MIT OR Apache-2.0" 10 | name = "kittest" 11 | publish = true 12 | readme = "README.md" 13 | repository = "https://github.com/rerun-io/kittest" 14 | rust-version = "1.81" 15 | version = "0.1.0" 16 | 17 | [package.metadata.docs.rs] 18 | all-features = true 19 | targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"] 20 | 21 | 22 | [features] 23 | default = [] 24 | 25 | 26 | [dependencies] 27 | accesskit_consumer = "0.28.0" 28 | accesskit = "0.19.0" 29 | parking_lot = "0.12" 30 | 31 | [dev-dependencies] 32 | egui = { version = "0.29.1", features = ["accesskit"] } 33 | 34 | [patch.crates-io] 35 | egui = { git = "https://github.com/emilk/egui", rev = "84cc1572b175d49a64f1b323a6d7e56b1f1fba66" } 36 | 37 | [lints] 38 | workspace = true 39 | 40 | 41 | [workspace.lints.rust] 42 | unsafe_code = "deny" 43 | 44 | elided_lifetimes_in_paths = "warn" 45 | future_incompatible = { level = "warn", priority = -1 } 46 | nonstandard_style = { level = "warn", priority = -1 } 47 | rust_2018_idioms = { level = "warn", priority = -1 } 48 | rust_2021_prelude_collisions = "warn" 49 | semicolon_in_expressions_from_macros = "warn" 50 | trivial_numeric_casts = "warn" 51 | 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 52 | unused_extern_crates = "warn" 53 | unused_import_braces = "warn" 54 | unused_lifetimes = "warn" 55 | 56 | trivial_casts = "allow" 57 | unused_qualifications = "allow" 58 | 59 | [workspace.lints.rustdoc] 60 | all = "warn" 61 | missing_crate_level_docs = "warn" 62 | 63 | # See also clippy.toml 64 | [workspace.lints.clippy] 65 | as_ptr_cast_mut = "warn" 66 | await_holding_lock = "warn" 67 | bool_to_int_with_if = "warn" 68 | char_lit_as_u8 = "warn" 69 | checked_conversions = "warn" 70 | clear_with_drain = "warn" 71 | cloned_instead_of_copied = "warn" 72 | dbg_macro = "warn" 73 | debug_assert_with_mut_call = "warn" 74 | derive_partial_eq_without_eq = "warn" 75 | disallowed_macros = "warn" # See clippy.toml 76 | disallowed_methods = "warn" # See clippy.toml 77 | disallowed_names = "warn" # See clippy.toml 78 | disallowed_script_idents = "warn" # See clippy.toml 79 | disallowed_types = "warn" # See clippy.toml 80 | doc_link_with_quotes = "warn" 81 | doc_markdown = "warn" 82 | empty_enum = "warn" 83 | enum_glob_use = "warn" 84 | equatable_if_let = "warn" 85 | exit = "warn" 86 | expl_impl_clone_on_copy = "warn" 87 | explicit_deref_methods = "warn" 88 | explicit_into_iter_loop = "warn" 89 | explicit_iter_loop = "warn" 90 | fallible_impl_from = "warn" 91 | filter_map_next = "warn" 92 | flat_map_option = "warn" 93 | float_cmp_const = "warn" 94 | fn_params_excessive_bools = "warn" 95 | fn_to_numeric_cast_any = "warn" 96 | from_iter_instead_of_collect = "warn" 97 | get_unwrap = "warn" 98 | if_let_mutex = "warn" 99 | implicit_clone = "warn" 100 | imprecise_flops = "warn" 101 | index_refutable_slice = "warn" 102 | inefficient_to_string = "warn" 103 | infinite_loop = "warn" 104 | into_iter_without_iter = "warn" 105 | invalid_upcast_comparisons = "warn" 106 | iter_not_returning_iterator = "warn" 107 | iter_on_empty_collections = "warn" 108 | iter_on_single_items = "warn" 109 | iter_over_hash_type = "warn" 110 | iter_without_into_iter = "warn" 111 | large_digit_groups = "warn" 112 | large_include_file = "warn" 113 | large_stack_arrays = "warn" 114 | large_stack_frames = "warn" 115 | large_types_passed_by_value = "warn" 116 | let_underscore_must_use = "warn" 117 | let_underscore_untyped = "warn" 118 | let_unit_value = "warn" 119 | linkedlist = "warn" 120 | lossy_float_literal = "warn" 121 | macro_use_imports = "warn" 122 | manual_assert = "warn" 123 | manual_clamp = "warn" 124 | manual_instant_elapsed = "warn" 125 | manual_let_else = "warn" 126 | manual_ok_or = "warn" 127 | manual_string_new = "warn" 128 | map_err_ignore = "warn" 129 | map_flatten = "warn" 130 | map_unwrap_or = "warn" 131 | match_bool = "warn" 132 | match_on_vec_items = "warn" 133 | match_same_arms = "warn" 134 | match_wild_err_arm = "warn" 135 | match_wildcard_for_single_variants = "warn" 136 | mem_forget = "warn" 137 | mismatching_type_param_order = "warn" 138 | missing_assert_message = "warn" 139 | missing_enforced_import_renames = "warn" 140 | missing_errors_doc = "warn" 141 | missing_safety_doc = "warn" 142 | mut_mut = "warn" 143 | mutex_integer = "warn" 144 | needless_borrow = "warn" 145 | needless_continue = "warn" 146 | needless_for_each = "warn" 147 | needless_pass_by_ref_mut = "warn" 148 | needless_pass_by_value = "warn" 149 | negative_feature_names = "warn" 150 | nonstandard_macro_braces = "warn" 151 | option_option = "warn" 152 | path_buf_push_overwrite = "warn" 153 | ptr_as_ptr = "warn" 154 | ptr_cast_constness = "warn" 155 | pub_without_shorthand = "warn" 156 | rc_mutex = "warn" 157 | readonly_write_lock = "warn" 158 | redundant_type_annotations = "warn" 159 | ref_option_ref = "warn" 160 | rest_pat_in_fully_bound_structs = "warn" 161 | same_functions_in_if_condition = "warn" 162 | semicolon_if_nothing_returned = "warn" 163 | should_panic_without_expect = "warn" 164 | significant_drop_tightening = "warn" 165 | single_match_else = "warn" 166 | str_to_string = "warn" 167 | string_add = "warn" 168 | string_add_assign = "warn" 169 | string_lit_as_bytes = "warn" 170 | string_lit_chars_any = "warn" 171 | string_to_string = "warn" 172 | suspicious_command_arg_space = "warn" 173 | suspicious_xor_used_as_pow = "warn" 174 | todo = "warn" 175 | too_many_lines = "warn" 176 | trailing_empty_array = "warn" 177 | trait_duplication_in_bounds = "warn" 178 | tuple_array_conversions = "warn" 179 | unchecked_duration_subtraction = "warn" 180 | undocumented_unsafe_blocks = "warn" 181 | unimplemented = "warn" 182 | uninhabited_references = "warn" 183 | uninlined_format_args = "warn" 184 | unnecessary_box_returns = "warn" 185 | unnecessary_safety_doc = "warn" 186 | unnecessary_struct_initialization = "warn" 187 | unnecessary_wraps = "warn" 188 | unnested_or_patterns = "warn" 189 | unused_peekable = "warn" 190 | unused_rounding = "warn" 191 | unused_self = "warn" 192 | unwrap_used = "warn" 193 | use_self = "warn" 194 | useless_transmute = "warn" 195 | verbose_file_reads = "warn" 196 | wildcard_dependencies = "warn" 197 | wildcard_imports = "warn" 198 | zero_sized_map_values = "warn" 199 | 200 | manual_range_contains = "allow" # this one is just worse imho 201 | ref_patterns = "allow" # It's nice to avoid ref pattern, but there are some situations that are hard (impossible?) to express without. 202 | -------------------------------------------------------------------------------- /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 | # 💻🐈 kittest: UI Testing Library for Rust Powered by AccessKit 2 | 3 | **kittest** is a GUI testing library for Rust, inspired by [Testing Library](https://testing-library.com/). 4 | It leverages [AccessKit](https://github.com/AccessKit/accesskit/) to provide a framework-agnostic solution 5 | for testing user interfaces, with a focus on accessibility. 6 | 7 | This library is designed to be flexible and works with any GUI framework that supports AccessKit. 8 | Creating new **kittest** integrations is simple and straightforward. To get started, check out our 9 | [basic integration example](https://github.com/rerun-io/kittest/blob/main/examples/basic_integration.rs). 10 | 11 | ## Available Integrations 12 | - [egui_kittest](https://github.com/emilk/egui/tree/master/crates/egui_kittest): Official integration for 13 | [egui](https://github.com/emilk/egui). 14 | 15 | If you create a new integration, please open a PR to add it to this list! 16 | 17 | ## Example usage with [egui_kittest](https://github.com/emilk/egui/tree/master/crates/egui_kittest) 18 | 19 | ```rust ignore 20 | use egui::accesskit::Toggled; 21 | use egui_kittest::{Harness, kittest::Queryable}; 22 | 23 | fn main() { 24 | let mut checked = false; 25 | let app = |ui: &mut egui::Ui| { 26 | ui.checkbox(&mut checked, "Check me!"); 27 | }; 28 | 29 | let mut harness = Harness::new_ui(app); 30 | 31 | let checkbox = harness.get_by_label("Check me!"); 32 | assert_eq!(checkbox.toggled(), Some(Toggled::False)); 33 | checkbox.click(); 34 | 35 | harness.run(); 36 | 37 | let checkbox = harness.get_by_label("Check me!"); 38 | assert_eq!(checkbox.toggled(), Some(Toggled::True)); 39 | } 40 | ``` 41 | 42 | Also see the [querying example](https://github.com/rerun-io/kittest/blob/4b3ecd2043bc6cd4b0460958f0ca0d8d81331841/examples/querying.rs). 43 | -------------------------------------------------------------------------------- /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 kittest` 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 origin main ; git push --tags` 9 | * [ ] Do a GitHub release: https://github.com/rerun-io/kittest/releases/new 10 | -------------------------------------------------------------------------------- /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 = 8 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 | 46 | 47 | [licenses] 48 | version = 2 49 | private = { ignore = true } 50 | confidence-threshold = 0.93 # We want really high confidence when inferring licenses from text 51 | allow = [ 52 | "Apache-2.0 WITH LLVM-exception", # https://spdx.org/licenses/LLVM-exception.html 53 | "Apache-2.0", # https://tldrlegal.com/license/apache-license-2.0-(apache-2.0) 54 | "BSD-2-Clause", # https://tldrlegal.com/license/bsd-2-clause-license-(freebsd) 55 | "BSD-3-Clause", # https://tldrlegal.com/license/bsd-3-clause-license-(revised) 56 | "BSL-1.0", # https://tldrlegal.com/license/boost-software-license-1.0-explained 57 | "CC0-1.0", # https://creativecommons.org/publicdomain/zero/1.0/ 58 | "ISC", # https://www.tldrlegal.com/license/isc-license 59 | "LicenseRef-UFL-1.0", # no official SPDX, see https://github.com/emilk/egui/issues/2321 60 | "MIT-0", # https://choosealicense.com/licenses/mit-0/ 61 | "MIT", # https://tldrlegal.com/license/mit-license 62 | "MPL-2.0", # https://www.mozilla.org/en-US/MPL/2.0/FAQ/ - see Q11. Used by webpki-roots on Linux. 63 | "OFL-1.1", # https://spdx.org/licenses/OFL-1.1.html 64 | "OpenSSL", # https://www.openssl.org/source/license.html - used on Linux 65 | "Unicode-DFS-2016", # https://spdx.org/licenses/Unicode-DFS-2016.html 66 | "Zlib", # https://tldrlegal.com/license/zlib-libpng-license-(zlib) 67 | ] 68 | exceptions = [] 69 | 70 | [[licenses.clarify]] 71 | name = "webpki" 72 | expression = "ISC" 73 | license-files = [{ path = "LICENSE", hash = 0x001c7e6c }] 74 | 75 | [[licenses.clarify]] 76 | name = "ring" 77 | expression = "MIT AND ISC AND OpenSSL" 78 | license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] 79 | 80 | 81 | [sources] 82 | unknown-registry = "deny" 83 | unknown-git = "deny" 84 | 85 | [sources.allow-org] 86 | github = ["emilk", "rerun-io"] 87 | -------------------------------------------------------------------------------- /examples_/README.md: -------------------------------------------------------------------------------- 1 | This is an example of using kittest with egui. 2 | 3 | See [egui_kittest](https://github.com/emilk/egui/tree/main/crates/egui_kittest) for a working kittest integration. 4 | 5 | ⚠️ This example is currently disabled because the accesskit version of kittest differs from that of egui. 6 | If you enable this example again, remember to also update `.github/workflows/rust.yml`. 7 | -------------------------------------------------------------------------------- /examples_/basic_integration.rs: -------------------------------------------------------------------------------- 1 | //! This example shows how to build a basic kittest integration for some ui framework (in this case egui). 2 | //! If you actually want to use kittest with egui, I suggest you check out the official 3 | //! [egui_kittest][1] integration. 4 | //! 5 | //! [1]: 6 | 7 | use kittest::{Event, Node, Queryable}; 8 | 9 | /// The test Harness. This contains everything needed to run the test. 10 | pub struct Harness<'a> { 11 | /// A handle to the ui framework 12 | ctx: egui::Context, 13 | /// the ui component that should be tested (for egui that's just a closure). 14 | app: Box, 15 | /// The kittest State 16 | pub state: kittest::State, 17 | } 18 | 19 | impl<'a> Harness<'a> { 20 | pub fn new(mut app: impl FnMut(&egui::Context) + 'a) -> Self { 21 | let ctx = egui::Context::default(); 22 | ctx.enable_accesskit(); 23 | 24 | let output = ctx.run(Default::default(), &mut app); 25 | 26 | Self { 27 | ctx, 28 | app: Box::new(app), 29 | state: kittest::State::new( 30 | output 31 | .platform_output 32 | .accesskit_update 33 | .expect("AccessKit not enabled"), 34 | ), 35 | } 36 | } 37 | 38 | pub fn run_frame(&mut self) { 39 | let events = self 40 | .state 41 | .take_events() 42 | .into_iter() 43 | .map(|e| match e { 44 | Event::ActionRequest(action) => egui::Event::AccessKitActionRequest(action), 45 | Event::Simulated(_) => { 46 | panic!("Check egui_kittest for a full implementation"); 47 | } 48 | }) 49 | .collect(); 50 | 51 | let output = self.ctx.run( 52 | egui::RawInput { 53 | events, 54 | ..Default::default() 55 | }, 56 | self.app.as_mut(), 57 | ); 58 | 59 | self.state.update( 60 | output 61 | .platform_output 62 | .accesskit_update 63 | .expect("AccessKit not enabled"), 64 | ); 65 | } 66 | } 67 | 68 | // This allows us to directly query the harness as if it's the root node. 69 | impl<'tree, 'node, 'app> Queryable<'tree, 'node> for Harness<'app> 70 | where 71 | 'node: 'tree, 72 | { 73 | fn node(&'node self) -> Node<'tree> { 74 | self.state.root() 75 | } 76 | } 77 | 78 | fn main() { 79 | let mut checked = false; 80 | let mut harness = Harness::new(|ctx| { 81 | egui::CentralPanel::default().show(ctx, |ui| { 82 | ui.checkbox(&mut checked, "Check me!"); 83 | }); 84 | }); 85 | 86 | harness.get_by_label("Check me!").click(); 87 | harness.run_frame(); 88 | 89 | drop(harness); 90 | 91 | assert!(checked, "Should be checked"); 92 | } 93 | -------------------------------------------------------------------------------- /examples_/querying.rs: -------------------------------------------------------------------------------- 1 | //! This example shows how to use the kittest query functions. 2 | 3 | /// For this example we'll use the egui integration from the basic_integration example. 4 | /// This allows us to easily create a realistic tree. 5 | #[allow(dead_code)] 6 | mod basic_integration; 7 | 8 | use accesskit::Role; 9 | use basic_integration::Harness; 10 | use kittest::{by, Queryable}; 11 | 12 | fn main() { 13 | let harness = make_tree(); 14 | 15 | // You can query nodes by their label (query_by_* functions always return an Option) 16 | let _button_1 = harness 17 | .query_by_label("Button 1") 18 | .expect("Button 1 not found"); 19 | 20 | // You can get nodes by their label (get_by_* functions will panic with a helpful error message 21 | // if the node is not found) 22 | let _button_2 = harness.get_by_label("Button 2"); 23 | 24 | // You can get all nodes with a certain label 25 | let buttons = harness.query_all_by_label("Duplicate"); 26 | assert_eq!( 27 | buttons.count(), 28 | 2, 29 | "Expected 2 buttons with the label 'Duplicate'" 30 | ); 31 | 32 | // If you have multiple items with the same label, you can query by label and role 33 | let _submit = harness.get_by_role_and_label(Role::Button, "Submit"); 34 | 35 | // If you need more complex queries, you can use the by struct 36 | let _check_me = harness.get(by().role(Role::CheckBox).label_contains("Check")); 37 | 38 | // You can also query children of a node 39 | let group = harness.get_by_role_and_label(Role::Label, "My Group"); 40 | // get_by_label won't panic here since we only find the button in the group 41 | group.get_by_label("Duplicate"); 42 | 43 | let btn_in_parent = harness 44 | .get_all_by_label("Duplicate") 45 | .next_back() 46 | .expect("No buttons found"); 47 | assert_eq!( 48 | btn_in_parent.parent_id().expect("No parent id"), 49 | group.id(), 50 | "Button is not in the group" 51 | ); 52 | 53 | // query_by and get_by functions will panic if more than one node is found 54 | // harness.get_by_role(Role::Button); // This will panic! 55 | } 56 | 57 | #[allow(clippy::let_underscore_must_use)] 58 | fn make_tree() -> Harness<'static> { 59 | Harness::new(|ctx| { 60 | egui::CentralPanel::default().show(ctx, |ui| { 61 | _ = ui.button("Button 1"); 62 | _ = ui.button("Button 2"); 63 | 64 | _ = ui.checkbox(&mut true, "Check me"); 65 | 66 | _ = ui.button("Duplicate"); 67 | 68 | _ = ui.label("Submit"); 69 | _ = ui.button("Submit"); 70 | 71 | let group_label = ui.label("My Group"); 72 | _ = ui 73 | .group(|ui| { 74 | // TODO(lucasmerlin): Egui should probably group widgets by their parent automatically 75 | ui.ctx() 76 | .clone() 77 | .with_accessibility_parent(group_label.id, || { 78 | _ = ui.button("Duplicate"); 79 | }); 80 | }) 81 | .response 82 | .labelled_by(group_label.id); 83 | }); 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /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 = 8
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 = "kittest"
 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_machete.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/event.rs:
--------------------------------------------------------------------------------
  1 | use accesskit::Vec2;
  2 | 
  3 | /// Kittest event sent to the GUI application.
  4 | pub enum Event {
  5 |     /// An [`accesskit::ActionRequest`] event. When using an application these would be generated by the
  6 |     /// screen reader.
  7 |     ActionRequest(accesskit::ActionRequest),
  8 | 
  9 |     /// A simulated event, e.g. clicks or typing.
 10 |     Simulated(SimulatedEvent),
 11 | }
 12 | 
 13 | /// A Simulated Event. These should map to the event type of the gui framework.
 14 | ///
 15 | /// The structure is inspired by the `winit` `WindowEvent` types.
 16 | pub enum SimulatedEvent {
 17 |     CursorMoved {
 18 |         position: Vec2,
 19 |     },
 20 |     MouseInput {
 21 |         state: ElementState,
 22 |         button: MouseButton,
 23 |     },
 24 |     Ime(String),
 25 |     KeyInput {
 26 |         state: ElementState,
 27 |         key: Key,
 28 |     },
 29 | }
 30 | 
 31 | /// The state of an element (e.g. Button), either pressed or released.
 32 | #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 33 | pub enum ElementState {
 34 |     Pressed,
 35 |     Released,
 36 | }
 37 | 
 38 | impl ElementState {
 39 |     /// Returns an iterator with first the Pressed state and then the Released state.
 40 |     pub fn click() -> impl Iterator {
 41 |         [Self::Pressed, Self::Released].into_iter()
 42 |     }
 43 | }
 44 | 
 45 | #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 46 | pub enum MouseButton {
 47 |     Left,
 48 |     Right,
 49 |     Middle,
 50 |     Back,
 51 |     Forward,
 52 |     Other(u16),
 53 | }
 54 | 
 55 | /// The keys (currently these match egui's keys).
 56 | #[non_exhaustive]
 57 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
 58 | pub enum Key {
 59 |     // ----------------------------------------------
 60 |     // Modifier keys:
 61 |     Alt,
 62 |     Command,
 63 |     Control,
 64 |     Shift,
 65 | 
 66 |     // ----------------------------------------------
 67 |     // Commands:
 68 |     ArrowDown,
 69 |     ArrowLeft,
 70 |     ArrowRight,
 71 |     ArrowUp,
 72 | 
 73 |     Escape,
 74 |     Tab,
 75 |     Backspace,
 76 |     Enter,
 77 |     Space,
 78 | 
 79 |     Insert,
 80 |     Delete,
 81 |     Home,
 82 |     End,
 83 |     PageUp,
 84 |     PageDown,
 85 | 
 86 |     Copy,
 87 |     Cut,
 88 |     Paste,
 89 | 
 90 |     // ----------------------------------------------
 91 |     // Punctuation:
 92 |     /// `:`
 93 |     Colon,
 94 | 
 95 |     /// `,`
 96 |     Comma,
 97 | 
 98 |     /// `\`
 99 |     Backslash,
100 | 
101 |     /// `/`
102 |     Slash,
103 | 
104 |     /// `|`, a vertical bar
105 |     Pipe,
106 | 
107 |     /// `?`
108 |     Questionmark,
109 | 
110 |     // `[`
111 |     OpenBracket,
112 | 
113 |     // `]`
114 |     CloseBracket,
115 | 
116 |     /// \`, also known as "backquote" or "grave"
117 |     Backtick,
118 | 
119 |     /// `-`
120 |     Minus,
121 | 
122 |     /// `.`
123 |     Period,
124 | 
125 |     /// `+`
126 |     Plus,
127 | 
128 |     /// `=`
129 |     Equals,
130 | 
131 |     /// `;`
132 |     Semicolon,
133 | 
134 |     /// `'`
135 |     Quote,
136 | 
137 |     // ----------------------------------------------
138 |     // Digits:
139 |     /// `0` (from main row or numpad)
140 |     Num0,
141 | 
142 |     /// `1` (from main row or numpad)
143 |     Num1,
144 | 
145 |     /// `2` (from main row or numpad)
146 |     Num2,
147 | 
148 |     /// `3` (from main row or numpad)
149 |     Num3,
150 | 
151 |     /// `4` (from main row or numpad)
152 |     Num4,
153 | 
154 |     /// `5` (from main row or numpad)
155 |     Num5,
156 | 
157 |     /// `6` (from main row or numpad)
158 |     Num6,
159 | 
160 |     /// `7` (from main row or numpad)
161 |     Num7,
162 | 
163 |     /// `8` (from main row or numpad)
164 |     Num8,
165 | 
166 |     /// `9` (from main row or numpad)
167 |     Num9,
168 | 
169 |     // ----------------------------------------------
170 |     // Letters:
171 |     A, // Used for cmd+A (select All)
172 |     B,
173 |     C, // |CMD COPY|
174 |     D, // |CMD BOOKMARK|
175 |     E, // |CMD SEARCH|
176 |     F, // |CMD FIND firefox & chrome|
177 |     G, // |CMD FIND chrome|
178 |     H, // |CMD History|
179 |     I, // italics
180 |     J, // |CMD SEARCH firefox/DOWNLOAD chrome|
181 |     K, // Used for ctrl+K (delete text after cursor)
182 |     L,
183 |     M,
184 |     N,
185 |     O, // |CMD OPEN|
186 |     P, // |CMD PRINT|
187 |     Q,
188 |     R, // |CMD REFRESH|
189 |     S, // |CMD SAVE|
190 |     T, // |CMD TAB|
191 |     U, // Used for ctrl+U (delete text before cursor)
192 |     V, // |CMD PASTE|
193 |     W, // Used for ctrl+W (delete previous word)
194 |     X, // |CMD CUT|
195 |     Y,
196 |     Z, // |CMD UNDO|
197 | 
198 |     // ----------------------------------------------
199 |     // Function keys:
200 |     F1,
201 |     F2,
202 |     F3,
203 |     F4,
204 |     F5, // |CMD REFRESH|
205 |     F6,
206 |     F7,
207 |     F8,
208 |     F9,
209 |     F10,
210 |     F11,
211 |     F12,
212 |     F13,
213 |     F14,
214 |     F15,
215 |     F16,
216 |     F17,
217 |     F18,
218 |     F19,
219 |     F20,
220 |     F21,
221 |     F22,
222 |     F23,
223 |     F24,
224 |     F25,
225 |     F26,
226 |     F27,
227 |     F28,
228 |     F29,
229 |     F30,
230 |     F31,
231 |     F32,
232 |     F33,
233 |     F34,
234 |     F35,
235 |     // Don't always add keys last; add them to the group they best belong to.
236 | }
237 | 


--------------------------------------------------------------------------------
/src/filter.rs:
--------------------------------------------------------------------------------
  1 | use crate::Node;
  2 | use accesskit::Role;
  3 | use std::fmt::{Debug, Formatter};
  4 | 
  5 | /// Create an empty filter.
  6 | /// Convenience function for [`By::new`].
  7 | pub fn by<'a>() -> By<'a> {
  8 |     By::new()
  9 | }
 10 | 
 11 | /// A filter for nodes.
 12 | /// The filters are combined with a logical AND.
 13 | pub struct By<'a> {
 14 |     label: Option<&'a str>,
 15 |     label_contains: bool,
 16 |     include_labels: bool,
 17 |     #[allow(clippy::type_complexity)]
 18 |     predicate: Option) -> bool + 'a>>,
 19 |     had_predicate: bool,
 20 |     role: Option,
 21 |     value: Option<&'a str>,
 22 |     pub(crate) recursive: bool,
 23 | }
 24 | 
 25 | impl<'a> Debug for By<'a> {
 26 |     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
 27 |         let By {
 28 |             label,
 29 |             label_contains,
 30 |             include_labels,
 31 |             predicate,
 32 |             had_predicate,
 33 |             role,
 34 |             value,
 35 |             recursive,
 36 |         } = self;
 37 |         let mut s = f.debug_struct("By");
 38 |         if let Some(label) = label {
 39 |             if *label_contains {
 40 |                 s.field("label_contains", &label);
 41 |             } else {
 42 |                 s.field("label", &label);
 43 |             }
 44 |         }
 45 |         if *include_labels {
 46 |             s.field("include_labels", &true);
 47 |         }
 48 |         if predicate.is_some() || *had_predicate {
 49 |             s.field("predicate", &"");
 50 |         }
 51 |         if let Some(role) = role {
 52 |             s.field("role", &role);
 53 |         }
 54 |         if let Some(value) = value {
 55 |             s.field("value", &value);
 56 |         }
 57 |         if !*recursive {
 58 |             s.field("recursive", recursive);
 59 |         }
 60 |         s.finish()
 61 |     }
 62 | }
 63 | 
 64 | impl<'a> Default for By<'a> {
 65 |     fn default() -> Self {
 66 |         Self::new()
 67 |     }
 68 | }
 69 | 
 70 | impl<'a> By<'a> {
 71 |     /// Create an empty filter.
 72 |     pub fn new() -> Self {
 73 |         Self {
 74 |             label: None,
 75 |             label_contains: false,
 76 |             include_labels: false,
 77 |             predicate: None,
 78 |             had_predicate: false,
 79 |             role: None,
 80 |             value: None,
 81 |             recursive: true,
 82 |         }
 83 |     }
 84 | 
 85 |     /// Filter by the label of the node with an exact match.
 86 |     ///
 87 |     /// Note that [in AccessKit](https://docs.rs/accesskit/latest/accesskit/struct.Node.html#method.label),
 88 |     /// a widget with `Role::Label`, stores it's label in `Node::value`.
 89 |     /// We check for this and use the value if the role is `Role::Label`.
 90 |     pub fn label(mut self, label: &'a str) -> Self {
 91 |         self.label = Some(label);
 92 |         self
 93 |     }
 94 | 
 95 |     /// Filter by the label of the node with a substring match.
 96 |     ///
 97 |     /// Note that [in AccessKit](https://docs.rs/accesskit/latest/accesskit/struct.Node.html#method.label),
 98 |     /// a widget with `Role::Label`, stores it's label in `Node::value`.
 99 |     /// We check for this and use the value if the role is `Role::Label`.
100 |     pub fn label_contains(mut self, label: &'a str) -> Self {
101 |         self.label = Some(label);
102 |         self.label_contains = true;
103 |         self
104 |     }
105 | 
106 |     /// If a node is labelled by another node, should the label node be included in the results?
107 |     /// Default is false.
108 |     pub fn include_labels(mut self) -> Self {
109 |         self.include_labels = true;
110 |         self
111 |     }
112 | 
113 |     /// Filter by a custom predicate.
114 |     pub fn predicate(mut self, predicate: impl Fn(&Node<'_>) -> bool + 'a) -> Self {
115 |         self.predicate = Some(Box::new(predicate));
116 |         self.had_predicate = true;
117 |         self
118 |     }
119 | 
120 |     /// Filter by the role of the node.
121 |     pub fn role(mut self, role: Role) -> Self {
122 |         self.role = Some(role);
123 |         self
124 |     }
125 | 
126 |     /// Filter by the value of the node with an exact match.
127 |     pub fn value(mut self, value: &'a str) -> Self {
128 |         self.value = Some(value);
129 |         self
130 |     }
131 | 
132 |     /// Should we search recursively?
133 |     /// Default is true.
134 |     pub fn recursive(mut self, recursive: bool) -> Self {
135 |         self.recursive = recursive;
136 |         self
137 |     }
138 | 
139 |     /// Should the labels of labelled nodes be filtered?
140 |     pub(crate) fn should_filter_labels(&self) -> bool {
141 |         !self.include_labels && self.label.is_some()
142 |     }
143 | 
144 |     /// Since we can't clone the predicate, we can't implement Clone for By.
145 |     /// Since we still need to clone By in some cases to show debug info, and since the predicate
146 |     /// can't be shown in debug info anyway, we just don't clone the predicate and
147 |     /// just remember if we had one.
148 |     pub(crate) fn debug_clone_without_predicate(&self) -> Self {
149 |         Self {
150 |             label: self.label,
151 |             label_contains: self.label_contains,
152 |             include_labels: self.include_labels,
153 |             predicate: None,
154 |             had_predicate: self.had_predicate,
155 |             role: self.role,
156 |             value: self.value,
157 |             recursive: self.recursive,
158 |         }
159 |     }
160 | 
161 |     /// Returns true if the given node matches this filter.
162 |     /// Note: For correct filtering if [`Self::include_labels`] is false, the tree should be
163 |     /// filtered like in [`crate::Queryable::query_all`].
164 |     /// Note: Remember to check for recursive filtering
165 |     pub(crate) fn matches(&self, node: &Node<'_>) -> bool {
166 |         if let Some(label) = self.label {
167 |             // In AccessKit, a widget with `Role::Label`, stores it's label in `Node::value`.
168 |             let node_label = if node.role() == Role::Label {
169 |                 node.value()
170 |             } else {
171 |                 node.label()
172 |             };
173 | 
174 |             if let Some(node_label) = node_label {
175 |                 if self.label_contains {
176 |                     if !node_label.contains(label) {
177 |                         return false;
178 |                     }
179 |                 } else if node_label != label {
180 |                     return false;
181 |                 }
182 |             } else {
183 |                 return false;
184 |             }
185 |         }
186 | 
187 |         if let Some(predicate) = &self.predicate {
188 |             if !predicate(node) {
189 |                 return false;
190 |             }
191 |         }
192 | 
193 |         if let Some(role) = self.role {
194 |             if node.role() != role {
195 |                 return false;
196 |             }
197 |         }
198 | 
199 |         if let Some(value) = self.value {
200 |             if let Some(node_value) = node.value() {
201 |                 if node_value != value {
202 |                     return false;
203 |                 }
204 |             } else {
205 |                 return false;
206 |             }
207 |         }
208 | 
209 |         true
210 |     }
211 | }
212 | 


--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
 1 | #![allow(clippy::doc_markdown)]
 2 | #![doc = include_str!("../README.md")]
 3 | mod event;
 4 | mod filter;
 5 | mod node;
 6 | mod query;
 7 | mod state;
 8 | 
 9 | pub use event::*;
10 | pub use filter::*;
11 | pub use node::*;
12 | pub use query::*;
13 | pub use state::*;
14 | 


--------------------------------------------------------------------------------
/src/node.rs:
--------------------------------------------------------------------------------
  1 | use crate::event::{Event, SimulatedEvent};
  2 | use crate::query::Queryable;
  3 | use crate::state::EventQueue;
  4 | use crate::{by, ElementState, Key, MouseButton};
  5 | use accesskit::{ActionRequest, Vec2};
  6 | use std::fmt::{Debug, Formatter};
  7 | use std::ops::Deref;
  8 | 
  9 | /// A node in the accessibility tree. This should correspond to a widget or container in the GUI
 10 | #[derive(Copy, Clone)]
 11 | pub struct Node<'tree> {
 12 |     node: accesskit_consumer::Node<'tree>,
 13 |     pub(crate) queue: &'tree EventQueue,
 14 | }
 15 | 
 16 | impl<'a> Debug for Node<'a> {
 17 |     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
 18 |         let mut s = f.debug_struct("Node");
 19 |         s.field("id", &self.node.id());
 20 |         s.field("role", &self.node.role());
 21 |         if let Some(label) = self.node.label() {
 22 |             s.field("label", &label);
 23 |         }
 24 |         if let Some(value) = self.node.value() {
 25 |             s.field("value", &value);
 26 |         }
 27 |         if let Some(numeric) = self.node.numeric_value() {
 28 |             s.field("numeric_value", &numeric);
 29 |         }
 30 |         s.field("focused", &self.node.is_focused());
 31 |         s.field("hidden", &self.node.is_hidden());
 32 |         s.field("disabled", &self.node.is_disabled());
 33 |         if let Some(toggled) = self.node.toggled() {
 34 |             s.field("toggled", &toggled);
 35 |         }
 36 | 
 37 |         let children = self
 38 |             .query_all(by().recursive(false))
 39 |             .collect::>>();
 40 | 
 41 |         s.field("children", &children);
 42 | 
 43 |         s.finish()
 44 |     }
 45 | }
 46 | 
 47 | /// We should probably add our own methods to query the node state but for now this should work
 48 | impl<'tree> Deref for Node<'tree> {
 49 |     type Target = accesskit_consumer::Node<'tree>;
 50 | 
 51 |     fn deref(&self) -> &Self::Target {
 52 |         &self.node
 53 |     }
 54 | }
 55 | 
 56 | impl<'tree> Node<'tree> {
 57 |     /// Create a new node from an [`accesskit_consumer::Node`] and an [`EventQueue`]
 58 |     pub(crate) fn new(node: accesskit_consumer::Node<'tree>, queue: &'tree EventQueue) -> Self {
 59 |         Self { node, queue }
 60 |     }
 61 | 
 62 |     pub(crate) fn queue<'node>(&'node self) -> &'tree EventQueue {
 63 |         self.queue
 64 |     }
 65 | 
 66 |     fn event(&self, event: Event) {
 67 |         self.queue.lock().push(event);
 68 |     }
 69 | 
 70 |     /// Request focus for the node via accesskit
 71 |     pub fn focus(&self) {
 72 |         self.event(Event::ActionRequest(ActionRequest {
 73 |             data: None,
 74 |             action: accesskit::Action::Focus,
 75 |             target: self.node.id(),
 76 |         }));
 77 |     }
 78 | 
 79 |     /// Click the node via accesskit. This will trigger a [`accesskit::Action::Click`] action
 80 |     pub fn click(&self) {
 81 |         self.event(Event::ActionRequest(ActionRequest {
 82 |             data: None,
 83 |             action: accesskit::Action::Click,
 84 |             target: self.node.id(),
 85 |         }));
 86 |     }
 87 | 
 88 |     /// Hover the cursor at the node center
 89 |     pub fn hover(&self) {
 90 |         let rect = self.node.raw_bounds().expect("Node has no bounds");
 91 |         let center = Vec2::new((rect.x0 + rect.x1) / 2.0, (rect.y0 + rect.y1) / 2.0);
 92 |         self.event(Event::Simulated(SimulatedEvent::CursorMoved {
 93 |             position: center,
 94 |         }));
 95 |     }
 96 | 
 97 |     /// Simulate a click event at the node center
 98 |     pub fn simulate_click(&self) {
 99 |         self.hover();
100 |         ElementState::click().for_each(|state| {
101 |             self.event(Event::Simulated(SimulatedEvent::MouseInput {
102 |                 button: MouseButton::Left,
103 |                 state,
104 |             }));
105 |         });
106 |     }
107 | 
108 |     /// Focus the node and type the given text
109 |     pub fn type_text(&self, text: impl Into) {
110 |         self.focus();
111 |         self.event(Event::Simulated(SimulatedEvent::Ime(text.into())));
112 |     }
113 | 
114 |     /// Press the given keys in combination
115 |     ///
116 |     /// For e.g. [`Key::Control`] + [`Key::A`] this would generate:
117 |     /// - Press [`Key::Control`]
118 |     /// - Press [`Key::A`]
119 |     /// - Release [`Key::A`]
120 |     /// - Release [`Key::Control`]
121 |     pub fn key_combination(&self, keys: &[Key]) {
122 |         self.focus();
123 |         for key in keys {
124 |             self.event(Event::Simulated(SimulatedEvent::KeyInput {
125 |                 key: *key,
126 |                 state: ElementState::Pressed,
127 |             }));
128 |         }
129 |         keys.iter().rev().for_each(|key| {
130 |             self.event(Event::Simulated(SimulatedEvent::KeyInput {
131 |                 key: *key,
132 |                 state: ElementState::Released,
133 |             }));
134 |         });
135 |     }
136 | 
137 |     /// Press the given keys
138 |     /// For e.g. [`Key::Control`] + [`Key::A`] this would generate:
139 |     /// - Press [`Key::Control`]
140 |     /// - Release [`Key::Control`]
141 |     /// - Press [`Key::A`]
142 |     /// - Release [`Key::A`]
143 |     pub fn press_keys(&self, keys: &[Key]) {
144 |         self.focus();
145 |         for key in keys {
146 |             ElementState::click().for_each(|state| {
147 |                 self.event(Event::Simulated(SimulatedEvent::KeyInput {
148 |                     key: *key,
149 |                     state,
150 |                 }));
151 |             });
152 |         }
153 |     }
154 | 
155 |     /// Press the given key
156 |     pub fn key_down(&self, key: Key) {
157 |         self.focus();
158 |         self.event(Event::Simulated(SimulatedEvent::KeyInput {
159 |             key,
160 |             state: ElementState::Pressed,
161 |         }));
162 |     }
163 | 
164 |     /// Release the given key
165 |     pub fn key_up(&self, key: Key) {
166 |         self.focus();
167 |         self.event(Event::Simulated(SimulatedEvent::KeyInput {
168 |             key,
169 |             state: ElementState::Released,
170 |         }));
171 |     }
172 | 
173 |     /// Press and release the given key
174 |     pub fn key_press(&self, key: Key) {
175 |         self.focus();
176 |         ElementState::click().for_each(|state| {
177 |             self.event(Event::Simulated(SimulatedEvent::KeyInput { key, state }));
178 |         });
179 |     }
180 | 
181 |     /// Get the parent of the node
182 |     pub fn parent(&self) -> Option {
183 |         self.node.parent().map(|node| Node::new(node, self.queue))
184 |     }
185 | }
186 | 
187 | impl<'t, 'n> Queryable<'t, 'n> for Node<'t> {
188 |     fn node(&'n self) -> Self {
189 |         Node::new(self.node, self.queue)
190 |     }
191 | }
192 | 


--------------------------------------------------------------------------------
/src/query.rs:
--------------------------------------------------------------------------------
  1 | use crate::filter::By;
  2 | use crate::Node;
  3 | use std::collections::BTreeSet;
  4 | use std::iter::{once, FusedIterator};
  5 | 
  6 | fn children_recursive(node: Node<'_>) -> Box> + '_> {
  7 |     let queue = node.queue();
  8 |     Box::new(node.children().flat_map(move |node| {
  9 |         let node = Node::new(node, queue);
 10 |         once(node).chain(children_recursive(node))
 11 |     }))
 12 | }
 13 | 
 14 | fn children(node: Node<'_>) -> impl Iterator> + '_ {
 15 |     let queue = node.queue();
 16 |     node.children().map(move |node| Node::new(node, queue))
 17 | }
 18 | 
 19 | fn children_maybe_recursive(
 20 |     node: Node<'_>,
 21 |     recursive: bool,
 22 | ) -> Box> + '_> {
 23 |     if recursive {
 24 |         children_recursive(node)
 25 |     } else {
 26 |         Box::new(children(node))
 27 |     }
 28 | }
 29 | 
 30 | #[allow(clippy::needless_pass_by_value)]
 31 | #[track_caller]
 32 | fn query_all<'tree>(
 33 |     node: Node<'tree>,
 34 |     by: By<'tree>,
 35 | ) -> impl DoubleEndedIterator> + FusedIterator> + 'tree {
 36 |     let should_filter_labels = by.should_filter_labels();
 37 | 
 38 |     let results = children_maybe_recursive(node, by.recursive).filter(move |node| by.matches(node));
 39 | 
 40 |     let nodes = results.collect::>();
 41 | 
 42 |     // If the widget label is provided by a different node, both will have the same label.
 43 |     // We only want to return the node that is labelled by the other node, not the label node.
 44 |     // (This matches the behavior of the testing-library getByLabelText query.)
 45 |     let labels: BTreeSet<_> = if should_filter_labels {
 46 |         nodes
 47 |             .iter()
 48 |             // TODO(lucas): It would be nicer if we could just get ids via something like labelled_by_ids
 49 |             .flat_map(|node| node.labelled_by())
 50 |             .map(|node| node.id())
 51 |             .collect()
 52 |     } else {
 53 |         BTreeSet::new()
 54 |     };
 55 | 
 56 |     nodes.into_iter().filter(move |node| {
 57 |         if should_filter_labels {
 58 |             !labels.contains(&node.id())
 59 |         } else {
 60 |             true
 61 |         }
 62 |     })
 63 | }
 64 | 
 65 | #[allow(clippy::needless_pass_by_value)]
 66 | #[track_caller]
 67 | fn get_all<'tree>(
 68 |     node: Node<'tree>,
 69 |     by: By<'tree>,
 70 | ) -> impl DoubleEndedIterator> + FusedIterator> + 'tree {
 71 |     let debug_query = by.debug_clone_without_predicate();
 72 |     let mut iter = query_all(node, by).peekable();
 73 |     assert!(
 74 |         iter.peek().is_some(),
 75 |         "No nodes found matching the query:\n{debug_query:#?}\n\nOn node:\n{node:#?}"
 76 |     );
 77 |     iter
 78 | }
 79 | 
 80 | #[allow(clippy::needless_pass_by_value)]
 81 | #[track_caller]
 82 | fn query<'tree>(node: Node<'tree>, by: By<'tree>) -> Option> {
 83 |     let debug_query = by.debug_clone_without_predicate();
 84 |     let mut iter = query_all(node, by);
 85 |     let result = iter.next();
 86 | 
 87 |     if let Some(second) = iter.next() {
 88 |         let first = result?;
 89 |         panic!(
 90 |             "Found two or more nodes matching the query: \n{debug_query:#?}\n\nFirst node:\n{first:#?}\n\nSecond node: {second:#?}\
 91 |                 \n\nIf you were expecting multiple nodes, use query_all instead of query."
 92 |         );
 93 |     }
 94 |     result
 95 | }
 96 | 
 97 | #[allow(clippy::needless_pass_by_value)]
 98 | #[track_caller]
 99 | fn get<'tree>(node: Node<'tree>, by: By<'tree>) -> Node<'tree> {
100 |     let debug_query = by.debug_clone_without_predicate();
101 |     let option = query(node, by);
102 |     if let Some(node) = option {
103 |         node
104 |     } else {
105 |         panic!("No nodes found matching the query:\n{debug_query:#?}\n\nOn node:\n{node:#?}");
106 |     }
107 | }
108 | 
109 | macro_rules! impl_helper {
110 |     (
111 |         $match_doc:literal,
112 |         $query_all_label:ident,
113 |         $get_all_label:ident,
114 |         $query_label:ident,
115 |         $get_label:ident,
116 |         ($($args:ident: $arg_ty:ty),*),
117 |         $by_expr:expr,
118 |         $(#[$extra_doc:meta])*
119 |     ) => {
120 |         /// Query all nodes in the tree where
121 |         #[doc = $match_doc]
122 |         $(#[$extra_doc])*
123 |         #[track_caller]
124 |         fn $query_all_label(
125 |             &'node self, $($args: $arg_ty),*
126 |         ) -> impl DoubleEndedIterator> + FusedIterator> + 'tree {
127 |             query_all(self.node(), $by_expr)
128 |         }
129 | 
130 |         /// Get all nodes in the tree where
131 |         #[doc = $match_doc]
132 |         /// Returns at least one node.
133 |         $(#[$extra_doc])*
134 |         ///
135 |         /// # Panics
136 |         /// - if no nodes are found matching the query.
137 |         #[track_caller]
138 |         fn $get_all_label(
139 |             &'node self, $($args: $arg_ty),*
140 |         ) -> impl DoubleEndedIterator> + FusedIterator> + 'tree {
141 |             get_all(self.node(), $by_expr)
142 |         }
143 | 
144 |         /// Query a single node in the tree where
145 |         #[doc = $match_doc]
146 |         /// Returns `None` if no nodes are found.
147 |         $(#[$extra_doc])*
148 |         #[track_caller]
149 |         fn $query_label(&'node self, $($args: $arg_ty),*) -> Option> {
150 |             query(self.node(), $by_expr)
151 |         }
152 | 
153 |         /// Get a single node in the tree where
154 |         #[doc = $match_doc]
155 |         $(#[$extra_doc])*
156 |         ///
157 |         /// # Panics
158 |         /// - if no nodes are found matching the query.
159 |         /// - if more than one node is found matching the query.
160 |         #[track_caller]
161 |         fn $get_label(&'node self, $($args: $arg_ty),*) -> Node<'tree> {
162 |             get(self.node(), $by_expr)
163 |         }
164 |     };
165 | }
166 | 
167 | /// Provides convenience methods for querying nodes in the tree, inspired by .
168 | pub trait Queryable<'tree, 'node> {
169 |     fn node(&'node self) -> crate::Node<'tree>;
170 | 
171 |     impl_helper!(
172 |         "the node matches the given [`By`] filter.",
173 |         query_all,
174 |         get_all,
175 |         query,
176 |         get,
177 |         (by: By<'tree>),
178 |         by,
179 |     );
180 | 
181 |     impl_helper!(
182 |         "the node label exactly matches given label.",
183 |         query_all_by_label,
184 |         get_all_by_label,
185 |         query_by_label,
186 |         get_by_label,
187 |         (label: &'tree str),
188 |         By::new().label(label),
189 |         #[doc = ""]
190 |         #[doc = "If a node is labelled by another node, the label node will not be included in the results."]
191 |     );
192 | 
193 |     impl_helper!(
194 |         "the node label contains the given substring.",
195 |         query_all_by_label_contains,
196 |         get_all_by_label_contains,
197 |         query_by_label_contains,
198 |         get_by_label_contains,
199 |         (label: &'tree str),
200 |         By::new().label_contains(label),
201 |         #[doc = ""]
202 |         #[doc = "If a node is labelled by another node, the label node will not be included in the results."]
203 |     );
204 | 
205 |     impl_helper!(
206 |         "the node role and label exactly match the given role and label.",
207 |         query_all_by_role_and_label,
208 |         get_all_by_role_and_label,
209 |         query_by_role_and_label,
210 |         get_by_role_and_label,
211 |         (role: accesskit::Role, label: &'tree str),
212 |         By::new().role(role).label(label),
213 |         #[doc = ""]
214 |         #[doc = "If a node is labelled by another node, the label node will not be included in the results."]
215 |     );
216 | 
217 |     impl_helper!(
218 |         "the node role matches the given role.",
219 |         query_all_by_role,
220 |         get_all_by_role,
221 |         query_by_role,
222 |         get_by_role,
223 |         (role: accesskit::Role),
224 |         By::new().role(role),
225 |     );
226 | 
227 |     impl_helper!(
228 |         "the node value exactly matches the given value.",
229 |         query_all_by_value,
230 |         get_all_by_value,
231 |         query_by_value,
232 |         get_by_value,
233 |         (value: &'tree str),
234 |         By::new().value(value),
235 |     );
236 | 
237 |     impl_helper!(
238 |         "the node matches the given predicate.",
239 |         query_all_by,
240 |         get_all_by,
241 |         query_by,
242 |         get_by,
243 |         (f: impl Fn(&Node<'_>) -> bool + 'tree),
244 |         By::new().predicate(f),
245 |     );
246 | }
247 | 
248 | // TODO: query_all could be optimized by returning different iterators based on should_filter_labels
249 | //
250 | // enum QueryAll<'a, 'b, Filter: FnMut(&'b Node<'b>) -> bool, I: IterType<'a>> {
251 | //     FilterLabels(std::iter::Filter>, Filter>),
252 | //     IncludeLabels(I),
253 | // }
254 | //
255 | // impl<'a, 'b, Filter: FnMut(&'b Node<'b>) -> bool, I: IterType<'a>> Iterator for QueryAll<'a, 'b, Filter, I> {
256 | //     type Item = Node<'a>;
257 | //
258 | //     fn next(&mut self) -> Option {
259 | //         match self {
260 | //             QueryAll::FilterLabels(i) => i.next(),
261 | //             QueryAll::IncludeLabels(i) => i.next(),
262 | //         }
263 | //     }
264 | // }
265 | //
266 | // impl<'a, 'b, Filter: FnMut(&'b Node<'b>) -> bool, I: IterType<'a>> DoubleEndedIterator
267 | //     for QueryAll<'a, 'b, Filter, I>
268 | // {
269 | //     fn next_back(&mut self) -> Option {
270 | //         match self {
271 | //             QueryAll::FilterLabels(i) => i.next_back(),
272 | //             QueryAll::IncludeLabels(i) => i.next_back(),
273 | //         }
274 | //     }
275 | // }
276 | //
277 | // impl<'a, 'b, Filter: FnMut(&'b Node<'b>) -> bool, I: IterType<'a>> FusedIterator for QueryAll<'a, 'b, Filter, I> {}
278 | 
279 | // TODO: I would like to add the find_by_* methods but I'm not sure how I would update the
280 | // application from here?
281 | //
282 | // pub trait Findable<'tree, 'node, 's>: Queryable<'tree, 'node> {
283 | //     fn run(&mut self);
284 | //
285 | //     fn find_timeout(&self) -> std::time::Duration {
286 | //         std::time::Duration::from_secs(5)
287 | //     }
288 | //
289 | //     fn find_all_by(
290 | //         &'node mut self,
291 | //         f: impl Fn(&Node<'_>) -> bool + Copy + 'tree,
292 | //     ) -> impl IterType<'tree> + 'tree {
293 | //         let timeout = self.find_timeout();
294 | //         let step = timeout / 10;
295 | //
296 | //         let mut start_time = std::time::Instant::now();
297 | //
298 | //         loop {
299 | //             {
300 | //                 let node = self.node();
301 | //                 let iter = node.query_all_by(f);
302 | //                 let mut peekable = iter.peekable();
303 | //                 if !peekable.peek().is_none() {
304 | //                     return peekable;
305 | //                 }
306 | //
307 | //                 if start_time.elapsed() > timeout {
308 | //                     panic!("Timeout exceeded while waiting for node");
309 | //                 }
310 | //             }
311 | //
312 | //             std::thread::sleep(step);
313 | //         }
314 | //     }
315 | // }
316 | 


--------------------------------------------------------------------------------
/src/state.rs:
--------------------------------------------------------------------------------
 1 | use crate::event::Event;
 2 | use crate::query::Queryable;
 3 | use crate::Node;
 4 | use accesskit::TreeUpdate;
 5 | use parking_lot::Mutex;
 6 | use std::fmt::{Debug, Formatter};
 7 | 
 8 | /// The kittest state
 9 | pub struct State {
10 |     tree: accesskit_consumer::Tree,
11 |     queued_events: Mutex>,
12 | }
13 | 
14 | impl Debug for State {
15 |     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
16 |         f.debug_struct("State")
17 |             .field("tree", &self.node())
18 |             .finish_non_exhaustive()
19 |     }
20 | }
21 | 
22 | pub(crate) type EventQueue = Mutex>;
23 | 
24 | struct NoOpChangeHandler;
25 | 
26 | impl accesskit_consumer::TreeChangeHandler for NoOpChangeHandler {
27 |     fn node_added(&mut self, _node: &accesskit_consumer::Node<'_>) {}
28 | 
29 |     fn node_updated(
30 |         &mut self,
31 |         _old_node: &accesskit_consumer::Node<'_>,
32 |         _new_node: &accesskit_consumer::Node<'_>,
33 |     ) {
34 |     }
35 | 
36 |     fn focus_moved(
37 |         &mut self,
38 |         _old_node: Option<&accesskit_consumer::Node<'_>>,
39 |         _new_node: Option<&accesskit_consumer::Node<'_>>,
40 |     ) {
41 |     }
42 | 
43 |     fn node_removed(&mut self, _node: &accesskit_consumer::Node<'_>) {}
44 | }
45 | 
46 | impl State {
47 |     /// Create a new State from a `TreeUpdate`
48 |     pub fn new(update: TreeUpdate) -> Self {
49 |         Self {
50 |             tree: accesskit_consumer::Tree::new(update, true),
51 |             queued_events: Mutex::new(Vec::new()),
52 |         }
53 |     }
54 | 
55 |     /// Update the state with a new `TreeUpdate` (this should be called after each frame)
56 |     pub fn update(&mut self, update: accesskit::TreeUpdate) {
57 |         self.tree
58 |             .update_and_process_changes(update, &mut NoOpChangeHandler);
59 |     }
60 | 
61 |     /// Get the root node
62 |     pub fn root(&self) -> Node<'_> {
63 |         self.node()
64 |     }
65 | 
66 |     /// Take all queued events. (These should then be passed to the UI framework)
67 |     pub fn take_events(&self) -> Vec {
68 |         self.queued_events.lock().drain(..).collect()
69 |     }
70 | }
71 | 
72 | impl<'tree, 'node> Queryable<'tree, 'node> for State
73 | where
74 |     'node: 'tree,
75 | {
76 |     /// Return the root node
77 |     fn node(&'node self) -> Node<'tree> where {
78 |         Node::new(self.tree.state().root(), &self.queued_events)
79 |     }
80 | }
81 | 


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