├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── dist-workspace.toml └── src ├── main.rs ├── operator.rs ├── pipeline.rs ├── prompt.rs ├── queue.rs └── render.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ynqa 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: test 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/cache@v4 12 | with: 13 | path: | 14 | ~/.cargo/registry 15 | ~/.cargo/git 16 | target 17 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 18 | - uses: actions-rs/toolchain@v1 19 | with: 20 | toolchain: stable 21 | components: rustfmt, clippy 22 | - uses: actions-rs/cargo@v1 23 | with: 24 | command: fmt 25 | args: --all -- --check 26 | - uses: actions-rs/cargo@v1 27 | with: 28 | command: clippy 29 | - uses: actions-rs/cargo@v1 30 | with: 31 | command: test 32 | args: -- --nocapture --format pretty 33 | - uses: actions-rs/cargo@v1 34 | with: 35 | command: build 36 | args: --examples 37 | - uses: actions-rs/cargo@v1 38 | with: 39 | command: build 40 | args: --bins 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by dist: https://opensource.axo.dev/cargo-dist/ 2 | # 3 | # Copyright 2022-2024, axodotdev 4 | # SPDX-License-Identifier: MIT or Apache-2.0 5 | # 6 | # CI that: 7 | # 8 | # * checks for a Git Tag that looks like a release 9 | # * builds artifacts with dist (archives, installers, hashes) 10 | # * uploads those artifacts to temporary workflow zip 11 | # * on success, uploads the artifacts to a GitHub Release 12 | # 13 | # Note that the GitHub Release will be created with a generated 14 | # title/body based on your changelogs. 15 | 16 | name: Release 17 | permissions: 18 | "contents": "write" 19 | 20 | # This task will run whenever you push a git tag that looks like a version 21 | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 22 | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 23 | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 24 | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 25 | # 26 | # If PACKAGE_NAME is specified, then the announcement will be for that 27 | # package (erroring out if it doesn't have the given version or isn't dist-able). 28 | # 29 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 30 | # (dist-able) packages in the workspace with that version (this mode is 31 | # intended for workspaces with only one dist-able package, or with all dist-able 32 | # packages versioned/released in lockstep). 33 | # 34 | # If you push multiple tags at once, separate instances of this workflow will 35 | # spin up, creating an independent announcement for each one. However, GitHub 36 | # will hard limit this to 3 tags per commit, as it will assume more tags is a 37 | # mistake. 38 | # 39 | # If there's a prerelease-style suffix to the version, then the release(s) 40 | # will be marked as a prerelease. 41 | on: 42 | pull_request: 43 | push: 44 | tags: 45 | - '**[0-9]+.[0-9]+.[0-9]+*' 46 | 47 | jobs: 48 | # Run 'dist plan' (or host) to determine what tasks we need to do 49 | plan: 50 | runs-on: "ubuntu-20.04" 51 | outputs: 52 | val: ${{ steps.plan.outputs.manifest }} 53 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 54 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 55 | publishing: ${{ !github.event.pull_request }} 56 | env: 57 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | steps: 59 | - uses: actions/checkout@v4 60 | with: 61 | submodules: recursive 62 | - name: Install dist 63 | # we specify bash to get pipefail; it guards against the `curl` command 64 | # failing. otherwise `sh` won't catch that `curl` returned non-0 65 | shell: bash 66 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.28.0/cargo-dist-installer.sh | sh" 67 | - name: Cache dist 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: cargo-dist-cache 71 | path: ~/.cargo/bin/dist 72 | # sure would be cool if github gave us proper conditionals... 73 | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 74 | # functionality based on whether this is a pull_request, and whether it's from a fork. 75 | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 76 | # but also really annoying to build CI around when it needs secrets to work right.) 77 | - id: plan 78 | run: | 79 | dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 80 | echo "dist ran successfully" 81 | cat plan-dist-manifest.json 82 | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 83 | - name: "Upload dist-manifest.json" 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: artifacts-plan-dist-manifest 87 | path: plan-dist-manifest.json 88 | 89 | # Build and packages all the platform-specific things 90 | build-local-artifacts: 91 | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 92 | # Let the initial task tell us to not run (currently very blunt) 93 | needs: 94 | - plan 95 | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 96 | strategy: 97 | fail-fast: false 98 | # Target platforms/runners are computed by dist in create-release. 99 | # Each member of the matrix has the following arguments: 100 | # 101 | # - runner: the github runner 102 | # - dist-args: cli flags to pass to dist 103 | # - install-dist: expression to run to install dist on the runner 104 | # 105 | # Typically there will be: 106 | # - 1 "global" task that builds universal installers 107 | # - N "local" tasks that build each platform's binaries and platform-specific installers 108 | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 109 | runs-on: ${{ matrix.runner }} 110 | container: ${{ matrix.container && matrix.container.image || null }} 111 | env: 112 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 113 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 114 | steps: 115 | - name: enable windows longpaths 116 | run: | 117 | git config --global core.longpaths true 118 | - uses: actions/checkout@v4 119 | with: 120 | submodules: recursive 121 | - name: Install Rust non-interactively if not already installed 122 | if: ${{ matrix.container }} 123 | run: | 124 | if ! command -v cargo > /dev/null 2>&1; then 125 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 126 | echo "$HOME/.cargo/bin" >> $GITHUB_PATH 127 | fi 128 | - name: Install dist 129 | run: ${{ matrix.install_dist.run }} 130 | # Get the dist-manifest 131 | - name: Fetch local artifacts 132 | uses: actions/download-artifact@v4 133 | with: 134 | pattern: artifacts-* 135 | path: target/distrib/ 136 | merge-multiple: true 137 | - name: Install dependencies 138 | run: | 139 | ${{ matrix.packages_install }} 140 | - name: Build artifacts 141 | run: | 142 | # Actually do builds and make zips and whatnot 143 | dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 144 | echo "dist ran successfully" 145 | - id: cargo-dist 146 | name: Post-build 147 | # We force bash here just because github makes it really hard to get values up 148 | # to "real" actions without writing to env-vars, and writing to env-vars has 149 | # inconsistent syntax between shell and powershell. 150 | shell: bash 151 | run: | 152 | # Parse out what we just built and upload it to scratch storage 153 | echo "paths<> "$GITHUB_OUTPUT" 154 | dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" 155 | echo "EOF" >> "$GITHUB_OUTPUT" 156 | 157 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 158 | - name: "Upload artifacts" 159 | uses: actions/upload-artifact@v4 160 | with: 161 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 162 | path: | 163 | ${{ steps.cargo-dist.outputs.paths }} 164 | ${{ env.BUILD_MANIFEST_NAME }} 165 | 166 | # Build and package all the platform-agnostic(ish) things 167 | build-global-artifacts: 168 | needs: 169 | - plan 170 | - build-local-artifacts 171 | runs-on: "ubuntu-20.04" 172 | env: 173 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 174 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 175 | steps: 176 | - uses: actions/checkout@v4 177 | with: 178 | submodules: recursive 179 | - name: Install cached dist 180 | uses: actions/download-artifact@v4 181 | with: 182 | name: cargo-dist-cache 183 | path: ~/.cargo/bin/ 184 | - run: chmod +x ~/.cargo/bin/dist 185 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 186 | - name: Fetch local artifacts 187 | uses: actions/download-artifact@v4 188 | with: 189 | pattern: artifacts-* 190 | path: target/distrib/ 191 | merge-multiple: true 192 | - id: cargo-dist 193 | shell: bash 194 | run: | 195 | dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 196 | echo "dist ran successfully" 197 | 198 | # Parse out what we just built and upload it to scratch storage 199 | echo "paths<> "$GITHUB_OUTPUT" 200 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 201 | echo "EOF" >> "$GITHUB_OUTPUT" 202 | 203 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 204 | - name: "Upload artifacts" 205 | uses: actions/upload-artifact@v4 206 | with: 207 | name: artifacts-build-global 208 | path: | 209 | ${{ steps.cargo-dist.outputs.paths }} 210 | ${{ env.BUILD_MANIFEST_NAME }} 211 | # Determines if we should publish/announce 212 | host: 213 | needs: 214 | - plan 215 | - build-local-artifacts 216 | - build-global-artifacts 217 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 218 | if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 219 | env: 220 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 221 | runs-on: "ubuntu-20.04" 222 | outputs: 223 | val: ${{ steps.host.outputs.manifest }} 224 | steps: 225 | - uses: actions/checkout@v4 226 | with: 227 | submodules: recursive 228 | - name: Install cached dist 229 | uses: actions/download-artifact@v4 230 | with: 231 | name: cargo-dist-cache 232 | path: ~/.cargo/bin/ 233 | - run: chmod +x ~/.cargo/bin/dist 234 | # Fetch artifacts from scratch-storage 235 | - name: Fetch artifacts 236 | uses: actions/download-artifact@v4 237 | with: 238 | pattern: artifacts-* 239 | path: target/distrib/ 240 | merge-multiple: true 241 | - id: host 242 | shell: bash 243 | run: | 244 | dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 245 | echo "artifacts uploaded and released successfully" 246 | cat dist-manifest.json 247 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 248 | - name: "Upload dist-manifest.json" 249 | uses: actions/upload-artifact@v4 250 | with: 251 | # Overwrite the previous copy 252 | name: artifacts-dist-manifest 253 | path: dist-manifest.json 254 | # Create a GitHub Release while uploading all files to it 255 | - name: "Download GitHub Artifacts" 256 | uses: actions/download-artifact@v4 257 | with: 258 | pattern: artifacts-* 259 | path: artifacts 260 | merge-multiple: true 261 | - name: Cleanup 262 | run: | 263 | # Remove the granular manifests 264 | rm -f artifacts/*-dist-manifest.json 265 | - name: Create GitHub Release 266 | env: 267 | PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" 268 | ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" 269 | ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" 270 | RELEASE_COMMIT: "${{ github.sha }}" 271 | run: | 272 | # Write and read notes from a file to avoid quoting breaking things 273 | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt 274 | 275 | gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* 276 | 277 | publish-homebrew-formula: 278 | needs: 279 | - plan 280 | - host 281 | runs-on: "ubuntu-20.04" 282 | env: 283 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 284 | PLAN: ${{ needs.plan.outputs.val }} 285 | GITHUB_USER: "axo bot" 286 | GITHUB_EMAIL: "admin+bot@axo.dev" 287 | if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} 288 | steps: 289 | - uses: actions/checkout@v4 290 | with: 291 | repository: "ynqa/homebrew-tap" 292 | token: ${{ secrets.HOMEBREW_TAP_TOKEN }} 293 | # So we have access to the formula 294 | - name: Fetch homebrew formulae 295 | uses: actions/download-artifact@v4 296 | with: 297 | pattern: artifacts-* 298 | path: Formula/ 299 | merge-multiple: true 300 | # This is extra complex because you can make your Formula name not match your app name 301 | # so we need to find releases with a *.rb file, and publish with that filename. 302 | - name: Commit formula files 303 | run: | 304 | git config --global user.name "${GITHUB_USER}" 305 | git config --global user.email "${GITHUB_EMAIL}" 306 | 307 | for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do 308 | filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) 309 | name=$(echo "$filename" | sed "s/\.rb$//") 310 | version=$(echo "$release" | jq .app_version --raw-output) 311 | 312 | export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" 313 | brew update 314 | # We avoid reformatting user-provided data such as the app description and homepage. 315 | brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true 316 | 317 | git add "Formula/${filename}" 318 | git commit -m "${name} ${version}" 319 | done 320 | git push 321 | 322 | announce: 323 | needs: 324 | - plan 325 | - host 326 | - publish-homebrew-formula 327 | # use "always() && ..." to allow us to wait for all publish jobs while 328 | # still allowing individual publish jobs to skip themselves (for prereleases). 329 | # "host" however must run to completion, no skipping allowed! 330 | if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }} 331 | runs-on: "ubuntu-20.04" 332 | env: 333 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 334 | steps: 335 | - uses: actions/checkout@v4 336 | with: 337 | submodules: recursive 338 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | -------------------------------------------------------------------------------- /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, religion, or sexual identity 10 | 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 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of 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 35 | address, 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 | email (un.pensiero.vano@gmail.com). 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 86 | of 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 93 | permanent 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 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to empiriqa 2 | 3 | We welcome contributions to "empiriqa" and greatly appreciate your help in making 4 | this project even better. Here's a quick guide to get you started. 5 | 6 | ## How to Contribute 7 | 8 | 1. **Fork the Repository**: Click the "Fork" button at the top right of the 9 | [empiriqa repository](https://github.com/ynqa/empiriqa) to create a copy of the 10 | project in your GitHub account. 11 | 12 | 2. **Clone the Repository**: On your local machine, open a terminal and run the 13 | following command, replacing `` with your GitHub username: 14 | 15 | ```bash 16 | git clone https://github.com//empiriqa.git 17 | ``` 18 | 19 | 3. **Create a Branch**: Before making any changes, create a new branch for your 20 | work: 21 | 22 | ```bash 23 | git checkout -b your-branch-name 24 | ``` 25 | 26 | 4. **Make Changes**: Make your desired code changes, bug fixes, or feature 27 | additions. 28 | 29 | 5. **Commit Your Changes**: Commit your changes with a clear and concise message 30 | explaining the purpose of your contribution: 31 | 32 | ```bash 33 | git commit -m "Your commit message here" 34 | ``` 35 | 36 | 6. **Push to Your Fork**: Push your changes to your forked repository on GitHub: 37 | 38 | ```bash 39 | git push origin your-branch-name 40 | ``` 41 | 42 | 7. **Create a Pull Request (PR)**: Open the 43 | [empiriqa Pull Request page](https://github.com/ynqa/empiriqa/pulls) and click the 44 | "New Pull Request" button. Compare and create your PR by following the prompts. 45 | 46 | 8. **Review and Discuss**: Your PR will be reviewed by project maintainers, who 47 | may provide feedback or request further changes. Be prepared for discussion and 48 | updates. 49 | 50 | 9. **Merging**: Once your PR is approved and passes any necessary tests, a 51 | project maintainer will merge it into the main repository. 52 | 53 | ## Code of Conduct 54 | 55 | Please adhere to our [Code of Conduct](CODE_OF_CONDUCT.md) when participating in 56 | this project. We aim to create a respectful and inclusive community for all 57 | contributors. 58 | 59 | Thank you for considering contributing to "empiriqa"! 60 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "android-tzdata" 22 | version = "0.1.1" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 25 | 26 | [[package]] 27 | name = "android_system_properties" 28 | version = "0.1.5" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 31 | dependencies = [ 32 | "libc", 33 | ] 34 | 35 | [[package]] 36 | name = "anstream" 37 | version = "0.6.18" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 40 | dependencies = [ 41 | "anstyle", 42 | "anstyle-parse", 43 | "anstyle-query", 44 | "anstyle-wincon", 45 | "colorchoice", 46 | "is_terminal_polyfill", 47 | "utf8parse", 48 | ] 49 | 50 | [[package]] 51 | name = "anstyle" 52 | version = "1.0.10" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 55 | 56 | [[package]] 57 | name = "anstyle-parse" 58 | version = "0.2.6" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 61 | dependencies = [ 62 | "utf8parse", 63 | ] 64 | 65 | [[package]] 66 | name = "anstyle-query" 67 | version = "1.1.2" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 70 | dependencies = [ 71 | "windows-sys 0.59.0", 72 | ] 73 | 74 | [[package]] 75 | name = "anstyle-wincon" 76 | version = "3.0.7" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 79 | dependencies = [ 80 | "anstyle", 81 | "once_cell", 82 | "windows-sys 0.59.0", 83 | ] 84 | 85 | [[package]] 86 | name = "anyhow" 87 | version = "1.0.97" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" 90 | 91 | [[package]] 92 | name = "autocfg" 93 | version = "1.4.0" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 96 | 97 | [[package]] 98 | name = "backtrace" 99 | version = "0.3.74" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 102 | dependencies = [ 103 | "addr2line", 104 | "cfg-if", 105 | "libc", 106 | "miniz_oxide", 107 | "object", 108 | "rustc-demangle", 109 | "windows-targets", 110 | ] 111 | 112 | [[package]] 113 | name = "bitflags" 114 | version = "2.8.0" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" 117 | 118 | [[package]] 119 | name = "bumpalo" 120 | version = "3.17.0" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 123 | 124 | [[package]] 125 | name = "bytes" 126 | version = "1.10.0" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" 129 | 130 | [[package]] 131 | name = "cc" 132 | version = "1.2.16" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" 135 | dependencies = [ 136 | "shlex", 137 | ] 138 | 139 | [[package]] 140 | name = "cfg-if" 141 | version = "1.0.0" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 144 | 145 | [[package]] 146 | name = "chrono" 147 | version = "0.4.40" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" 150 | dependencies = [ 151 | "android-tzdata", 152 | "iana-time-zone", 153 | "js-sys", 154 | "num-traits", 155 | "wasm-bindgen", 156 | "windows-link", 157 | ] 158 | 159 | [[package]] 160 | name = "clap" 161 | version = "4.5.32" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" 164 | dependencies = [ 165 | "clap_builder", 166 | "clap_derive", 167 | ] 168 | 169 | [[package]] 170 | name = "clap_builder" 171 | version = "4.5.32" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" 174 | dependencies = [ 175 | "anstream", 176 | "anstyle", 177 | "clap_lex", 178 | "strsim", 179 | ] 180 | 181 | [[package]] 182 | name = "clap_derive" 183 | version = "4.5.32" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 186 | dependencies = [ 187 | "heck", 188 | "proc-macro2", 189 | "quote", 190 | "syn", 191 | ] 192 | 193 | [[package]] 194 | name = "clap_lex" 195 | version = "0.7.4" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 198 | 199 | [[package]] 200 | name = "colorchoice" 201 | version = "1.0.3" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 204 | 205 | [[package]] 206 | name = "core-foundation-sys" 207 | version = "0.8.7" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 210 | 211 | [[package]] 212 | name = "crossbeam-deque" 213 | version = "0.8.6" 214 | source = "registry+https://github.com/rust-lang/crates.io-index" 215 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 216 | dependencies = [ 217 | "crossbeam-epoch", 218 | "crossbeam-utils", 219 | ] 220 | 221 | [[package]] 222 | name = "crossbeam-epoch" 223 | version = "0.9.18" 224 | source = "registry+https://github.com/rust-lang/crates.io-index" 225 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 226 | dependencies = [ 227 | "crossbeam-utils", 228 | ] 229 | 230 | [[package]] 231 | name = "crossbeam-utils" 232 | version = "0.8.21" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 235 | 236 | [[package]] 237 | name = "crossterm" 238 | version = "0.28.1" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 241 | dependencies = [ 242 | "bitflags", 243 | "crossterm_winapi", 244 | "filedescriptor", 245 | "futures-core", 246 | "libc", 247 | "mio", 248 | "parking_lot", 249 | "rustix", 250 | "signal-hook", 251 | "signal-hook-mio", 252 | "winapi", 253 | ] 254 | 255 | [[package]] 256 | name = "crossterm_winapi" 257 | version = "0.9.1" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 260 | dependencies = [ 261 | "winapi", 262 | ] 263 | 264 | [[package]] 265 | name = "either" 266 | version = "1.14.0" 267 | source = "registry+https://github.com/rust-lang/crates.io-index" 268 | checksum = "b7914353092ddf589ad78f25c5c1c21b7f80b0ff8621e7c814c3485b5306da9d" 269 | 270 | [[package]] 271 | name = "endian-type" 272 | version = "0.1.2" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" 275 | 276 | [[package]] 277 | name = "epiq" 278 | version = "0.1.0" 279 | dependencies = [ 280 | "anyhow", 281 | "chrono", 282 | "clap", 283 | "crossterm", 284 | "futures", 285 | "promkit", 286 | "shlex", 287 | "strip-ansi-escapes", 288 | "tokio", 289 | ] 290 | 291 | [[package]] 292 | name = "equivalent" 293 | version = "1.0.2" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 296 | 297 | [[package]] 298 | name = "errno" 299 | version = "0.3.10" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 302 | dependencies = [ 303 | "libc", 304 | "windows-sys 0.59.0", 305 | ] 306 | 307 | [[package]] 308 | name = "filedescriptor" 309 | version = "0.8.3" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" 312 | dependencies = [ 313 | "libc", 314 | "thiserror", 315 | "winapi", 316 | ] 317 | 318 | [[package]] 319 | name = "futures" 320 | version = "0.3.31" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 323 | dependencies = [ 324 | "futures-channel", 325 | "futures-core", 326 | "futures-executor", 327 | "futures-io", 328 | "futures-sink", 329 | "futures-task", 330 | "futures-util", 331 | ] 332 | 333 | [[package]] 334 | name = "futures-channel" 335 | version = "0.3.31" 336 | source = "registry+https://github.com/rust-lang/crates.io-index" 337 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 338 | dependencies = [ 339 | "futures-core", 340 | "futures-sink", 341 | ] 342 | 343 | [[package]] 344 | name = "futures-core" 345 | version = "0.3.31" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 348 | 349 | [[package]] 350 | name = "futures-executor" 351 | version = "0.3.31" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 354 | dependencies = [ 355 | "futures-core", 356 | "futures-task", 357 | "futures-util", 358 | ] 359 | 360 | [[package]] 361 | name = "futures-io" 362 | version = "0.3.31" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 365 | 366 | [[package]] 367 | name = "futures-macro" 368 | version = "0.3.31" 369 | source = "registry+https://github.com/rust-lang/crates.io-index" 370 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 371 | dependencies = [ 372 | "proc-macro2", 373 | "quote", 374 | "syn", 375 | ] 376 | 377 | [[package]] 378 | name = "futures-sink" 379 | version = "0.3.31" 380 | source = "registry+https://github.com/rust-lang/crates.io-index" 381 | checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 382 | 383 | [[package]] 384 | name = "futures-task" 385 | version = "0.3.31" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 388 | 389 | [[package]] 390 | name = "futures-util" 391 | version = "0.3.31" 392 | source = "registry+https://github.com/rust-lang/crates.io-index" 393 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 394 | dependencies = [ 395 | "futures-channel", 396 | "futures-core", 397 | "futures-io", 398 | "futures-macro", 399 | "futures-sink", 400 | "futures-task", 401 | "memchr", 402 | "pin-project-lite", 403 | "pin-utils", 404 | "slab", 405 | ] 406 | 407 | [[package]] 408 | name = "gimli" 409 | version = "0.31.1" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 412 | 413 | [[package]] 414 | name = "hashbrown" 415 | version = "0.15.2" 416 | source = "registry+https://github.com/rust-lang/crates.io-index" 417 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 418 | 419 | [[package]] 420 | name = "heck" 421 | version = "0.5.0" 422 | source = "registry+https://github.com/rust-lang/crates.io-index" 423 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 424 | 425 | [[package]] 426 | name = "iana-time-zone" 427 | version = "0.1.61" 428 | source = "registry+https://github.com/rust-lang/crates.io-index" 429 | checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" 430 | dependencies = [ 431 | "android_system_properties", 432 | "core-foundation-sys", 433 | "iana-time-zone-haiku", 434 | "js-sys", 435 | "wasm-bindgen", 436 | "windows-core", 437 | ] 438 | 439 | [[package]] 440 | name = "iana-time-zone-haiku" 441 | version = "0.1.2" 442 | source = "registry+https://github.com/rust-lang/crates.io-index" 443 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 444 | dependencies = [ 445 | "cc", 446 | ] 447 | 448 | [[package]] 449 | name = "indexmap" 450 | version = "2.7.1" 451 | source = "registry+https://github.com/rust-lang/crates.io-index" 452 | checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" 453 | dependencies = [ 454 | "equivalent", 455 | "hashbrown", 456 | ] 457 | 458 | [[package]] 459 | name = "is_terminal_polyfill" 460 | version = "1.70.1" 461 | source = "registry+https://github.com/rust-lang/crates.io-index" 462 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 463 | 464 | [[package]] 465 | name = "itoa" 466 | version = "1.0.14" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 469 | 470 | [[package]] 471 | name = "js-sys" 472 | version = "0.3.77" 473 | source = "registry+https://github.com/rust-lang/crates.io-index" 474 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 475 | dependencies = [ 476 | "once_cell", 477 | "wasm-bindgen", 478 | ] 479 | 480 | [[package]] 481 | name = "libc" 482 | version = "0.2.169" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 485 | 486 | [[package]] 487 | name = "linux-raw-sys" 488 | version = "0.4.15" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 491 | 492 | [[package]] 493 | name = "lock_api" 494 | version = "0.4.12" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 497 | dependencies = [ 498 | "autocfg", 499 | "scopeguard", 500 | ] 501 | 502 | [[package]] 503 | name = "log" 504 | version = "0.4.26" 505 | source = "registry+https://github.com/rust-lang/crates.io-index" 506 | checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" 507 | 508 | [[package]] 509 | name = "memchr" 510 | version = "2.7.4" 511 | source = "registry+https://github.com/rust-lang/crates.io-index" 512 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 513 | 514 | [[package]] 515 | name = "miniz_oxide" 516 | version = "0.8.4" 517 | source = "registry+https://github.com/rust-lang/crates.io-index" 518 | checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" 519 | dependencies = [ 520 | "adler2", 521 | ] 522 | 523 | [[package]] 524 | name = "mio" 525 | version = "1.0.3" 526 | source = "registry+https://github.com/rust-lang/crates.io-index" 527 | checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 528 | dependencies = [ 529 | "libc", 530 | "log", 531 | "wasi", 532 | "windows-sys 0.52.0", 533 | ] 534 | 535 | [[package]] 536 | name = "nibble_vec" 537 | version = "0.1.0" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" 540 | dependencies = [ 541 | "smallvec", 542 | ] 543 | 544 | [[package]] 545 | name = "num-traits" 546 | version = "0.2.19" 547 | source = "registry+https://github.com/rust-lang/crates.io-index" 548 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 549 | dependencies = [ 550 | "autocfg", 551 | ] 552 | 553 | [[package]] 554 | name = "object" 555 | version = "0.36.7" 556 | source = "registry+https://github.com/rust-lang/crates.io-index" 557 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 558 | dependencies = [ 559 | "memchr", 560 | ] 561 | 562 | [[package]] 563 | name = "once_cell" 564 | version = "1.20.3" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" 567 | 568 | [[package]] 569 | name = "parking_lot" 570 | version = "0.12.3" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 573 | dependencies = [ 574 | "lock_api", 575 | "parking_lot_core", 576 | ] 577 | 578 | [[package]] 579 | name = "parking_lot_core" 580 | version = "0.9.10" 581 | source = "registry+https://github.com/rust-lang/crates.io-index" 582 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 583 | dependencies = [ 584 | "cfg-if", 585 | "libc", 586 | "redox_syscall", 587 | "smallvec", 588 | "windows-targets", 589 | ] 590 | 591 | [[package]] 592 | name = "pin-project-lite" 593 | version = "0.2.16" 594 | source = "registry+https://github.com/rust-lang/crates.io-index" 595 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 596 | 597 | [[package]] 598 | name = "pin-utils" 599 | version = "0.1.0" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 602 | 603 | [[package]] 604 | name = "proc-macro2" 605 | version = "1.0.93" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" 608 | dependencies = [ 609 | "unicode-ident", 610 | ] 611 | 612 | [[package]] 613 | name = "promkit" 614 | version = "0.8.0" 615 | source = "registry+https://github.com/rust-lang/crates.io-index" 616 | checksum = "44c6e59a653d881def8030e7c9a268cca224483b7dc1ad9f482fa4a1bd221a35" 617 | dependencies = [ 618 | "anyhow", 619 | "crossterm", 620 | "radix_trie", 621 | "rayon", 622 | "serde", 623 | "serde_json", 624 | "unicode-width", 625 | ] 626 | 627 | [[package]] 628 | name = "quote" 629 | version = "1.0.38" 630 | source = "registry+https://github.com/rust-lang/crates.io-index" 631 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 632 | dependencies = [ 633 | "proc-macro2", 634 | ] 635 | 636 | [[package]] 637 | name = "radix_trie" 638 | version = "0.2.1" 639 | source = "registry+https://github.com/rust-lang/crates.io-index" 640 | checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" 641 | dependencies = [ 642 | "endian-type", 643 | "nibble_vec", 644 | ] 645 | 646 | [[package]] 647 | name = "rayon" 648 | version = "1.10.0" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" 651 | dependencies = [ 652 | "either", 653 | "rayon-core", 654 | ] 655 | 656 | [[package]] 657 | name = "rayon-core" 658 | version = "1.12.1" 659 | source = "registry+https://github.com/rust-lang/crates.io-index" 660 | checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" 661 | dependencies = [ 662 | "crossbeam-deque", 663 | "crossbeam-utils", 664 | ] 665 | 666 | [[package]] 667 | name = "redox_syscall" 668 | version = "0.5.8" 669 | source = "registry+https://github.com/rust-lang/crates.io-index" 670 | checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" 671 | dependencies = [ 672 | "bitflags", 673 | ] 674 | 675 | [[package]] 676 | name = "rustc-demangle" 677 | version = "0.1.24" 678 | source = "registry+https://github.com/rust-lang/crates.io-index" 679 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 680 | 681 | [[package]] 682 | name = "rustix" 683 | version = "0.38.44" 684 | source = "registry+https://github.com/rust-lang/crates.io-index" 685 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 686 | dependencies = [ 687 | "bitflags", 688 | "errno", 689 | "libc", 690 | "linux-raw-sys", 691 | "windows-sys 0.59.0", 692 | ] 693 | 694 | [[package]] 695 | name = "rustversion" 696 | version = "1.0.20" 697 | source = "registry+https://github.com/rust-lang/crates.io-index" 698 | checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" 699 | 700 | [[package]] 701 | name = "ryu" 702 | version = "1.0.19" 703 | source = "registry+https://github.com/rust-lang/crates.io-index" 704 | checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" 705 | 706 | [[package]] 707 | name = "scopeguard" 708 | version = "1.2.0" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 711 | 712 | [[package]] 713 | name = "serde" 714 | version = "1.0.218" 715 | source = "registry+https://github.com/rust-lang/crates.io-index" 716 | checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" 717 | dependencies = [ 718 | "serde_derive", 719 | ] 720 | 721 | [[package]] 722 | name = "serde_derive" 723 | version = "1.0.218" 724 | source = "registry+https://github.com/rust-lang/crates.io-index" 725 | checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" 726 | dependencies = [ 727 | "proc-macro2", 728 | "quote", 729 | "syn", 730 | ] 731 | 732 | [[package]] 733 | name = "serde_json" 734 | version = "1.0.139" 735 | source = "registry+https://github.com/rust-lang/crates.io-index" 736 | checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6" 737 | dependencies = [ 738 | "indexmap", 739 | "itoa", 740 | "memchr", 741 | "ryu", 742 | "serde", 743 | ] 744 | 745 | [[package]] 746 | name = "shlex" 747 | version = "1.3.0" 748 | source = "registry+https://github.com/rust-lang/crates.io-index" 749 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 750 | 751 | [[package]] 752 | name = "signal-hook" 753 | version = "0.3.17" 754 | source = "registry+https://github.com/rust-lang/crates.io-index" 755 | checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" 756 | dependencies = [ 757 | "libc", 758 | "signal-hook-registry", 759 | ] 760 | 761 | [[package]] 762 | name = "signal-hook-mio" 763 | version = "0.2.4" 764 | source = "registry+https://github.com/rust-lang/crates.io-index" 765 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 766 | dependencies = [ 767 | "libc", 768 | "mio", 769 | "signal-hook", 770 | ] 771 | 772 | [[package]] 773 | name = "signal-hook-registry" 774 | version = "1.4.2" 775 | source = "registry+https://github.com/rust-lang/crates.io-index" 776 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 777 | dependencies = [ 778 | "libc", 779 | ] 780 | 781 | [[package]] 782 | name = "slab" 783 | version = "0.4.9" 784 | source = "registry+https://github.com/rust-lang/crates.io-index" 785 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 786 | dependencies = [ 787 | "autocfg", 788 | ] 789 | 790 | [[package]] 791 | name = "smallvec" 792 | version = "1.14.0" 793 | source = "registry+https://github.com/rust-lang/crates.io-index" 794 | checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" 795 | 796 | [[package]] 797 | name = "socket2" 798 | version = "0.5.8" 799 | source = "registry+https://github.com/rust-lang/crates.io-index" 800 | checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" 801 | dependencies = [ 802 | "libc", 803 | "windows-sys 0.52.0", 804 | ] 805 | 806 | [[package]] 807 | name = "strip-ansi-escapes" 808 | version = "0.2.1" 809 | source = "registry+https://github.com/rust-lang/crates.io-index" 810 | checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" 811 | dependencies = [ 812 | "vte", 813 | ] 814 | 815 | [[package]] 816 | name = "strsim" 817 | version = "0.11.1" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 820 | 821 | [[package]] 822 | name = "syn" 823 | version = "2.0.98" 824 | source = "registry+https://github.com/rust-lang/crates.io-index" 825 | checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" 826 | dependencies = [ 827 | "proc-macro2", 828 | "quote", 829 | "unicode-ident", 830 | ] 831 | 832 | [[package]] 833 | name = "thiserror" 834 | version = "1.0.69" 835 | source = "registry+https://github.com/rust-lang/crates.io-index" 836 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 837 | dependencies = [ 838 | "thiserror-impl", 839 | ] 840 | 841 | [[package]] 842 | name = "thiserror-impl" 843 | version = "1.0.69" 844 | source = "registry+https://github.com/rust-lang/crates.io-index" 845 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 846 | dependencies = [ 847 | "proc-macro2", 848 | "quote", 849 | "syn", 850 | ] 851 | 852 | [[package]] 853 | name = "tokio" 854 | version = "1.44.1" 855 | source = "registry+https://github.com/rust-lang/crates.io-index" 856 | checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" 857 | dependencies = [ 858 | "backtrace", 859 | "bytes", 860 | "libc", 861 | "mio", 862 | "parking_lot", 863 | "pin-project-lite", 864 | "signal-hook-registry", 865 | "socket2", 866 | "tokio-macros", 867 | "windows-sys 0.52.0", 868 | ] 869 | 870 | [[package]] 871 | name = "tokio-macros" 872 | version = "2.5.0" 873 | source = "registry+https://github.com/rust-lang/crates.io-index" 874 | checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 875 | dependencies = [ 876 | "proc-macro2", 877 | "quote", 878 | "syn", 879 | ] 880 | 881 | [[package]] 882 | name = "unicode-ident" 883 | version = "1.0.17" 884 | source = "registry+https://github.com/rust-lang/crates.io-index" 885 | checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" 886 | 887 | [[package]] 888 | name = "unicode-width" 889 | version = "0.2.0" 890 | source = "registry+https://github.com/rust-lang/crates.io-index" 891 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 892 | 893 | [[package]] 894 | name = "utf8parse" 895 | version = "0.2.2" 896 | source = "registry+https://github.com/rust-lang/crates.io-index" 897 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 898 | 899 | [[package]] 900 | name = "vte" 901 | version = "0.14.1" 902 | source = "registry+https://github.com/rust-lang/crates.io-index" 903 | checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" 904 | dependencies = [ 905 | "memchr", 906 | ] 907 | 908 | [[package]] 909 | name = "wasi" 910 | version = "0.11.0+wasi-snapshot-preview1" 911 | source = "registry+https://github.com/rust-lang/crates.io-index" 912 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 913 | 914 | [[package]] 915 | name = "wasm-bindgen" 916 | version = "0.2.100" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 919 | dependencies = [ 920 | "cfg-if", 921 | "once_cell", 922 | "rustversion", 923 | "wasm-bindgen-macro", 924 | ] 925 | 926 | [[package]] 927 | name = "wasm-bindgen-backend" 928 | version = "0.2.100" 929 | source = "registry+https://github.com/rust-lang/crates.io-index" 930 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 931 | dependencies = [ 932 | "bumpalo", 933 | "log", 934 | "proc-macro2", 935 | "quote", 936 | "syn", 937 | "wasm-bindgen-shared", 938 | ] 939 | 940 | [[package]] 941 | name = "wasm-bindgen-macro" 942 | version = "0.2.100" 943 | source = "registry+https://github.com/rust-lang/crates.io-index" 944 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 945 | dependencies = [ 946 | "quote", 947 | "wasm-bindgen-macro-support", 948 | ] 949 | 950 | [[package]] 951 | name = "wasm-bindgen-macro-support" 952 | version = "0.2.100" 953 | source = "registry+https://github.com/rust-lang/crates.io-index" 954 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 955 | dependencies = [ 956 | "proc-macro2", 957 | "quote", 958 | "syn", 959 | "wasm-bindgen-backend", 960 | "wasm-bindgen-shared", 961 | ] 962 | 963 | [[package]] 964 | name = "wasm-bindgen-shared" 965 | version = "0.2.100" 966 | source = "registry+https://github.com/rust-lang/crates.io-index" 967 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 968 | dependencies = [ 969 | "unicode-ident", 970 | ] 971 | 972 | [[package]] 973 | name = "winapi" 974 | version = "0.3.9" 975 | source = "registry+https://github.com/rust-lang/crates.io-index" 976 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 977 | dependencies = [ 978 | "winapi-i686-pc-windows-gnu", 979 | "winapi-x86_64-pc-windows-gnu", 980 | ] 981 | 982 | [[package]] 983 | name = "winapi-i686-pc-windows-gnu" 984 | version = "0.4.0" 985 | source = "registry+https://github.com/rust-lang/crates.io-index" 986 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 987 | 988 | [[package]] 989 | name = "winapi-x86_64-pc-windows-gnu" 990 | version = "0.4.0" 991 | source = "registry+https://github.com/rust-lang/crates.io-index" 992 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 993 | 994 | [[package]] 995 | name = "windows-core" 996 | version = "0.52.0" 997 | source = "registry+https://github.com/rust-lang/crates.io-index" 998 | checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" 999 | dependencies = [ 1000 | "windows-targets", 1001 | ] 1002 | 1003 | [[package]] 1004 | name = "windows-link" 1005 | version = "0.1.0" 1006 | source = "registry+https://github.com/rust-lang/crates.io-index" 1007 | checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" 1008 | 1009 | [[package]] 1010 | name = "windows-sys" 1011 | version = "0.52.0" 1012 | source = "registry+https://github.com/rust-lang/crates.io-index" 1013 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1014 | dependencies = [ 1015 | "windows-targets", 1016 | ] 1017 | 1018 | [[package]] 1019 | name = "windows-sys" 1020 | version = "0.59.0" 1021 | source = "registry+https://github.com/rust-lang/crates.io-index" 1022 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1023 | dependencies = [ 1024 | "windows-targets", 1025 | ] 1026 | 1027 | [[package]] 1028 | name = "windows-targets" 1029 | version = "0.52.6" 1030 | source = "registry+https://github.com/rust-lang/crates.io-index" 1031 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1032 | dependencies = [ 1033 | "windows_aarch64_gnullvm", 1034 | "windows_aarch64_msvc", 1035 | "windows_i686_gnu", 1036 | "windows_i686_gnullvm", 1037 | "windows_i686_msvc", 1038 | "windows_x86_64_gnu", 1039 | "windows_x86_64_gnullvm", 1040 | "windows_x86_64_msvc", 1041 | ] 1042 | 1043 | [[package]] 1044 | name = "windows_aarch64_gnullvm" 1045 | version = "0.52.6" 1046 | source = "registry+https://github.com/rust-lang/crates.io-index" 1047 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1048 | 1049 | [[package]] 1050 | name = "windows_aarch64_msvc" 1051 | version = "0.52.6" 1052 | source = "registry+https://github.com/rust-lang/crates.io-index" 1053 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1054 | 1055 | [[package]] 1056 | name = "windows_i686_gnu" 1057 | version = "0.52.6" 1058 | source = "registry+https://github.com/rust-lang/crates.io-index" 1059 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1060 | 1061 | [[package]] 1062 | name = "windows_i686_gnullvm" 1063 | version = "0.52.6" 1064 | source = "registry+https://github.com/rust-lang/crates.io-index" 1065 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1066 | 1067 | [[package]] 1068 | name = "windows_i686_msvc" 1069 | version = "0.52.6" 1070 | source = "registry+https://github.com/rust-lang/crates.io-index" 1071 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1072 | 1073 | [[package]] 1074 | name = "windows_x86_64_gnu" 1075 | version = "0.52.6" 1076 | source = "registry+https://github.com/rust-lang/crates.io-index" 1077 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1078 | 1079 | [[package]] 1080 | name = "windows_x86_64_gnullvm" 1081 | version = "0.52.6" 1082 | source = "registry+https://github.com/rust-lang/crates.io-index" 1083 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1084 | 1085 | [[package]] 1086 | name = "windows_x86_64_msvc" 1087 | version = "0.52.6" 1088 | source = "registry+https://github.com/rust-lang/crates.io-index" 1089 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1090 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "epiq" 3 | version = "0.1.0" 4 | authors = ["ynqa "] 5 | edition = "2024" 6 | description = "Laboratory for pipeline construction with feedback" 7 | repository = "https://github.com/ynqa/empiriqa" 8 | license = "MIT" 9 | readme = "README.md" 10 | 11 | [dependencies] 12 | anyhow = "1.0.97" 13 | clap = { version = "4.5.32", features = ["derive"] } 14 | chrono = "0.4.40" 15 | # See https://github.com/crossterm-rs/crossterm/issues/935 16 | crossterm = { version = "0.28.1", features = ["use-dev-tty", "event-stream", "libc"] } 17 | futures = "0.3.31" 18 | promkit = "0.8.0" 19 | shlex = "1.3.0" 20 | strip-ansi-escapes = "0.2.1" 21 | tokio = { version = "1.44.1", features = ["full"] } 22 | 23 | # The profile that 'dist' will build with 24 | [profile.dist] 25 | inherits = "release" 26 | lto = "thin" 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 empiriqa authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # empiriqa 2 | 3 | [![ci](https://github.com/ynqa/empiriqa/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ynqa/empiriqa/actions/workflows/ci.yml) 4 | 5 | Laboratory for pipeline construction with feedback. 6 | 7 | ![empiriqa.gif](https://github.com/ynqa/ynqa/blob/master/demo/empiriqa.gif) 8 | 9 | ## Overview 10 | 11 | *empiriqa* (command name is `epiq`) is a tool for interactively manipulating 12 | UNIX pipelines `|`. You can individually edit, add, delete, and toggle 13 | disable/enable for each pipeline stage. It allows you to easily and 14 | efficiently experiment with data processing and analysis using commands. 15 | Additionally, you can execute commands with continuous output streams like `tail -f`. 16 | 17 | *empiriqa* can be considered a generalization of tools like 18 | [*jnv*](https://github.com/ynqa/jnv) (interactive JSON filter using jq) and 19 | [*sig*](https://github.com/ynqa/sig) (interactive grep for streaming). While *jnv* 20 | focuses on JSON data manipulation and *sig* specializes in grep searches, *empiriqa* 21 | extends the interactive approach to all UNIX pipeline operations, providing a 22 | more versatile platform for command-line experimentation. 23 | 24 | ## Installation 25 | 26 | ### Homebrew 27 | 28 | ```bash 29 | brew install ynqa/tap/epiq 30 | ``` 31 | 32 | ### Cargo 33 | 34 | ```bash 35 | cargo install epiq 36 | 37 | # Or from source (at empiriqa root) 38 | cargo install --path . 39 | ``` 40 | 41 | ### Shell 42 | 43 | ```bash 44 | curl --proto '=https' --tlsv1.2 -LsSf https://github.com/ynqa/empiriqa/releases/download/v0.1.0/epiq-installer.sh | sh 45 | ``` 46 | 47 | ## Usage 48 | 49 | ```bash 50 | % epiq -h 51 | Laboratory for pipeline construction with feedback 52 | 53 | Usage: epiq [OPTIONS] 54 | 55 | Options: 56 | --output-queue-size 57 | Set the size of the output queue [default: 1000] 58 | --event-operate-interval 59 | Event processing aggregation interval (milliseconds) [default: 32] 60 | --output-render-interval 61 | Output rendering interval (milliseconds) [default: 10] 62 | -h, --help 63 | Print help (see more with '--help') 64 | -V, --version 65 | Print version 66 | ``` 67 | 68 | ## Keymap 69 | 70 | | Key | Function | 71 | |-------------|-------------------------------| 72 | | `Enter` | Execute command | 73 | | `Ctrl+C` | Exit | 74 | | `Esc` | Toggle mouse capture | 75 | | `Ctrl+B` | Add new pipeline stage | 76 | | `Ctrl+D` | Delete current pipeline stage | 77 | | `Ctrl+X` | Disable/Enable current stage | 78 | | `↑`/`↓` | Move between stages | 79 | | `←`/`→` | Move cursor left/right | 80 | | `Ctrl+A` | Move to beginning of line | 81 | | `Ctrl+E` | Move to end of line | 82 | | `Alt+B` | Move to previous word | 83 | | `Alt+F` | Move to next word | 84 | | `Backspace` | Delete character | 85 | | `Ctrl+U` | Clear line | 86 | | `Ctrl+W` | Delete previous word | 87 | | `Alt+D` | Delete next word | 88 | 89 | ### Enter: Behavior when executing 90 | 91 | - When you press Enter key, any currently running command will be interrupted, 92 | and the new command will be executed 93 | - Error messages such as command execution failures are displayed in red at the 94 | top 95 | - If you add multiple pipeline stages, the output of each stage is automatically 96 | passed to the next stage 97 | - Similar to `|&`, both stdout and stderr are automatically processed 98 | - Output can be scrolled using the mouse wheel 99 | - ANSI escape sequences (color and formatting codes) in command output are 100 | automatically removed and displayed as plain text 101 | 102 | ### Esc: Toggling mouse capture 103 | 104 | By default, *empiriqa* captures all mouse events to provide output scrolling 105 | functionality. This specification means that operations such as text selection 106 | that are normally performed in the terminal are absorbed by the application and 107 | become unavailable. 108 | 109 | If you want to select and copy text in the terminal, follow these steps: 110 | 111 | 1. Press Esc key to disable mouse capture 112 | 2. Perform text selection and copying operations 113 | 3. If necessary, press Esc key again to re-enable mouse capture 114 | 115 | Note: While mouse capture is disabled, you cannot scroll the output. 116 | 117 | Technical background: 118 | - The backend uses `crossterm`, and the feature to selectively disable specific 119 | mouse events is being discussed in the following issue 120 | - [crossterm#640](https://github.com/crossterm-rs/crossterm/issues/640) 121 | 122 | ### Ctrl+X: Disabling/Enabling stages 123 | 124 | By pressing Ctrl+X, you can toggle the currently selected command stage between 125 | disabled and enabled. Disabled stages are skipped during pipeline execution. 126 | This is useful when you want to temporarily exclude specific commands for 127 | testing. 128 | 129 | Disabled stages are displayed with a strikethrough, making them visually 130 | distinguishable. 131 | 132 | ### Behavior when resizing 133 | 134 | When you resize the terminal window, the following automatic adjustments are 135 | made: 136 | 137 | - All panels (editor, output, notifications) are re-rendered to fit the screen 138 | size 139 | - **When height is insufficient**: If the screen height is insufficient for the 140 | number of pipeline stages, some stages will be automatically deleted 141 | - Deletion occurs in order from the most recently added stage 142 | - The main editor (first stage) is not deleted 143 | - Focus automatically moves to the main editor 144 | - This is an automatic adjustment that differs from shortcut operations 145 | intentionally performed by the user (such as adding stages with Ctrl+B, 146 | deleting stages with Ctrl+D, etc.) 147 | - Since stages deleted due to resizing cannot be restored, it is recommended to 148 | ensure sufficient screen size if you have important editing content 149 | 150 | ## Limitations 151 | 152 | After launching *empiriqa*, commands that require keyboard interaction (such as 153 | `python` and other interactive commands that require input) cannot be executed. 154 | This is because *empiriqa* itself processes keyboard inputs, so commands that 155 | require interactive input in any pipeline stage will not function properly. 156 | 157 | ## License 158 | 159 | This project is licensed under MIT. See [LICENSE](./LICENSE) for details. 160 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.28.0" 8 | # CI backends to support 9 | ci = "github" 10 | # The installers to generate for each app 11 | installers = ["shell", "homebrew"] 12 | # A GitHub repo to push Homebrew formulas to 13 | tap = "ynqa/homebrew-tap" 14 | # Target platforms to build apps for (Rust target-triple syntax) 15 | # Note: aarch64-pc-windows-msvc is excluded because it fails during build when using edition2024, 16 | # and even with edition2021, it still fails during crates build. 17 | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] 18 | # Publish jobs to run in CI 19 | publish-jobs = ["homebrew"] 20 | # Path that installers should place binaries in 21 | install-path = "CARGO_HOME" 22 | # Whether to install an updater program 23 | install-updater = true 24 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, time::Duration}; 2 | 3 | use chrono::Local; 4 | use clap::Parser; 5 | use crossterm::{ 6 | self, 7 | event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, 8 | style::Color, 9 | }; 10 | use promkit::{PaneFactory, grapheme::StyledGraphemes, text}; 11 | use tokio::sync::{broadcast, mpsc}; 12 | 13 | mod operator; 14 | mod pipeline; 15 | mod prompt; 16 | use prompt::EditorTheme; 17 | mod queue; 18 | mod render; 19 | use render::NotifyMessage; 20 | 21 | use crate::{ 22 | operator::{Buffer, EventOperator, EventStream}, 23 | pipeline::Pipeline, 24 | prompt::Prompt, 25 | render::{PaneIndex, SharedRenderer}, 26 | }; 27 | 28 | /// Laboratory for pipeline construction with feedback 29 | #[derive(Parser)] 30 | #[command(name = "epiq", version)] 31 | pub struct Args { 32 | #[arg( 33 | long, 34 | default_value = "1000", 35 | help = "Set the size of the output queue", 36 | long_help = "Sets the size of the queue that holds output from the pipeline. \ 37 | A larger value allows storing more output history, \ 38 | but increases memory usage." 39 | )] 40 | output_queue_size: usize, 41 | 42 | #[arg( 43 | long, 44 | default_value = "32", 45 | help = "Event processing aggregation interval (milliseconds)", 46 | long_help = "Specifies the time boundary in milliseconds for aggregating event operations \ 47 | (such as key inputs and mouse operations). Multiple events occurring within \ 48 | this time frame are processed together (e.g., debounce, buffering). \ 49 | Setting a smaller value improves responsiveness, but may cause internal \ 50 | processing to bottleneck when a large number of events are issued during \ 51 | scrolling or pasting. Setting an appropriate value enables efficient \ 52 | operation by buffering and processing events in batches." 53 | )] 54 | event_operate_interval: u64, 55 | 56 | #[arg( 57 | long, 58 | default_value = "10", 59 | help = "Output rendering interval (milliseconds)", 60 | long_help = "Specifies the interval in milliseconds for rendering pipeline output to the screen. \ 61 | Setting a smaller value increases the frequency of display updates, \ 62 | but may cause screen flickering due to frequent rendering operations." 63 | )] 64 | output_render_interval: u64, 65 | } 66 | 67 | #[tokio::main] 68 | async fn main() -> anyhow::Result<()> { 69 | let args = Args::parse(); 70 | 71 | crossterm::terminal::enable_raw_mode()?; 72 | crossterm::execute!( 73 | std::io::stdout(), 74 | crossterm::cursor::Hide, 75 | crossterm::event::EnableMouseCapture, 76 | )?; 77 | 78 | let mut enable_mouse_capture = true; 79 | let mut cur_pipeline: Option = None; 80 | let (event_tx, mut event_rx) = mpsc::channel(1); 81 | let event_operator = EventOperator::spawn( 82 | event_tx, 83 | tokio::time::interval(Duration::from_millis(args.event_operate_interval)), 84 | ); 85 | let shared_renderer = SharedRenderer::try_new()?; 86 | let (broadcast_event_tx, _) = broadcast::channel(1); 87 | let (broadcast_reset_tx, _) = broadcast::channel(1); 88 | 89 | let (notify_tx, notify_rx) = mpsc::channel(1); 90 | let notify_renderer = shared_renderer.clone(); 91 | let notify_stream = tokio::spawn(async move { 92 | notify_stream(text::State::default(), notify_rx, notify_renderer).await 93 | }); 94 | 95 | let (output_tx, output_rx) = mpsc::channel(1); 96 | let output_renderer = shared_renderer.clone(); 97 | let output_event_subscriber = broadcast_event_tx.subscribe(); 98 | let output_reset_subscriber = broadcast_reset_tx.subscribe(); 99 | let output_stream = tokio::spawn(async move { 100 | output_stream( 101 | queue::State::new(args.output_queue_size), 102 | output_rx, 103 | output_event_subscriber, 104 | output_reset_subscriber, 105 | output_renderer, 106 | Duration::from_millis(args.output_render_interval), 107 | ) 108 | .await 109 | }); 110 | 111 | let mut prompt = Prompt::spawn( 112 | broadcast_event_tx.subscribe(), 113 | notify_tx.clone(), 114 | // TODO: Configurable theme 115 | ( 116 | // Head theme 117 | EditorTheme { 118 | prefix: String::from("❯❯ "), 119 | prefix_fg_color: Color::DarkGreen, 120 | active_char_bg_color: Color::DarkCyan, 121 | word_break_chars: HashSet::from(['.', '|', '(', ')', '[', ']']), 122 | }, 123 | // Pipe theme 124 | EditorTheme { 125 | prefix: String::from("❚ "), 126 | prefix_fg_color: Color::DarkYellow, 127 | active_char_bg_color: Color::DarkCyan, 128 | word_break_chars: HashSet::from(['.', '|', '(', ')', '[', ']']), 129 | }, 130 | ), 131 | crossterm::terminal::size()?, 132 | shared_renderer.clone(), 133 | ); 134 | 135 | 'outer: while let Some(events) = event_rx.recv().await { 136 | for event in events { 137 | match event { 138 | EventStream::Buffer(Buffer::Other( 139 | Event::Key(KeyEvent { 140 | code: KeyCode::Char('c'), 141 | modifiers: KeyModifiers::CONTROL, 142 | kind: KeyEventKind::Press, 143 | state: KeyEventState::NONE, 144 | }), 145 | _, 146 | )) => break 'outer, 147 | // There is no way to capture ONLY mouse scroll events, 148 | // so, toggle enabling and disabling of capturing all mouse events with Esc. 149 | // https://github.com/crossterm-rs/crossterm/issues/640 150 | EventStream::Buffer(Buffer::Other( 151 | Event::Key(KeyEvent { 152 | code: KeyCode::Esc, 153 | modifiers: KeyModifiers::NONE, 154 | kind: KeyEventKind::Press, 155 | state: KeyEventState::NONE, 156 | }), 157 | times, 158 | )) => { 159 | if times % 2 != 0 { 160 | enable_mouse_capture = !enable_mouse_capture; 161 | if enable_mouse_capture { 162 | crossterm::execute!( 163 | std::io::stdout(), 164 | crossterm::event::EnableMouseCapture, 165 | )?; 166 | } else { 167 | crossterm::execute!( 168 | std::io::stdout(), 169 | crossterm::event::DisableMouseCapture, 170 | )?; 171 | } 172 | } 173 | } 174 | EventStream::Buffer(Buffer::Other( 175 | Event::Key(KeyEvent { 176 | code: KeyCode::Enter, 177 | modifiers: KeyModifiers::NONE, 178 | kind: KeyEventKind::Press, 179 | state: KeyEventState::NONE, 180 | }), 181 | _, 182 | )) => { 183 | // First of all, abort the current command if it is running. 184 | if let Some(ref mut pipeline) = cur_pipeline { 185 | pipeline.abort_all(); 186 | broadcast_reset_tx.send(())?; 187 | let _ = notify_tx.send(NotifyMessage::None).await; 188 | } 189 | 190 | match Pipeline::spawn(prompt.get_all_texts().await, output_tx.clone()) { 191 | Ok(pipeline) => { 192 | cur_pipeline = Some(pipeline); 193 | } 194 | Err(e) => { 195 | let _ = notify_tx 196 | .send(NotifyMessage::Error(format!( 197 | "Cannot spawn commands: {:?}", 198 | e 199 | ))) 200 | .await; 201 | } 202 | } 203 | } 204 | event => { 205 | broadcast_event_tx.send(event)?; 206 | } 207 | } 208 | } 209 | } 210 | 211 | event_operator.background.abort(); 212 | if let Some(mut pipeline) = cur_pipeline { 213 | pipeline.abort_all(); 214 | } 215 | prompt.background.abort(); 216 | output_stream.abort(); 217 | notify_stream.abort(); 218 | 219 | crossterm::terminal::disable_raw_mode()?; 220 | crossterm::execute!( 221 | std::io::stdout(), 222 | crossterm::cursor::Show, 223 | crossterm::event::DisableMouseCapture, 224 | )?; 225 | Ok(()) 226 | } 227 | 228 | async fn notify_stream( 229 | mut text: text::State, 230 | mut stream: mpsc::Receiver, 231 | shared_renderer: SharedRenderer, 232 | ) { 233 | while let Some(message) = stream.recv().await { 234 | text.replace(message.into()); 235 | 236 | let mut renderer = shared_renderer.lock().await; 237 | if let Ok((width, height)) = crossterm::terminal::size() { 238 | let _ = renderer 239 | .update([(PaneIndex::Notify, text.create_pane(width, height))]) 240 | .render(); 241 | } 242 | } 243 | } 244 | 245 | async fn output_stream( 246 | mut queue: queue::State, 247 | mut stdout_stream: mpsc::Receiver, 248 | mut event_stream: broadcast::Receiver, 249 | mut reset: broadcast::Receiver<()>, 250 | shared_renderer: SharedRenderer, 251 | render_interval: Duration, 252 | ) { 253 | let mut delay = tokio::time::interval(render_interval); 254 | let mut last_modified_time = Local::now(); 255 | let mut last_render_time = Local::now(); 256 | 257 | loop { 258 | tokio::select! { 259 | _ = reset.recv() => { 260 | queue.reset(); 261 | last_modified_time = Local::now(); 262 | last_render_time = Local::now(); 263 | 264 | let _ = shared_renderer.lock().await.remove([ 265 | PaneIndex::Output, 266 | ]).render(); 267 | }, 268 | _ = delay.tick() => { 269 | if last_modified_time > last_render_time { 270 | if let Ok((width, height)) = crossterm::terminal::size() { 271 | let _ = shared_renderer.lock().await.update([ 272 | (PaneIndex::Output, queue.create_pane(width, height)), 273 | ]).render(); 274 | 275 | last_render_time = Local::now(); 276 | } 277 | } 278 | }, 279 | Ok(EventStream::Buffer(Buffer::VerticalScroll(up, down))) = event_stream.recv() => { 280 | let shifted = queue.shift(up, down); 281 | if shifted { 282 | last_modified_time = Local::now(); 283 | } 284 | }, 285 | maybe_line = stdout_stream.recv() => { 286 | match maybe_line { 287 | Some(line) => { 288 | queue.push(StyledGraphemes::from(line)); 289 | last_modified_time = Local::now(); 290 | } 291 | None => { 292 | break; 293 | } 294 | } 295 | }, 296 | } 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/operator.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Borrow, fmt}; 2 | 3 | use crossterm::event::{MouseEvent, MouseEventKind}; 4 | use futures::StreamExt; 5 | use promkit::crossterm::{ 6 | self, 7 | event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, 8 | }; 9 | use tokio::{sync::mpsc, task::JoinHandle, time::Interval}; 10 | 11 | #[derive(Clone, Debug, PartialEq)] 12 | pub enum Buffer { 13 | Key(Vec), // (chars) 14 | VerticalCursor(usize, usize), // (up, down) 15 | VerticalScroll(usize, usize), // (up, down) 16 | HorizontalCursor(usize, usize), // (left, right) 17 | HorizontalScroll(usize, usize), // (left, right) 18 | Other(crossterm::event::Event, usize), // (event, count) 19 | } 20 | 21 | impl fmt::Display for Buffer { 22 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 23 | match self { 24 | Buffer::Key(chars) => write!(f, "Key({:?})", chars), 25 | Buffer::VerticalCursor(up, down) => write!(f, "VerticalCursor({}, {})", up, down), 26 | Buffer::VerticalScroll(up, down) => write!(f, "VerticalScroll({}, {})", up, down), 27 | Buffer::HorizontalCursor(left, right) => { 28 | write!(f, "HorizontalCursor({}, {})", left, right) 29 | } 30 | Buffer::HorizontalScroll(left, right) => { 31 | write!(f, "HorizontalScroll({}, {})", left, right) 32 | } 33 | Buffer::Other(event, count) => write!(f, "Other({:?}, {})", event, count), 34 | } 35 | } 36 | } 37 | 38 | #[derive(Clone, Debug, PartialEq)] 39 | pub enum Debounce { 40 | Resize(u16, u16), // (width, height) 41 | } 42 | 43 | impl fmt::Display for Debounce { 44 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 45 | match self { 46 | Debounce::Resize(width, height) => write!(f, "Resize({}, {})", width, height), 47 | } 48 | } 49 | } 50 | 51 | #[derive(Clone, Debug, PartialEq)] 52 | pub enum EventStream { 53 | Buffer(Buffer), 54 | Debounce(Debounce), 55 | } 56 | 57 | impl fmt::Display for EventStream { 58 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 59 | match self { 60 | EventStream::Buffer(buffer) => write!(f, "{}", buffer), 61 | EventStream::Debounce(debounce) => write!(f, "{}", debounce), 62 | } 63 | } 64 | } 65 | 66 | pub struct EventOperator { 67 | pub background: JoinHandle<()>, 68 | } 69 | 70 | impl EventOperator { 71 | pub fn spawn(tx: mpsc::Sender>, mut interval: Interval) -> Self { 72 | Self { 73 | background: tokio::spawn(async move { 74 | let mut event_stream = crossterm::event::EventStream::new(); 75 | let mut buf = vec![]; 76 | 77 | loop { 78 | tokio::select! { 79 | _ = interval.tick() => { 80 | let _ = tx.send(Self::operate(buf.drain(..))).await; 81 | }, 82 | Some(Ok(event)) = event_stream.next() => { 83 | buf.push(event); 84 | }, 85 | } 86 | } 87 | }), 88 | } 89 | } 90 | 91 | fn operate(events: I) -> Vec 92 | where 93 | I: IntoIterator, 94 | E: Borrow, 95 | { 96 | let mut result = Vec::new(); 97 | let mut current_chars = Vec::new(); 98 | let mut current_vertical = (0, 0); 99 | let mut current_horizontal = (0, 0); 100 | let mut current_vertical_scroll = (0, 0); 101 | let mut current_horizontal_scroll = (0, 0); 102 | let mut current_others: Option<(crossterm::event::Event, usize)> = None; 103 | let mut last_resize: Option<(u16, u16)> = None; 104 | let mut resize_index: Option = None; 105 | 106 | for event_ref in events { 107 | let event = event_ref.borrow(); 108 | match event { 109 | crossterm::event::Event::Resize(width, height) => { 110 | Self::flush_all_buffers( 111 | &mut result, 112 | &mut current_chars, 113 | &mut current_vertical, 114 | &mut current_horizontal, 115 | &mut current_vertical_scroll, 116 | &mut current_horizontal_scroll, 117 | &mut current_others, 118 | ); 119 | last_resize = Some((*width, *height)); 120 | resize_index = Some(result.len()); 121 | } 122 | event => { 123 | if let Some(ch) = Self::extract_char(event) { 124 | Self::flush_non_char_buffers( 125 | &mut result, 126 | &mut current_vertical, 127 | &mut current_horizontal, 128 | &mut current_vertical_scroll, 129 | &mut current_horizontal_scroll, 130 | &mut current_others, 131 | ); 132 | current_chars.push(ch); 133 | } else if let Some((up, down)) = Self::detect_vertical_direction(event) { 134 | Self::flush_char_buffer(&mut result, &mut current_chars); 135 | Self::flush_horizontal_buffer(&mut result, &mut current_horizontal); 136 | Self::flush_vertical_scroll_buffer( 137 | &mut result, 138 | &mut current_vertical_scroll, 139 | ); 140 | Self::flush_horizontal_scroll_buffer( 141 | &mut result, 142 | &mut current_horizontal_scroll, 143 | ); 144 | Self::flush_others_buffer(&mut result, &mut current_others); 145 | current_vertical.0 += up; 146 | current_vertical.1 += down; 147 | } else if let Some((up, down)) = Self::detect_vertical_scroll(event) { 148 | Self::flush_char_buffer(&mut result, &mut current_chars); 149 | Self::flush_vertical_buffer(&mut result, &mut current_vertical); 150 | Self::flush_horizontal_buffer(&mut result, &mut current_horizontal); 151 | Self::flush_horizontal_scroll_buffer( 152 | &mut result, 153 | &mut current_horizontal_scroll, 154 | ); 155 | Self::flush_others_buffer(&mut result, &mut current_others); 156 | current_vertical_scroll.0 += up; 157 | current_vertical_scroll.1 += down; 158 | } else if let Some((left, right)) = Self::detect_horizontal_direction(event) { 159 | Self::flush_char_buffer(&mut result, &mut current_chars); 160 | Self::flush_vertical_buffer(&mut result, &mut current_vertical); 161 | Self::flush_vertical_scroll_buffer( 162 | &mut result, 163 | &mut current_vertical_scroll, 164 | ); 165 | Self::flush_horizontal_scroll_buffer( 166 | &mut result, 167 | &mut current_horizontal_scroll, 168 | ); 169 | Self::flush_others_buffer(&mut result, &mut current_others); 170 | current_horizontal.0 += left; 171 | current_horizontal.1 += right; 172 | } else if let Some((left, right)) = Self::detect_horizontal_scroll(event) { 173 | Self::flush_char_buffer(&mut result, &mut current_chars); 174 | Self::flush_vertical_buffer(&mut result, &mut current_vertical); 175 | Self::flush_vertical_scroll_buffer( 176 | &mut result, 177 | &mut current_vertical_scroll, 178 | ); 179 | Self::flush_horizontal_buffer(&mut result, &mut current_horizontal); 180 | Self::flush_others_buffer(&mut result, &mut current_others); 181 | current_horizontal_scroll.0 += left; 182 | current_horizontal_scroll.1 += right; 183 | } else { 184 | Self::flush_char_buffer(&mut result, &mut current_chars); 185 | Self::flush_vertical_buffer(&mut result, &mut current_vertical); 186 | Self::flush_vertical_scroll_buffer( 187 | &mut result, 188 | &mut current_vertical_scroll, 189 | ); 190 | Self::flush_horizontal_buffer(&mut result, &mut current_horizontal); 191 | Self::flush_horizontal_scroll_buffer( 192 | &mut result, 193 | &mut current_horizontal_scroll, 194 | ); 195 | 196 | match &mut current_others { 197 | Some((last_event, count)) if last_event == event => { 198 | *count += 1; 199 | } 200 | _ => { 201 | Self::flush_others_buffer(&mut result, &mut current_others); 202 | current_others = Some((event.clone(), 1)); 203 | } 204 | } 205 | } 206 | } 207 | } 208 | } 209 | 210 | // Flush remaining buffers 211 | Self::flush_all_buffers( 212 | &mut result, 213 | &mut current_chars, 214 | &mut current_vertical, 215 | &mut current_horizontal, 216 | &mut current_vertical_scroll, 217 | &mut current_horizontal_scroll, 218 | &mut current_others, 219 | ); 220 | 221 | // Add the last resize event if exists at the recorded index 222 | if let (Some((width, height)), Some(idx)) = (last_resize, resize_index) { 223 | result.insert(idx, EventStream::Debounce(Debounce::Resize(width, height))); 224 | } 225 | 226 | result 227 | } 228 | 229 | fn flush_all_buffers( 230 | result: &mut Vec, 231 | chars: &mut Vec, 232 | vertical: &mut (usize, usize), 233 | horizontal: &mut (usize, usize), 234 | vertical_scroll: &mut (usize, usize), 235 | horizontal_scroll: &mut (usize, usize), 236 | others: &mut Option<(crossterm::event::Event, usize)>, 237 | ) { 238 | Self::flush_char_buffer(result, chars); 239 | Self::flush_vertical_buffer(result, vertical); 240 | Self::flush_horizontal_buffer(result, horizontal); 241 | Self::flush_vertical_scroll_buffer(result, vertical_scroll); 242 | Self::flush_horizontal_scroll_buffer(result, horizontal_scroll); 243 | Self::flush_others_buffer(result, others); 244 | } 245 | 246 | fn flush_char_buffer(result: &mut Vec, chars: &mut Vec) { 247 | if !chars.is_empty() { 248 | result.push(EventStream::Buffer(Buffer::Key(chars.clone()))); 249 | chars.clear(); 250 | } 251 | } 252 | 253 | fn flush_vertical_buffer(result: &mut Vec, vertical: &mut (usize, usize)) { 254 | if *vertical != (0, 0) { 255 | result.push(EventStream::Buffer(Buffer::VerticalCursor( 256 | vertical.0, vertical.1, 257 | ))); 258 | *vertical = (0, 0); 259 | } 260 | } 261 | 262 | fn flush_horizontal_buffer(result: &mut Vec, horizontal: &mut (usize, usize)) { 263 | if *horizontal != (0, 0) { 264 | result.push(EventStream::Buffer(Buffer::HorizontalCursor( 265 | horizontal.0, 266 | horizontal.1, 267 | ))); 268 | *horizontal = (0, 0); 269 | } 270 | } 271 | 272 | fn flush_vertical_scroll_buffer( 273 | result: &mut Vec, 274 | vertical_scroll: &mut (usize, usize), 275 | ) { 276 | if *vertical_scroll != (0, 0) { 277 | result.push(EventStream::Buffer(Buffer::VerticalScroll( 278 | vertical_scroll.0, 279 | vertical_scroll.1, 280 | ))); 281 | *vertical_scroll = (0, 0); 282 | } 283 | } 284 | 285 | fn flush_horizontal_scroll_buffer( 286 | result: &mut Vec, 287 | horizontal_scroll: &mut (usize, usize), 288 | ) { 289 | if *horizontal_scroll != (0, 0) { 290 | result.push(EventStream::Buffer(Buffer::HorizontalScroll( 291 | horizontal_scroll.0, 292 | horizontal_scroll.1, 293 | ))); 294 | *horizontal_scroll = (0, 0); 295 | } 296 | } 297 | 298 | fn flush_others_buffer( 299 | result: &mut Vec, 300 | others: &mut Option<(crossterm::event::Event, usize)>, 301 | ) { 302 | if let Some((event, count)) = others.take() { 303 | result.push(EventStream::Buffer(Buffer::Other(event, count))); 304 | } 305 | } 306 | 307 | fn flush_non_char_buffers( 308 | result: &mut Vec, 309 | vertical: &mut (usize, usize), 310 | horizontal: &mut (usize, usize), 311 | vertical_scroll: &mut (usize, usize), 312 | horizontal_scroll: &mut (usize, usize), 313 | others: &mut Option<(crossterm::event::Event, usize)>, 314 | ) { 315 | Self::flush_vertical_buffer(result, vertical); 316 | Self::flush_horizontal_buffer(result, horizontal); 317 | Self::flush_vertical_scroll_buffer(result, vertical_scroll); 318 | Self::flush_horizontal_scroll_buffer(result, horizontal_scroll); 319 | Self::flush_others_buffer(result, others); 320 | } 321 | 322 | fn extract_char(event: &crossterm::event::Event) -> Option { 323 | match event { 324 | crossterm::event::Event::Key(KeyEvent { 325 | code: KeyCode::Char(ch), 326 | modifiers: KeyModifiers::NONE, 327 | kind: KeyEventKind::Press, 328 | state: KeyEventState::NONE, 329 | }) 330 | | crossterm::event::Event::Key(KeyEvent { 331 | code: KeyCode::Char(ch), 332 | modifiers: KeyModifiers::SHIFT, 333 | kind: KeyEventKind::Press, 334 | state: KeyEventState::NONE, 335 | }) => Some(*ch), 336 | _ => None, 337 | } 338 | } 339 | 340 | fn detect_vertical_direction(event: &crossterm::event::Event) -> Option<(usize, usize)> { 341 | match event { 342 | crossterm::event::Event::Key(KeyEvent { 343 | code: KeyCode::Up, .. 344 | }) => Some((1, 0)), 345 | crossterm::event::Event::Key(KeyEvent { 346 | code: KeyCode::Down, 347 | .. 348 | }) => Some((0, 1)), 349 | _ => None, 350 | } 351 | } 352 | 353 | fn detect_vertical_scroll(event: &crossterm::event::Event) -> Option<(usize, usize)> { 354 | match event { 355 | crossterm::event::Event::Mouse(MouseEvent { 356 | kind: MouseEventKind::ScrollUp, 357 | .. 358 | }) => Some((1, 0)), 359 | crossterm::event::Event::Mouse(MouseEvent { 360 | kind: MouseEventKind::ScrollDown, 361 | .. 362 | }) => Some((0, 1)), 363 | _ => None, 364 | } 365 | } 366 | 367 | fn detect_horizontal_direction(event: &crossterm::event::Event) -> Option<(usize, usize)> { 368 | match event { 369 | crossterm::event::Event::Key(KeyEvent { 370 | code: KeyCode::Left, 371 | .. 372 | }) => Some((1, 0)), 373 | crossterm::event::Event::Key(KeyEvent { 374 | code: KeyCode::Right, 375 | .. 376 | }) => Some((0, 1)), 377 | _ => None, 378 | } 379 | } 380 | 381 | fn detect_horizontal_scroll(event: &crossterm::event::Event) -> Option<(usize, usize)> { 382 | match event { 383 | crossterm::event::Event::Mouse(MouseEvent { 384 | kind: MouseEventKind::ScrollLeft, 385 | .. 386 | }) => Some((1, 0)), 387 | crossterm::event::Event::Mouse(MouseEvent { 388 | kind: MouseEventKind::ScrollRight, 389 | .. 390 | }) => Some((0, 1)), 391 | _ => None, 392 | } 393 | } 394 | } 395 | 396 | #[cfg(test)] 397 | mod tests { 398 | use super::*; 399 | 400 | mod operate { 401 | use super::*; 402 | 403 | #[test] 404 | fn test() { 405 | // Input: 406 | // 'a', 'B', 'c', Resize(128, 128), Resize(256, 256), 407 | // Up, Down, Up, ScrollDown, ScrollUp, Left, Right, Left, 408 | // Ctrl+f, Ctrl+f, Ctrl+f, Ctrl+d, 409 | // Up, Resize(64, 64), 'd' 410 | let events = vec![ 411 | crossterm::event::Event::Key(KeyEvent { 412 | code: KeyCode::Char('a'), 413 | modifiers: KeyModifiers::NONE, 414 | kind: KeyEventKind::Press, 415 | state: KeyEventState::NONE, 416 | }), 417 | crossterm::event::Event::Key(KeyEvent { 418 | code: KeyCode::Char('B'), 419 | modifiers: KeyModifiers::SHIFT, 420 | kind: KeyEventKind::Press, 421 | state: KeyEventState::NONE, 422 | }), 423 | crossterm::event::Event::Key(KeyEvent { 424 | code: KeyCode::Char('c'), 425 | modifiers: KeyModifiers::NONE, 426 | kind: KeyEventKind::Press, 427 | state: KeyEventState::NONE, 428 | }), 429 | crossterm::event::Event::Resize(128, 128), 430 | crossterm::event::Event::Resize(256, 256), 431 | crossterm::event::Event::Key(KeyEvent { 432 | code: KeyCode::Up, 433 | modifiers: KeyModifiers::NONE, 434 | kind: KeyEventKind::Press, 435 | state: KeyEventState::NONE, 436 | }), 437 | crossterm::event::Event::Key(KeyEvent { 438 | code: KeyCode::Down, 439 | modifiers: KeyModifiers::NONE, 440 | kind: KeyEventKind::Press, 441 | state: KeyEventState::NONE, 442 | }), 443 | crossterm::event::Event::Key(KeyEvent { 444 | code: KeyCode::Up, 445 | modifiers: KeyModifiers::NONE, 446 | kind: KeyEventKind::Press, 447 | state: KeyEventState::NONE, 448 | }), 449 | crossterm::event::Event::Mouse(MouseEvent { 450 | kind: MouseEventKind::ScrollDown, 451 | modifiers: KeyModifiers::NONE, 452 | row: 0, 453 | column: 0, 454 | }), 455 | crossterm::event::Event::Mouse(MouseEvent { 456 | kind: MouseEventKind::ScrollUp, 457 | modifiers: KeyModifiers::NONE, 458 | row: 0, 459 | column: 0, 460 | }), 461 | crossterm::event::Event::Key(KeyEvent { 462 | code: KeyCode::Left, 463 | modifiers: KeyModifiers::NONE, 464 | kind: KeyEventKind::Press, 465 | state: KeyEventState::NONE, 466 | }), 467 | crossterm::event::Event::Key(KeyEvent { 468 | code: KeyCode::Right, 469 | modifiers: KeyModifiers::NONE, 470 | kind: KeyEventKind::Press, 471 | state: KeyEventState::NONE, 472 | }), 473 | crossterm::event::Event::Key(KeyEvent { 474 | code: KeyCode::Left, 475 | modifiers: KeyModifiers::NONE, 476 | kind: KeyEventKind::Press, 477 | state: KeyEventState::NONE, 478 | }), 479 | crossterm::event::Event::Key(KeyEvent { 480 | code: KeyCode::Char('f'), 481 | modifiers: KeyModifiers::CONTROL, 482 | kind: KeyEventKind::Press, 483 | state: KeyEventState::NONE, 484 | }), 485 | crossterm::event::Event::Key(KeyEvent { 486 | code: KeyCode::Char('f'), 487 | modifiers: KeyModifiers::CONTROL, 488 | kind: KeyEventKind::Press, 489 | state: KeyEventState::NONE, 490 | }), 491 | crossterm::event::Event::Key(KeyEvent { 492 | code: KeyCode::Char('f'), 493 | modifiers: KeyModifiers::CONTROL, 494 | kind: KeyEventKind::Press, 495 | state: KeyEventState::NONE, 496 | }), 497 | crossterm::event::Event::Key(KeyEvent { 498 | code: KeyCode::Char('d'), 499 | modifiers: KeyModifiers::CONTROL, 500 | kind: KeyEventKind::Press, 501 | state: KeyEventState::NONE, 502 | }), 503 | crossterm::event::Event::Key(KeyEvent { 504 | code: KeyCode::Up, 505 | modifiers: KeyModifiers::NONE, 506 | kind: KeyEventKind::Press, 507 | state: KeyEventState::NONE, 508 | }), 509 | crossterm::event::Event::Resize(64, 64), 510 | crossterm::event::Event::Key(KeyEvent { 511 | code: KeyCode::Char('d'), 512 | modifiers: KeyModifiers::NONE, 513 | kind: KeyEventKind::Press, 514 | state: KeyEventState::NONE, 515 | }), 516 | ]; 517 | 518 | let expected = vec![ 519 | EventStream::Buffer(Buffer::Key(vec!['a', 'B', 'c'])), 520 | EventStream::Buffer(Buffer::VerticalCursor(2, 1)), 521 | EventStream::Buffer(Buffer::VerticalScroll(1, 1)), 522 | EventStream::Buffer(Buffer::HorizontalCursor(2, 1)), 523 | EventStream::Buffer(Buffer::Other( 524 | crossterm::event::Event::Key(KeyEvent { 525 | code: KeyCode::Char('f'), 526 | modifiers: KeyModifiers::CONTROL, 527 | kind: KeyEventKind::Press, 528 | state: KeyEventState::NONE, 529 | }), 530 | 3, 531 | )), 532 | EventStream::Buffer(Buffer::Other( 533 | crossterm::event::Event::Key(KeyEvent { 534 | code: KeyCode::Char('d'), 535 | modifiers: KeyModifiers::CONTROL, 536 | kind: KeyEventKind::Press, 537 | state: KeyEventState::NONE, 538 | }), 539 | 1, 540 | )), 541 | EventStream::Buffer(Buffer::VerticalCursor(1, 0)), 542 | EventStream::Debounce(Debounce::Resize(64, 64)), 543 | EventStream::Buffer(Buffer::Key(vec!['d'])), 544 | ]; 545 | 546 | assert_eq!(EventOperator::operate(&events), expected); 547 | } 548 | } 549 | } 550 | -------------------------------------------------------------------------------- /src/pipeline.rs: -------------------------------------------------------------------------------- 1 | use std::{marker::PhantomData, process::Stdio}; 2 | 3 | use tokio::{ 4 | io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter, Lines}, 5 | process::{ChildStderr, ChildStdin, ChildStdout, Command}, 6 | sync::mpsc, 7 | task::JoinHandle, 8 | }; 9 | 10 | pub trait StageKind {} 11 | 12 | pub struct Head; 13 | impl StageKind for Head {} 14 | 15 | pub struct Pipe; 16 | impl StageKind for Pipe {} 17 | 18 | pub struct Stage { 19 | waiter: JoinHandle<()>, 20 | _marker: PhantomData, 21 | } 22 | 23 | fn parse_command(cmd: &str) -> anyhow::Result { 24 | let parts = shlex::split(cmd.trim()) 25 | .ok_or_else(|| anyhow::anyhow!("Failed to parse {}: invalid shell syntax", cmd))?; 26 | 27 | if parts.is_empty() { 28 | return Err(anyhow::anyhow!("The command is empty")); 29 | } 30 | 31 | let mut command = Command::new(&parts[0]); 32 | for arg in parts.iter().skip(1) { 33 | command.arg(arg); 34 | } 35 | Ok(command) 36 | } 37 | 38 | #[allow(clippy::type_complexity)] 39 | fn setup_command( 40 | mut command: Command, 41 | use_stdin: bool, 42 | ) -> anyhow::Result<( 43 | Option>, 44 | Lines>, 45 | Lines>, 46 | )> { 47 | let stdin_config = if use_stdin { 48 | Stdio::piped() 49 | } else { 50 | Stdio::null() 51 | }; 52 | 53 | let mut child = match command 54 | .stdin(stdin_config) 55 | .stdout(Stdio::piped()) 56 | .stderr(Stdio::piped()) 57 | .spawn() 58 | { 59 | Ok(child) => child, 60 | Err(e) => { 61 | if e.kind() == std::io::ErrorKind::NotFound { 62 | anyhow::bail!("Command {:?} is not found", command.as_std().get_program()); 63 | } 64 | return Err(e.into()); 65 | } 66 | }; 67 | 68 | let stdout = child 69 | .stdout 70 | .take() 71 | .ok_or_else(|| anyhow::anyhow!("stdout is not available"))?; 72 | let stderr = child 73 | .stderr 74 | .take() 75 | .ok_or_else(|| anyhow::anyhow!("stderr is not available"))?; 76 | 77 | Ok(( 78 | if use_stdin { 79 | let stdin = child 80 | .stdin 81 | .take() 82 | .ok_or_else(|| anyhow::anyhow!("stdin is not available"))?; 83 | Some(BufWriter::new(stdin)) 84 | } else { 85 | None 86 | }, 87 | BufReader::new(stdout).lines(), 88 | BufReader::new(stderr).lines(), 89 | )) 90 | } 91 | 92 | fn spawn_process_output( 93 | mut stdout_reader: Lines>, 94 | mut stderr_reader: Lines>, 95 | tx: mpsc::Sender, 96 | ) -> JoinHandle<()> { 97 | tokio::spawn(async move { 98 | loop { 99 | tokio::select! { 100 | Ok(Some(out)) = stdout_reader.next_line() => { 101 | // Remove ANSI escape sequences and properly decode the byte array as UTF-8 string 102 | let stripped = strip_ansi_escapes::strip(&out); 103 | let decoded = String::from_utf8_lossy(&stripped).into_owned(); 104 | let _ = tx.send(decoded).await; 105 | }, 106 | Ok(Some(err)) = stderr_reader.next_line() => { 107 | let _ = tx.send(err).await; 108 | }, 109 | else => { 110 | // NOTE: BufReader will be closed when the command is terminated. 111 | // Without a return here, all outputs may not be rendered correctly. 112 | // (they may not display properly unless the Enter key is pressed repeatedly) 113 | return; 114 | } 115 | } 116 | } 117 | }) 118 | } 119 | 120 | impl Stage { 121 | pub fn spawn(cmd: &str, tx: mpsc::Sender) -> anyhow::Result { 122 | let command = parse_command(cmd)?; 123 | let (_, stdout_reader, stderr_reader) = setup_command(command, false)?; 124 | 125 | Ok(Self { 126 | waiter: spawn_process_output(stdout_reader, stderr_reader, tx), 127 | _marker: PhantomData, 128 | }) 129 | } 130 | 131 | pub fn abort_if_running(&mut self) { 132 | self.waiter.abort(); 133 | } 134 | } 135 | 136 | impl Stage { 137 | pub fn spawn( 138 | cmd: &str, 139 | mut rx: mpsc::Receiver, 140 | tx: mpsc::Sender, 141 | ) -> anyhow::Result { 142 | let command = parse_command(cmd)?; 143 | let (stdin_writer, stdout_reader, stderr_reader) = setup_command(command, true)?; 144 | let mut stdin_writer = stdin_writer.expect("stdin should be available for Pipe stage"); 145 | 146 | let waiter = tokio::spawn(async move { 147 | let input_task = tokio::spawn(async move { 148 | while let Some(line) = rx.recv().await { 149 | let _ = stdin_writer 150 | .write_all(format!("{}\n", line).as_bytes()) 151 | .await; 152 | let _ = stdin_writer.flush().await; 153 | } 154 | let _ = stdin_writer.flush().await; 155 | }); 156 | 157 | let output_task = spawn_process_output(stdout_reader, stderr_reader, tx); 158 | 159 | let _ = tokio::join!(input_task, output_task); 160 | }); 161 | 162 | Ok(Self { 163 | waiter, 164 | _marker: PhantomData, 165 | }) 166 | } 167 | 168 | pub fn abort_if_running(&mut self) { 169 | self.waiter.abort(); 170 | } 171 | } 172 | 173 | pub struct Pipeline { 174 | head: Option>, 175 | pipes: Vec>, 176 | } 177 | 178 | impl Pipeline { 179 | pub fn spawn(cmds: Vec, tx: mpsc::Sender) -> anyhow::Result { 180 | if cmds.is_empty() { 181 | return Err(anyhow::anyhow!("No commands provided")); 182 | } 183 | 184 | let mut pipeline = Self { 185 | head: None, 186 | pipes: Vec::new(), 187 | }; 188 | 189 | if cmds.len() == 1 { 190 | let head = Stage::::spawn(&cmds[0], tx)?; 191 | pipeline.head = Some(head); 192 | return Ok(pipeline); 193 | } 194 | 195 | let (prev_tx, mut prev_rx) = mpsc::channel::(100); 196 | 197 | let head = Stage::::spawn(&cmds[0], prev_tx)?; 198 | pipeline.head = Some(head); 199 | 200 | for cmd in cmds.iter().take(cmds.len() - 1).skip(1) { 201 | let (next_tx, next_rx) = mpsc::channel::(100); 202 | let tx_clone = next_tx.clone(); 203 | let pipe = Stage::::spawn(cmd, prev_rx, tx_clone)?; 204 | pipeline.pipes.push(pipe); 205 | prev_rx = next_rx; 206 | } 207 | 208 | let last_pipe = Stage::::spawn(&cmds[cmds.len() - 1], prev_rx, tx)?; 209 | pipeline.pipes.push(last_pipe); 210 | 211 | Ok(pipeline) 212 | } 213 | 214 | pub fn abort_all(&mut self) { 215 | if let Some(head) = &mut self.head { 216 | head.abort_if_running(); 217 | } 218 | for pipe in &mut self.pipes { 219 | pipe.abort_if_running(); 220 | } 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/prompt.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::Ordering, 3 | collections::{BTreeMap, HashSet}, 4 | sync::Arc, 5 | }; 6 | 7 | use anyhow::bail; 8 | use crossterm::{ 9 | event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, 10 | style::{Attribute, Color}, 11 | }; 12 | use promkit::{PaneFactory, pane::Pane, style::StyleBuilder, text_editor}; 13 | use tokio::{ 14 | sync::{Mutex, broadcast, mpsc}, 15 | task::JoinHandle, 16 | }; 17 | 18 | use crate::{ 19 | operator::{Buffer, Debounce, EventStream}, 20 | render::{EditorIndex, HEAD_INDEX, NotifyMessage, PaneIndex, SharedRenderer}, 21 | }; 22 | 23 | fn edit(event: &EventStream, editor: &mut text_editor::State) { 24 | match event { 25 | // Move cursor. 26 | EventStream::Buffer(Buffer::HorizontalCursor(left, right)) => { 27 | editor.texteditor.shift(*left, *right); 28 | } 29 | EventStream::Buffer(Buffer::Other( 30 | Event::Key(KeyEvent { 31 | code: KeyCode::Char('a'), 32 | modifiers: KeyModifiers::CONTROL, 33 | kind: KeyEventKind::Press, 34 | state: KeyEventState::NONE, 35 | }), 36 | _, 37 | )) => { 38 | editor.texteditor.move_to_head(); 39 | } 40 | EventStream::Buffer(Buffer::Other( 41 | Event::Key(KeyEvent { 42 | code: KeyCode::Char('e'), 43 | modifiers: KeyModifiers::CONTROL, 44 | kind: KeyEventKind::Press, 45 | state: KeyEventState::NONE, 46 | }), 47 | _, 48 | )) => { 49 | editor.texteditor.move_to_tail(); 50 | } 51 | 52 | // Move cursor to the nearest character. 53 | EventStream::Buffer(Buffer::Other( 54 | Event::Key(KeyEvent { 55 | code: KeyCode::Char('b'), 56 | modifiers: KeyModifiers::ALT, 57 | kind: KeyEventKind::Press, 58 | state: KeyEventState::NONE, 59 | }), 60 | times, 61 | )) => { 62 | for _ in 0..*times { 63 | editor 64 | .texteditor 65 | .move_to_previous_nearest(&editor.word_break_chars); 66 | } 67 | } 68 | EventStream::Buffer(Buffer::Other( 69 | Event::Key(KeyEvent { 70 | code: KeyCode::Char('f'), 71 | modifiers: KeyModifiers::ALT, 72 | kind: KeyEventKind::Press, 73 | state: KeyEventState::NONE, 74 | }), 75 | times, 76 | )) => { 77 | for _ in 0..*times { 78 | editor 79 | .texteditor 80 | .move_to_next_nearest(&editor.word_break_chars); 81 | } 82 | } 83 | 84 | // Erase char(s). 85 | EventStream::Buffer(Buffer::Other( 86 | Event::Key(KeyEvent { 87 | code: KeyCode::Backspace, 88 | modifiers: KeyModifiers::NONE, 89 | kind: KeyEventKind::Press, 90 | state: KeyEventState::NONE, 91 | }), 92 | times, 93 | )) => { 94 | for _ in 0..*times { 95 | editor.texteditor.erase(); 96 | } 97 | } 98 | EventStream::Buffer(Buffer::Other( 99 | Event::Key(KeyEvent { 100 | code: KeyCode::Char('u'), 101 | modifiers: KeyModifiers::CONTROL, 102 | kind: KeyEventKind::Press, 103 | state: KeyEventState::NONE, 104 | }), 105 | _, 106 | )) => { 107 | editor.texteditor.erase_all(); 108 | } 109 | 110 | // Erase to the nearest character. 111 | EventStream::Buffer(Buffer::Other( 112 | Event::Key(KeyEvent { 113 | code: KeyCode::Char('w'), 114 | modifiers: KeyModifiers::CONTROL, 115 | kind: KeyEventKind::Press, 116 | state: KeyEventState::NONE, 117 | }), 118 | times, 119 | )) => { 120 | for _ in 0..*times { 121 | editor 122 | .texteditor 123 | .erase_to_previous_nearest(&editor.word_break_chars); 124 | } 125 | } 126 | EventStream::Buffer(Buffer::Other( 127 | Event::Key(KeyEvent { 128 | code: KeyCode::Char('d'), 129 | modifiers: KeyModifiers::ALT, 130 | kind: KeyEventKind::Press, 131 | state: KeyEventState::NONE, 132 | }), 133 | times, 134 | )) => { 135 | for _ in 0..*times { 136 | editor 137 | .texteditor 138 | .erase_to_next_nearest(&editor.word_break_chars); 139 | } 140 | } 141 | 142 | // Input char. 143 | EventStream::Buffer(Buffer::Key(chars)) => match editor.edit_mode { 144 | text_editor::Mode::Insert => editor.texteditor.insert_chars(chars), 145 | text_editor::Mode::Overwrite => editor.texteditor.overwrite_chars(chars), 146 | }, 147 | 148 | _ => {} 149 | } 150 | } 151 | 152 | #[derive(Clone)] 153 | pub struct EditorTheme { 154 | pub prefix: String, 155 | pub prefix_fg_color: Color, 156 | pub active_char_bg_color: Color, 157 | pub word_break_chars: HashSet, 158 | } 159 | 160 | struct Editor { 161 | state: text_editor::State, 162 | ignore: bool, 163 | } 164 | 165 | impl From for Editor { 166 | fn from(state: text_editor::State) -> Self { 167 | Self { 168 | state, 169 | ignore: false, 170 | } 171 | } 172 | } 173 | 174 | impl Editor { 175 | fn create_pane(&self, width: u16, height: u16) -> Pane { 176 | self.state.create_pane(width, height) 177 | } 178 | } 179 | 180 | struct EditorMap(BTreeMap); 181 | 182 | enum Direction { 183 | Up(usize), 184 | Down(usize), 185 | } 186 | 187 | impl Direction { 188 | fn distance(&self) -> usize { 189 | match self { 190 | Self::Up(up) => *up, 191 | Self::Down(down) => *down, 192 | } 193 | } 194 | } 195 | 196 | impl EditorMap { 197 | fn from(state: text_editor::State) -> Self { 198 | Self(BTreeMap::from_iter([( 199 | HEAD_INDEX.clone(), 200 | Editor::from(state), 201 | )])) 202 | } 203 | 204 | fn len(&self) -> usize { 205 | self.0.len() 206 | } 207 | 208 | fn get(&self, index: &EditorIndex) -> Option<&Editor> { 209 | self.0.get(index) 210 | } 211 | 212 | fn get_mut(&mut self, index: &EditorIndex) -> Option<&mut Editor> { 213 | self.0.get_mut(index) 214 | } 215 | 216 | fn insert(&mut self, index: EditorIndex, state: text_editor::State) -> Option { 217 | self.0.insert(index, Editor::from(state)) 218 | } 219 | 220 | fn pop_last(&mut self) -> Option<(EditorIndex, Editor)> { 221 | self.0.pop_last() 222 | } 223 | 224 | fn iter(&self) -> impl Iterator { 225 | self.0.iter() 226 | } 227 | 228 | fn remove(&mut self, index: &EditorIndex) -> Option { 229 | self.0.remove(index) 230 | } 231 | 232 | fn values(&self) -> impl Iterator { 233 | self.0.values() 234 | } 235 | 236 | fn last_index(&self) -> Option<&EditorIndex> { 237 | self.0.keys().last() 238 | } 239 | 240 | fn contains_key(&self, index: &EditorIndex) -> bool { 241 | self.0.contains_key(index) 242 | } 243 | 244 | fn is_last(&self, index: &EditorIndex) -> bool { 245 | if let Some(last) = self.0.keys().last() { 246 | last.0 == index.0 && last.1 == index.1 247 | } else { 248 | false 249 | } 250 | } 251 | 252 | fn new_index(&self, index: &EditorIndex) -> anyhow::Result { 253 | if self.is_last(index) { 254 | // If this is the last index, create a new index that is greater 255 | // A simple way is to add 1 to the numerator while keeping the denominator 256 | Ok(EditorIndex(index.0 + 1, index.1)) 257 | } else { 258 | Ok(EditorIndex::mediant( 259 | index, 260 | &self.seek_index(index, Direction::Down(1))?, 261 | )) 262 | } 263 | } 264 | 265 | fn shift_index( 266 | &self, 267 | index: &EditorIndex, 268 | up: usize, 269 | down: usize, 270 | ) -> anyhow::Result { 271 | match up.cmp(&down) { 272 | Ordering::Less => self.seek_index(index, Direction::Down(down.saturating_sub(up))), 273 | Ordering::Greater => self.seek_index(index, Direction::Up(up.saturating_sub(down))), 274 | Ordering::Equal => Ok(index.clone()), 275 | } 276 | } 277 | 278 | fn seek_index(&self, index: &EditorIndex, direction: Direction) -> anyhow::Result { 279 | if !self.contains_key(index) { 280 | bail!("{} not found", index); 281 | } 282 | 283 | let mut iter = match direction { 284 | Direction::Up(_) => { 285 | Box::new( 286 | self.0 287 | .keys() 288 | .rev() 289 | .skip_while(|k| !(k.0 == index.0 && k.1 == index.1)) 290 | // Skip the current index 291 | .skip(1), 292 | ) as Box> 293 | } 294 | Direction::Down(_) => { 295 | Box::new( 296 | self.0 297 | .keys() 298 | .skip_while(|k| !(k.0 == index.0 && k.1 == index.1)) 299 | // Skip the current index 300 | .skip(1), 301 | ) as Box> 302 | } 303 | }; 304 | 305 | let (mut cur, mut remaining) = (index.clone(), direction.distance()); 306 | 307 | while let Some(next) = iter.next() { 308 | if remaining == 0 { 309 | break; 310 | } 311 | 312 | cur = next.clone(); 313 | remaining -= 1; 314 | } 315 | 316 | Ok(cur) 317 | } 318 | } 319 | 320 | pub struct Prompt { 321 | // TODO: reconsider whether mutex is necessary only for get_all_texts 322 | shared_editors: Arc>, 323 | pub background: JoinHandle<()>, 324 | } 325 | 326 | impl Prompt { 327 | pub fn spawn( 328 | mut rx: broadcast::Receiver, 329 | notify_tx: mpsc::Sender, 330 | themes: (EditorTheme, EditorTheme), // (head, pipe) 331 | init_terminal_shape: (u16, u16), 332 | shared_renderer: SharedRenderer, 333 | ) -> Self { 334 | let shared_editors = Arc::new(Mutex::new(EditorMap::from(text_editor::State { 335 | prefix: themes.0.prefix.clone(), 336 | prefix_style: StyleBuilder::new().fgc(themes.0.prefix_fg_color).build(), 337 | active_char_style: StyleBuilder::new() 338 | .bgc(themes.0.active_char_bg_color) 339 | .build(), 340 | word_break_chars: themes.0.word_break_chars.clone(), 341 | ..Default::default() 342 | }))); 343 | 344 | let background = { 345 | let mut terminal_shape = init_terminal_shape; 346 | let shared_editors = shared_editors.clone(); 347 | 348 | tokio::spawn(async move { 349 | let mut cur_index = HEAD_INDEX.clone(); 350 | 351 | // Initial renderings 352 | { 353 | let (editors, mut renderer) = 354 | tokio::join!(shared_editors.lock(), shared_renderer.lock()); 355 | 356 | let _ = renderer 357 | .update(editors.iter().map(|(index, editor)| { 358 | ( 359 | PaneIndex::Editor(index.clone()), 360 | editor.create_pane(terminal_shape.0, terminal_shape.1), 361 | ) 362 | })) 363 | .render(); 364 | } 365 | 366 | loop { 367 | if let Ok(event) = rx.recv().await { 368 | match event { 369 | EventStream::Debounce(Debounce::Resize(width, height)) => { 370 | terminal_shape = (width, height); 371 | 372 | let (mut editors, mut renderer) = 373 | tokio::join!(shared_editors.lock(), shared_renderer.lock()); 374 | 375 | // Resize the editors also 376 | // Note to consider the notify and output panes... 377 | if height < editors.len() as u16 + 2 { 378 | let removals = { 379 | let times = 380 | (editors.len() + 2).saturating_sub(height as usize); 381 | Self::pop_editors(&mut editors, times) 382 | }; 383 | renderer.remove(removals.into_iter().map(PaneIndex::Editor)); 384 | 385 | // Update the current index 386 | cur_index = HEAD_INDEX.clone(); 387 | // Change theme because of switching focus 388 | Self::switch_theme(&mut editors, None, &cur_index, &themes); 389 | } 390 | 391 | renderer.update(editors.iter().map(|(index, editor)| { 392 | ( 393 | PaneIndex::Editor(index.clone()), 394 | editor.create_pane(terminal_shape.0, terminal_shape.1), 395 | ) 396 | })); 397 | } 398 | EventStream::Buffer(Buffer::Other( 399 | Event::Key(KeyEvent { 400 | code: KeyCode::Char('b'), 401 | modifiers: KeyModifiers::CONTROL, 402 | kind: KeyEventKind::Press, 403 | state: KeyEventState::NONE, 404 | }), 405 | times, 406 | )) => { 407 | let mut new_index = cur_index.clone(); 408 | let mut inserts = HashSet::from([new_index.clone()]); 409 | 410 | let mut editors = shared_editors.lock().await; 411 | // Insert new editors 412 | for _ in 0..times { 413 | // 2 represents the notify and output panes 414 | if editors.len() >= terminal_shape.1.saturating_sub(2) as usize 415 | { 416 | let _ = notify_tx 417 | .send(NotifyMessage::Error(String::from( 418 | "Cannot create more editors", 419 | ))) 420 | .await; 421 | break; 422 | } 423 | new_index = 424 | Self::insert_editor(&new_index, &mut editors, &themes.1); 425 | inserts.insert(new_index.clone()); 426 | } 427 | // Change theme because of switching focus 428 | Self::switch_theme( 429 | &mut editors, 430 | Some(&cur_index), 431 | &new_index, 432 | &themes, 433 | ); 434 | // Update changes for rendering 435 | shared_renderer.lock().await.update(inserts.into_iter().map( 436 | |index| { 437 | ( 438 | PaneIndex::Editor(index.clone()), 439 | editors 440 | .get(&index) 441 | .unwrap() 442 | .create_pane(terminal_shape.0, terminal_shape.1), 443 | ) 444 | }, 445 | )); 446 | // Update the current index 447 | cur_index = new_index; 448 | } 449 | EventStream::Buffer(Buffer::Other( 450 | Event::Key(KeyEvent { 451 | code: KeyCode::Char('d'), 452 | modifiers: KeyModifiers::CONTROL, 453 | kind: KeyEventKind::Press, 454 | state: KeyEventState::NONE, 455 | }), 456 | times, 457 | )) => { 458 | let mut prev_index = cur_index.clone(); 459 | let mut removals = HashSet::new(); 460 | 461 | { 462 | let mut editors = shared_editors.lock().await; 463 | // Remove editors 464 | for _ in 0..times { 465 | // Early return if the head editor is removed 466 | if prev_index == HEAD_INDEX { 467 | break; 468 | } 469 | removals.insert(prev_index.clone()); 470 | prev_index = Self::remove_editor(&prev_index, &mut editors); 471 | } 472 | // Change theme because of switching focus 473 | Self::switch_theme(&mut editors, None, &prev_index, &themes); 474 | } 475 | 476 | // Update changes for rendering 477 | { 478 | let mut renderer = shared_renderer.lock().await; 479 | let _ = renderer 480 | .remove(removals.into_iter().map(PaneIndex::Editor)) 481 | .update([( 482 | PaneIndex::Editor(prev_index.clone()), 483 | shared_editors 484 | .lock() 485 | .await 486 | .get(&prev_index) 487 | .unwrap() 488 | .create_pane(terminal_shape.0, terminal_shape.1), 489 | )]); 490 | } 491 | 492 | // Update the current index 493 | cur_index = prev_index; 494 | } 495 | EventStream::Buffer(Buffer::Other( 496 | Event::Key(KeyEvent { 497 | code: KeyCode::Char('x'), 498 | modifiers: KeyModifiers::CONTROL, 499 | kind: KeyEventKind::Press, 500 | state: KeyEventState::NONE, 501 | }), 502 | times, 503 | )) => { 504 | if times % 2 != 0 { 505 | let mut editors = shared_editors.lock().await; 506 | let cur_editor = editors.get_mut(&cur_index).unwrap(); 507 | cur_editor.ignore = !cur_editor.ignore; 508 | cur_editor 509 | .state 510 | .prefix_style 511 | .attributes 512 | .toggle(Attribute::CrossedOut); 513 | cur_editor 514 | .state 515 | .active_char_style 516 | .attributes 517 | .toggle(Attribute::CrossedOut); 518 | cur_editor 519 | .state 520 | .inactive_char_style 521 | .attributes 522 | .toggle(Attribute::CrossedOut); 523 | shared_renderer.lock().await.update(vec![( 524 | PaneIndex::Editor(cur_index.clone()), 525 | cur_editor.create_pane(terminal_shape.0, terminal_shape.1), 526 | )]); 527 | } 528 | } 529 | EventStream::Buffer(Buffer::VerticalCursor(up, down)) => { 530 | let mut editors = shared_editors.lock().await; 531 | // Move cursor up or down 532 | let next_index = editors.shift_index(&cur_index, up, down).unwrap(); 533 | // Change theme because of switching focus 534 | Self::switch_theme( 535 | &mut editors, 536 | Some(&cur_index), 537 | &next_index, 538 | &themes, 539 | ); 540 | // Update changes for rendering 541 | shared_renderer.lock().await.update(vec![ 542 | ( 543 | PaneIndex::Editor(cur_index.clone()), 544 | editors 545 | .get(&cur_index) 546 | .unwrap() 547 | .create_pane(terminal_shape.0, terminal_shape.1), 548 | ), 549 | ( 550 | PaneIndex::Editor(next_index.clone()), 551 | editors 552 | .get(&next_index) 553 | .unwrap() 554 | .create_pane(terminal_shape.0, terminal_shape.1), 555 | ), 556 | ]); 557 | // Update the current index 558 | cur_index = next_index; 559 | } 560 | event => { 561 | let mut editors = shared_editors.lock().await; 562 | edit(&event, &mut editors.get_mut(&cur_index).unwrap().state); 563 | shared_renderer.lock().await.update(vec![( 564 | PaneIndex::Editor(cur_index.clone()), 565 | editors 566 | .get(&cur_index) 567 | .unwrap() 568 | .create_pane(terminal_shape.0, terminal_shape.1), 569 | )]); 570 | } 571 | }; 572 | 573 | let _ = shared_renderer.lock().await.render(); 574 | } 575 | } 576 | }) 577 | }; 578 | 579 | Self { 580 | shared_editors, 581 | background, 582 | } 583 | } 584 | 585 | pub async fn get_all_texts(&mut self) -> Vec { 586 | self.shared_editors 587 | .lock() 588 | .await 589 | .values() 590 | .filter(|editor| !editor.ignore) 591 | .map(|editor| editor.state.texteditor.text_without_cursor().to_string()) 592 | .filter(|cmd| !cmd.trim().is_empty()) 593 | .collect() 594 | } 595 | 596 | fn insert_editor( 597 | cur_index: &EditorIndex, 598 | editors: &mut EditorMap, 599 | theme: &EditorTheme, 600 | ) -> EditorIndex { 601 | let new_index = editors.new_index(cur_index).unwrap(); 602 | editors.insert( 603 | new_index.clone(), 604 | text_editor::State { 605 | prefix: theme.prefix.clone(), 606 | prefix_style: StyleBuilder::new().fgc(theme.prefix_fg_color).build(), 607 | active_char_style: StyleBuilder::new().bgc(theme.active_char_bg_color).build(), 608 | word_break_chars: theme.word_break_chars.clone(), 609 | ..Default::default() 610 | }, 611 | ); 612 | new_index 613 | } 614 | 615 | fn pop_editors(editors: &mut EditorMap, times: usize) -> Vec { 616 | let mut popped = vec![]; 617 | for _ in 0..times { 618 | if editors.last_index() == Some(&HEAD_INDEX) { 619 | return popped; 620 | } 621 | popped.push(editors.pop_last().unwrap().0); 622 | } 623 | popped 624 | } 625 | 626 | fn remove_editor(cur_index: &EditorIndex, editors: &mut EditorMap) -> EditorIndex { 627 | // Do not remove the head editor 628 | if cur_index == &HEAD_INDEX { 629 | return cur_index.clone(); 630 | } 631 | 632 | // Note that we're moving the index to the previous one 633 | // because the given index is the focused editor. 634 | // If in the future we need to remove a non-focused editor, 635 | // this operation would be unnecessary. 636 | let prev_index = editors.seek_index(cur_index, Direction::Up(1)).unwrap(); 637 | 638 | editors.remove(cur_index); 639 | 640 | prev_index 641 | } 642 | 643 | fn switch_theme( 644 | editors: &mut EditorMap, 645 | defocus_index: Option<&EditorIndex>, 646 | focus_index: &EditorIndex, 647 | themes: &(EditorTheme, EditorTheme), // (head, pipe) 648 | ) { 649 | if Some(focus_index) == defocus_index { 650 | return; 651 | } 652 | 653 | if let Some(defocus_index) = defocus_index { 654 | let defocus = editors.get_mut(defocus_index).unwrap(); 655 | defocus.state.prefix_style.attributes.set(Attribute::Dim); 656 | defocus 657 | .state 658 | .inactive_char_style 659 | .attributes 660 | .set(Attribute::Dim); 661 | defocus.state.active_char_style.background_color = None; 662 | defocus 663 | .state 664 | .active_char_style 665 | .attributes 666 | .set(Attribute::Dim); 667 | } 668 | 669 | let focus = editors.get_mut(focus_index).unwrap(); 670 | let theme = match focus_index { 671 | &HEAD_INDEX => themes.0.clone(), 672 | _ => themes.1.clone(), 673 | }; 674 | focus.state.prefix_style.attributes.unset(Attribute::Dim); 675 | focus 676 | .state 677 | .inactive_char_style 678 | .attributes 679 | .unset(Attribute::Dim); 680 | focus.state.active_char_style.background_color = Some(theme.active_char_bg_color); 681 | focus 682 | .state 683 | .active_char_style 684 | .attributes 685 | .unset(Attribute::Dim); 686 | } 687 | } 688 | -------------------------------------------------------------------------------- /src/queue.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | use promkit::{Cursor, PaneFactory, grapheme::StyledGraphemes, pane::Pane}; 4 | 5 | pub struct Queue { 6 | buf: Cursor>, 7 | capacity: usize, 8 | } 9 | 10 | impl Queue { 11 | pub fn new(capacity: usize) -> Self { 12 | Self { 13 | buf: Cursor::new(VecDeque::with_capacity(capacity), 0, false), 14 | capacity, 15 | } 16 | } 17 | 18 | pub fn push(&mut self, item: StyledGraphemes) { 19 | if self.buf.contents().len() > self.capacity { 20 | self.buf.contents_mut().pop_front(); 21 | } 22 | // Note: promkit::terminal::Terminal ignores empty items. 23 | // Therefore, it replace empty items with a null character. 24 | if item.is_empty() { 25 | self.buf.contents_mut().push_back("\0".into()); 26 | } else { 27 | self.buf.contents_mut().push_back(item); 28 | } 29 | } 30 | } 31 | 32 | pub struct State { 33 | queue: Queue, 34 | capacity: usize, 35 | } 36 | 37 | impl State { 38 | pub fn new(capacity: usize) -> Self { 39 | Self { 40 | queue: Queue::new(capacity), 41 | capacity, 42 | } 43 | } 44 | 45 | pub fn reset(&mut self) { 46 | self.queue = Queue::new(self.capacity); 47 | } 48 | 49 | pub fn push(&mut self, item: StyledGraphemes) { 50 | self.queue.push(item); 51 | } 52 | 53 | pub fn shift(&mut self, up: usize, down: usize) -> bool { 54 | self.queue.buf.shift(up, down) 55 | } 56 | } 57 | 58 | impl PaneFactory for State { 59 | fn create_pane(&self, width: u16, height: u16) -> Pane { 60 | Pane::new( 61 | self.queue 62 | .buf 63 | .contents() 64 | .iter() 65 | .enumerate() 66 | .filter(|(i, _)| { 67 | *i >= self.queue.buf.position() 68 | && *i < self.queue.buf.position() + height as usize 69 | }) 70 | .fold((vec![], 0), |(mut acc, pos), (_, item)| { 71 | let rows = item.matrixify(width as usize, height as usize, 0).0; 72 | if pos < self.queue.buf.position() + height as usize { 73 | acc.extend(rows); 74 | } 75 | (acc, pos + 1) 76 | }) 77 | .0, 78 | 0, 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/render.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::BTreeMap, 3 | sync::{Arc, LazyLock}, 4 | }; 5 | 6 | use crossterm::style::{Attribute, Attributes, Color}; 7 | use promkit::{pane::Pane, style::StyleBuilder, terminal::Terminal, text}; 8 | use tokio::sync::{Mutex, MutexGuard}; 9 | 10 | pub static EMPTY_PANE: LazyLock = LazyLock::new(|| Pane::new(vec![], 0)); 11 | 12 | #[derive(Clone, Debug, Hash, PartialEq, Eq)] 13 | pub struct EditorIndex(pub usize, pub usize); // numerator, denominator 14 | 15 | impl std::fmt::Display for EditorIndex { 16 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 17 | write!(f, "({},{})", self.0, self.1) 18 | } 19 | } 20 | 21 | pub const HEAD_INDEX: EditorIndex = EditorIndex(1, 1); 22 | 23 | impl PartialOrd for EditorIndex { 24 | fn partial_cmp(&self, other: &Self) -> Option { 25 | Some(self.cmp(other)) 26 | } 27 | } 28 | 29 | impl Ord for EditorIndex { 30 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 31 | // Comparing fractions: To compare a/b and c/d, compare ad and bc 32 | let left = (self.0 as u64) * (other.1 as u64); 33 | let right = (self.1 as u64) * (other.0 as u64); 34 | left.cmp(&right) 35 | } 36 | } 37 | 38 | impl EditorIndex { 39 | pub fn mediant(a: &EditorIndex, b: &EditorIndex) -> Self { 40 | // TODO: gcd to reduce the fraction 41 | Self(a.0 + b.0, a.1 + b.1) 42 | } 43 | } 44 | 45 | #[derive(Clone, PartialEq, Eq)] 46 | pub enum NotifyMessage { 47 | None, 48 | Error(String), 49 | } 50 | 51 | impl From for text::State { 52 | fn from(val: NotifyMessage) -> Self { 53 | match val { 54 | NotifyMessage::None => text::State::default(), 55 | NotifyMessage::Error(message) => text::State { 56 | text: text::Text::from(message), 57 | style: StyleBuilder::new() 58 | .fgc(Color::DarkRed) 59 | .attrs(Attributes::from(Attribute::Bold)) 60 | .build(), 61 | ..Default::default() 62 | }, 63 | } 64 | } 65 | } 66 | 67 | #[derive(Clone, PartialEq, Eq)] 68 | pub enum PaneIndex { 69 | Notify, 70 | Editor(EditorIndex), 71 | Output, 72 | } 73 | 74 | impl PartialOrd for PaneIndex { 75 | fn partial_cmp(&self, other: &Self) -> Option { 76 | Some(self.cmp(other)) 77 | } 78 | } 79 | 80 | impl Ord for PaneIndex { 81 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 82 | match (self, other) { 83 | (PaneIndex::Notify, PaneIndex::Notify) => std::cmp::Ordering::Equal, 84 | (PaneIndex::Notify, _) => std::cmp::Ordering::Less, 85 | (_, PaneIndex::Notify) => std::cmp::Ordering::Greater, 86 | 87 | (PaneIndex::Output, PaneIndex::Output) => std::cmp::Ordering::Equal, 88 | (PaneIndex::Output, _) => std::cmp::Ordering::Greater, 89 | (_, PaneIndex::Output) => std::cmp::Ordering::Less, 90 | 91 | (PaneIndex::Editor(a), PaneIndex::Editor(b)) => a.cmp(b), 92 | } 93 | } 94 | } 95 | pub struct SharedRenderer(Arc>); 96 | 97 | impl SharedRenderer { 98 | pub fn try_new() -> anyhow::Result { 99 | Ok(Self(Arc::new(Mutex::new(Renderer::try_new()?)))) 100 | } 101 | 102 | pub fn clone(&self) -> Self { 103 | Self(self.0.clone()) 104 | } 105 | 106 | pub fn lock(&self) -> impl Future> { 107 | self.0.lock() 108 | } 109 | } 110 | 111 | pub struct Renderer { 112 | terminal: Terminal, 113 | panes: BTreeMap, 114 | } 115 | 116 | impl Renderer { 117 | pub fn try_new() -> anyhow::Result { 118 | Ok(Self { 119 | terminal: Terminal { 120 | position: crossterm::cursor::position()?, 121 | }, 122 | panes: BTreeMap::from([ 123 | (PaneIndex::Notify, EMPTY_PANE.clone()), 124 | (PaneIndex::Editor(EditorIndex(1, 1)), EMPTY_PANE.clone()), 125 | (PaneIndex::Output, EMPTY_PANE.clone()), 126 | ]), 127 | }) 128 | } 129 | 130 | pub fn update(&mut self, items: I) -> &mut Self 131 | where 132 | I: IntoIterator, 133 | { 134 | items.into_iter().for_each(|(index, pane)| { 135 | self.panes.insert(index, pane); 136 | }); 137 | self 138 | } 139 | 140 | pub fn remove(&mut self, items: I) -> &mut Self 141 | where 142 | I: IntoIterator, 143 | { 144 | items.into_iter().for_each(|index| { 145 | self.panes.remove(&index); 146 | }); 147 | self 148 | } 149 | 150 | pub fn render(&mut self) -> anyhow::Result<()> { 151 | self.terminal 152 | .draw(&self.panes.values().cloned().collect::>()) 153 | } 154 | } 155 | --------------------------------------------------------------------------------