├── .envrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Cargo.toml ├── Justfile ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── demo.png ├── examples ├── color-output.rs ├── dyn_tokio.rs ├── large_lines.rs ├── less-rs.rs ├── msg-tokio.rs ├── static-no-overflow.rs └── static.rs ├── flake.lock ├── flake.nix ├── minus.svg └── src ├── core ├── commands.rs ├── ev_handler.rs ├── init.rs ├── mod.rs └── utils │ ├── display │ ├── mod.rs │ └── tests.rs │ ├── mod.rs │ └── term.rs ├── dynamic_pager.rs ├── error.rs ├── input ├── definitions │ ├── keydefs.rs │ ├── mod.rs │ └── mousedefs.rs ├── hashed_event_register.rs ├── mod.rs └── tests.rs ├── lib.rs ├── pager.rs ├── screen ├── mod.rs └── tests.rs ├── search.rs ├── state.rs ├── static_pager.rs └── tests.rs /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Desktop:** 23 | - OS 24 | - Terminal 25 | - Shell 26 | - `minus` Version 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | env: 8 | CARGO_TERM_COLOR: always 9 | jobs: 10 | rustfmt: 11 | name: rustfmt 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | - name: Check formatting 17 | run: cargo fmt --all -- --check 18 | 19 | build: 20 | name: build 21 | env: 22 | RUST_BACKTRACE: 1 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | - name: Build crate (Static Output) 28 | run: cargo build --verbose --features=static_output,search 29 | - name: Build crate (Tokio) 30 | run: cargo build --verbose --features=dynamic_output,search 31 | 32 | test: 33 | name: test 34 | env: 35 | # Emit backtraces on panics. 36 | RUST_BACKTRACE: 1 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v4 41 | # This step helps separate buildtime and runtime errors in tests. 42 | # Do not build or run doc tests 43 | - name: Build tests (Static Output) 44 | run: cargo test --verbose --features=static_output,search --no-run --lib 45 | - name: Run Tests (Static Output) 46 | run: cargo test --verbose --features=static_output,search --lib 47 | - name: Build tests (Tokio) 48 | run: cargo test --verbose --features=dynamic_output,search --no-run --lib 49 | - name: Run Tests (Tokio) 50 | run: cargo test --verbose --features=dynamic_output,search --lib 51 | - name: Build tests (Search-only) 52 | run: cargo test --verbose --features=search --no-run --lib 53 | - name: Run Tests (Search-only) 54 | run: cargo test --verbose --features=search --lib 55 | 56 | examples: 57 | name: examples 58 | env: 59 | # Emit backtraces on panics. 60 | RUST_BACKTRACE: 1 61 | runs-on: ubuntu-latest 62 | steps: 63 | - name: Checkout repository 64 | uses: actions/checkout@v4 65 | # No terminal available in CI, only check the examples. 66 | - name: Tokio 67 | run: cargo check --example=dyn_tokio --features=dynamic_output 68 | - name: Tokio Messages 69 | run: cargo check --example=msg-tokio --features=dynamic_output 70 | - name: Static 71 | run: cargo check --example=static --features=static_output 72 | - name: Less-rs 73 | run: cargo check --example=less-rs --features=dynamic_output 74 | - name: Color output 75 | run: cargo check --example=color-output --features=static_output 76 | - name: Large Lines 77 | run: cargo check --example=large_lines --features=static_output 78 | 79 | doctests: 80 | name: doctests 81 | env: 82 | RUST_BACKTRACE: 1 83 | runs-on: ubuntu-latest 84 | steps: 85 | - name: Checkout repository 86 | uses: actions/checkout@v4 87 | - name: Run documentation tests 88 | run: cargo test --doc --all-features 89 | 90 | lint: 91 | name: lint 92 | runs-on: ubuntu-latest 93 | steps: 94 | - uses: actions/checkout@v3 95 | - uses: dtolnay/rust-toolchain@stable 96 | with: 97 | toolchain: stable 98 | profile: minimal 99 | components: clippy 100 | - uses: actions-rs/cargo@v1 101 | with: 102 | command: clippy 103 | args: --features=dynamic_output,search --tests --examples 104 | - uses: actions-rs/cargo@v1 105 | with: 106 | command: clippy 107 | args: --features=dynamic_output,search --tests --examples 108 | - uses: actions-rs/cargo@v1 109 | with: 110 | command: clippy 111 | args: --features=static_output,search --tests --examples 112 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | .direnv/ 10 | -------------------------------------------------------------------------------- /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 | https://minus.zulipchat.com/#narrow/stream/294466-community. 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 | # How to contribute 2 | First of all, we want to thank you for taking the time to contribute to this project. 3 | 4 | Contributing to a `minus` is pretty straight forward. If this is you're first time, these are the steps you should take. 5 | 6 | - Create an issue describing your feature/bug/enhancement and how it will be beneficial. 7 | - State that you are currently working on implementing/fixing the feature/bug/enhancement 8 | - Fork this repo. 9 | - Start from **main** branch and create a separate branch for making changes. 10 | - Read the code available and make your changes. 11 | - When you're done, submit a pull request for one of the maintainers to check it out. We would let you know if there is 12 | any problem or any changes that should be considered. 13 | 14 | ## Maintaining code quality and best practices 15 | - Your code should be formatted with rustfmt and should be free from clippy warnings. 16 | - If you're adding/making changes to the public API, write/change the documentation appropriately. Put documentation 17 | examples where possible. If the code returns a `Result`, describe it in the the Error section of the item's documentation. 18 | If it can panic, describe that too in the documentation. 19 | 20 | - Every chunk of code has some comments above it. If you write some new code or change some part of the existing code, 21 | you should write comments to explain it. 22 | 23 | - Gate your code on appropriate Cargo features if it is required only by that functionality 24 | - Code related to dynamic paging should be gated on the `dynamic_pagiing` feature. 25 | - Code related to searching should be gated on the `search` feature. 26 | - Code related to static output display should be gated on `static_output` feature. 27 | 28 | ## Tests and CI 29 | Your code will automatically be tested by GitHub Actions. If you're code fails in CI, you should fix it appropriately 30 | and ensure all tests/examples are passing. 31 | 32 | ## Commit messages 33 | To ensure quality commit messages which also help other fellow developers better understand changes, you should 34 | write commit messages that strictly adhere to [Conventional Commits](https://conventionalcommits.org) v1.0.0. 35 | 36 | ### Types 37 | You commit must have a type associated with it. Here are all the types that we encourage people to use ensure commits 38 | can be classified same for everyone contributing to minus. 39 | - `ci` - Changes to GitHub Actions CI wofkflows file 40 | - `chore`: Regular stuff that don't fall into any category like running `rustfmt` etc. 41 | - `docs` - Improvements to documentation 42 | - `feat` - Feature improvements 43 | - `fix` - Bug fixes 44 | - `perf` - Performance improvements 45 | - `refactor` - Changes that don't fix bugs or add features but improves the overall quality of code base. 46 | You can use this for commits that fix cargo/clippy warnings 47 | - `release` - Used to mark commits that make a new commit on crates.io 48 | - `test`: Commits that touch examples/unit tests/doc tests. 49 | 50 | ### Scopes 51 | Commit messages following Conventional Commits can optionally describe their **scope**. The scope broadly 52 | describes which parts of the project you commit has touched. 53 | 54 | In general, the Rust quailfied name of each file will be it's respect scope. For example `src/state.rs` will have the 55 | `state` scope. Similarly `src/dynamic_pager.rs` will have have scope `dynamic_pager`. With all that, there are a few 56 | exceptions that you should take care of. 57 | 58 | - Use the word `manifest` rather than writing `Cargo.toml` 59 | - Use the word `root` rather than writing `src/lib.rs` 60 | - Do not mention the name of parent directories for modules. For example, use `keydefs` for 61 | `src/input/definitions/keydefs.rs` or `display` for `src/core/utils/display/mod.rs`. 62 | - Use the name of the module rather than writing the path to its `mod.rs` file. For example, write `core` rather than `src/core/mod.rs` 63 | - Include the name of the parent module if the commit is related to a test. For example, use `display/tests` for `src/core/utils/display/tests.rs`. 64 | 65 | ## License 66 | Unless explicitly stated otherwise, all code written to this project is dual licensed under the MIT and Apache license 67 | 2.0. 68 | 69 | The copyrights of `minus` are retained by their contributors and no copyright assignment is required to contribute to 70 | the project. 71 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "minus" 3 | version = "5.6.1" 4 | authors = ["Arijit Dey "] 5 | edition = "2018" 6 | license = "MIT OR Apache-2.0" 7 | documentation = "https://docs.rs/minus" 8 | repository = "https://github.com/AMythicDev/minus" 9 | description = "An asynchronous data feedable terminal paging library for Rust" 10 | keywords = ["pager", "asynchronous", "dynamic", "less", "more"] 11 | categories = ["Text processing", "Command-line interface", "Asynchronous"] 12 | 13 | [package.metadata.docs.rs] 14 | all-features = true 15 | rustdoc-args = ["--cfg", "docsrs"] 16 | 17 | [lib] 18 | name = "minus" 19 | path = "src/lib.rs" 20 | crate-type = ["lib"] 21 | 22 | [dependencies] 23 | crossterm = "^0.27" 24 | textwrap = { version = "~0.16", default-features = false, features = ["unicode-width"] } 25 | thiserror = "^1" 26 | regex = { version = "^1", optional = true } 27 | crossbeam-channel = "^0.5" 28 | parking_lot = "0.12.1" 29 | once_cell = { version = "^1.18", features = ["parking_lot"] } 30 | 31 | [features] 32 | search = [ "regex" ] 33 | static_output = [] 34 | dynamic_output = [] 35 | 36 | [dev-dependencies] 37 | tokio = { version = "^1.0", features = ["rt", "macros", "rt-multi-thread", "time"] } 38 | 39 | [[example]] 40 | name = "dyn_tokio" 41 | path = "examples/dyn_tokio.rs" 42 | required-features = ["dynamic_output"] 43 | 44 | [[example]] 45 | name = "less-rs" 46 | path = "examples/less-rs.rs" 47 | required-features = ["dynamic_output"] 48 | 49 | [[example]] 50 | name = "static" 51 | path = "examples/static.rs" 52 | required-features = ["static_output"] 53 | 54 | [[example]] 55 | name = "large_lines" 56 | path = "examples/large_lines.rs" 57 | required-features = ["static_output"] 58 | 59 | [[example]] 60 | name = "color-output" 61 | path = "examples/color-output.rs" 62 | required-features = ["static_output"] 63 | 64 | [[example]] 65 | name = "static-no-overflow" 66 | path = "examples/static-no-overflow.rs" 67 | required-features = ["static_output"] 68 | 69 | [[example]] 70 | name = "msg-tokio" 71 | path = "examples/msg-tokio.rs" 72 | required-features = ["dynamic_output"] 73 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | _prechecks: 2 | -cargo hack 2> /dev/null 3 | 4 | if [ $? == 101 ]; then \ 5 | cargo install cargo-hack; \ 6 | fi 7 | 8 | fmt: 9 | cargo fmt --all 10 | 11 | check-fmt: 12 | cargo fmt --all -- --check 13 | 14 | build: _prechecks 15 | cargo hack --feature-powerset build 16 | 17 | tests: 18 | cargo test --all-features --no-run 19 | cargo test --all-features 20 | 21 | examples: 22 | cargo check --example=dyn_tokio --features=dynamic_output 23 | cargo check --example=msg-tokio --features=dynamic_output 24 | cargo check --example=static --features=static_output 25 | cargo check --example=less-rs --features=dynamic_output,search 26 | 27 | lint: _prechecks 28 | cargo hack --feature-powerset clippy 29 | 30 | verify-all: check-fmt build tests examples lint 31 | @echo "Ready to go" 32 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # minus 2 | 3 |

4 | 5 |

6 | 7 | [![crates.io](https://img.shields.io/crates/v/minus?style=for-the-badge)](https://crates.io/crates/minus) 8 | [![ci](https://github.com/arijit79/minus/actions/workflows/ci.yml/badge.svg)](https://github.com/arijit79/minus/actions/workflows/ci.yml) 9 | [![docs.rs](https://img.shields.io/docsrs/minus?label=docs.rs&style=for-the-badge)](https://docs.rs/minus) 10 | [![Discord](https://img.shields.io/discord/953920872857620541?color=%237289da&label=Discord&style=for-the-badge)](https://discord.gg/FKEnDPE6Bv) 11 | [![Matrix](https://img.shields.io/matrix/minus:matrix.org?color=%230dbd8b&label=Matrix&style=for-the-badge)](https://matrix.to/#/!hfVLHlAlRLnAMdKdjK:matrix.org?via=matrix.org) 12 | [![Crates.io](https://img.shields.io/crates/l/minus?style=for-the-badge)](https://github.com/arijit79/minus#license) 13 | 14 | `minus`: A library for asynchronous terminal [paging], written in Rust. 15 | 16 |

17 | 18 |

19 | 20 | ## Motivation 21 | Traditional pagers like `more` or `less` weren't made for integrating into other applications. They were meant to 22 | be standalone binaries that are executed directly by users. However most applications don't adhere to this and 23 | exploit these pagers' functionality by calling them as external programs and passing the data through the standard input. 24 | This method worked for Unix and other Unix-like OSs like Linux and MacOS because they already came with any of these 25 | pagers installed. But it wasn't this easy on Windows; it required shipping the pager binary along with the applications. 26 | Since these programs were originally designed for Unix and Unix-like OSs, distributing these binaries meant shipping an 27 | entire environment like MinGW or Cygwin so that these can run properly on Windows. 28 | 29 | Recently, some libraries have emerged to solve this issue. They are compiled along with your application and give you a 30 | single binary to distribute. The problem with them is that they require you to feed the entire data to the pager before 31 | the pager can run, this meant that there will be no output on the terminal until the entire data is loaded by the 32 | application and passed on to the pager. 33 | 34 | These could cause long delays before output to the terminal if the data comes from a very large file or is being 35 | downloaded from the internet. 36 | 37 | ## Features 38 | - Send data as well as configure the pager on the fly. 39 | This means that your data can be shown on the pager's screen as soon as it is loaded by your application. But not only that, 40 | you can also configure the minus while its running. 41 | - Supports separate modes for dynamic and static output display 42 | This separation of modes allows us to do some cool tricks in static mode. For example in static mode, if the terminal has 43 | enough rows to display all the data at once then minus won't even start the pager and write all the data to the screen and quit. 44 | (Of course this behaviour can be avoided if you don't like it). 45 | Similarly, in static mode if the output is piped using the `|` or sent to a file using the `>`/`>>`, minus would simply pass the 46 | data as it is without starting the pager. 47 | - Highly configurable 48 | You can configure terminal key/mouse mappings, line numbers, bottom prompt line and more with a simple and clean API. 49 | - Good support for ANSI escape sequences 50 | - Both keyboard and mouse support 51 | Key bindings highly inspired by Vim and other modern text editors 52 | - Clutter free line numbering 53 | - Horizontal scrolling 54 | Scroll not only up or down but also left and right. 55 | **NOTE: ANSI escape codes are broken when scrolling horizontally which means as you scroll along the axis, you may see broken 56 | colors, emphasis etc. This is not a minus-specific problem but rather its how terminals behave and is inherently limited because of their design** 57 | - Follow output mode 58 | This feature ensures that you always see the last line as the data is being pushed onto the pager's buffer. 59 | - Full [regex](https://docs.rs/regex) based searching. 60 | Which also fully takes care of escape sequences. Also supports incremental searching of text as you type. 61 | - Tries to be very minimal on dependencies. 62 | - Is designed to be used with [`tokio`], [`async-std`] or native [`threads`] as you like. 63 | 64 | ## Usage 65 | 66 | Add minus as a dependency in your `Cargo.toml` file and enable features as you like. 67 | 68 | * If you only want a pager to display static data, enable the `static_output` feature 69 | 70 | * If you want a pager to display dynamic data and be configurable at runtime, enable the `dynamic_output` feature 71 | 72 | * If you want search support inside the pager, you need to enable the `search` feature 73 | 74 | ```toml 75 | [dependencies.minus] 76 | version = "5.6" 77 | features = [ 78 | # Enable features you want. For example 79 | "dynamic_output", 80 | "search", 81 | ] 82 | ``` 83 | 84 | ## Examples 85 | 86 | You can try the provided examples in the `examples` directory by using `cargo`: 87 | ```bash 88 | cargo run --example --features= 89 | 90 | # for example to try the `dyn_tokio` example 91 | cargo run --example dyn_tokio --features=dynamic_output,search 92 | ``` 93 | 94 | See [the docs](https://docs.rs/minus/latest/minus/#examples) for a summary of examples. 95 | 96 | 97 | ## Standard keyboard and mouse bindings 98 | 99 | Can be seen [in the docs](https://docs.rs/minus/latest/minus/#standard-actions). 100 | 101 | ## MSRV 102 | The latest version of minus requires Rust >= 1.67 to build correctly. 103 | 104 | ## License 105 | 106 | Unless explicitly stated, all works to `minus` are dual licensed under the 107 | [MIT License](./LICENSE-MIT) and [Apache License 2.0](./LICENSE-APACHE). 108 | 109 | ## Contributing 110 | Issues and pull requests are more than welcome. 111 | See [CONTRIBUTING.md](CONTRIBUTING.md) on how to contribute to `minus`. 112 | 113 | ## Thanks 114 | 115 | minus would never have been this without the :heart: from these kind people 116 | 117 | 118 | 119 | 120 | 121 | And the help from these projects:- 122 | - [crossterm](https://crates.io/crates/crossterm): An amazing library for working with terminals. 123 | - [textwrap](https://crates.io/crates/textwrap): Support for text wrapping. 124 | - [thiserror](https://crates.io/crates/thiserror): Helps in defining custom errors types. 125 | - [regex](https://crates.io/crates/regex): Regex support when searching. 126 | - [crossbeam-channel](https://crates.io/crates/crossbeam-channel): MPMC channel 127 | - [parking_lot](https://crates.io/crates/parking_lot): Improved atomic storage types 128 | - [once_cell](https://crates.io/crates/once_cell): Provides one-time initialization types. 129 | - [tokio](https://crates.io/crates/tokio): Provides runtime for async examples. 130 | 131 | ## Get in touch 132 | 133 | We are open to discussion and thoughts om improving `minus`. Join us at 134 | [Discord](https://discord.gg/FKEnDPE6Bv) or 135 | [Matrix](https://matrix.to/#/!hfVLHlAlRLnAMdKdjK:matrix.org?via=matrix.org). 136 | 137 | [`tokio`]: https://crates.io/crates/tokio 138 | [`async-std`]: https://crates.io/crates/async-std 139 | [`Threads`]: https://doc.rust-lang.org/std/thread/index.html 140 | [paging]: https://en.wikipedia.org/wiki/Terminal_pager 141 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AMythicDev/minus/43aa50d78a7fa1205fd7d37242d30ac0344461e8/demo.png -------------------------------------------------------------------------------- /examples/color-output.rs: -------------------------------------------------------------------------------- 1 | use crossterm::style::{Color, ResetColor, SetForegroundColor}; 2 | use minus::{error::MinusError, page_all, Pager}; 3 | use std::fmt::Write; 4 | 5 | fn main() -> Result<(), MinusError> { 6 | let mut pager = Pager::new(); 7 | for _ in 1..=30 { 8 | writeln!( 9 | pager, 10 | "{}These are some lines{}", 11 | SetForegroundColor(Color::Blue), 12 | ResetColor 13 | )?; 14 | } 15 | page_all(pager)?; 16 | Ok(()) 17 | } 18 | -------------------------------------------------------------------------------- /examples/dyn_tokio.rs: -------------------------------------------------------------------------------- 1 | use minus::error::MinusError; 2 | use std::fmt::Write; 3 | use std::time::Duration; 4 | use tokio::{join, task::spawn_blocking, time::sleep}; 5 | 6 | #[tokio::main] 7 | async fn main() -> Result<(), MinusError> { 8 | let mut output = minus::Pager::new(); 9 | let output2 = output.clone(); 10 | 11 | let increment = async { 12 | for i in 0..=100_u32 { 13 | writeln!(output, "{}", i)?; 14 | sleep(Duration::from_millis(100)).await; 15 | } 16 | Result::<_, MinusError>::Ok(()) 17 | }; 18 | 19 | let (res1, res2) = join!( 20 | spawn_blocking(move || minus::dynamic_paging(output2)), 21 | increment 22 | ); 23 | res1.unwrap()?; 24 | res2?; 25 | Ok(()) 26 | } 27 | -------------------------------------------------------------------------------- /examples/large_lines.rs: -------------------------------------------------------------------------------- 1 | use minus::error::MinusError; 2 | 3 | fn main() -> Result<(), MinusError> { 4 | let output = minus::Pager::new(); 5 | 6 | for i in 0..30 { 7 | for _ in 0..=10 { 8 | output.push_str(&format!("{}. Hello ", i))?; 9 | } 10 | output.push_str("\n")?; 11 | } 12 | 13 | minus::page_all(output)?; 14 | Ok(()) 15 | } 16 | -------------------------------------------------------------------------------- /examples/less-rs.rs: -------------------------------------------------------------------------------- 1 | // This is an example of a pager that uses minus and reads data from a file and pages 2 | // it. It is similar to less, but in Rust. Hence the name `less-rs` 3 | // This example uses OS threads, 4 | 5 | // This example uses a lot of `.expect()` and does not properly handle them. If 6 | // someone is interested to add proper error handling, you are free to file pull 7 | //requests. Libraries like anyhow and thiserror are generally preferred and minus 8 | //also uses thiserror 9 | 10 | use std::env::args; 11 | use std::fs::File; 12 | use std::io::{BufReader, Read}; 13 | use std::thread; 14 | 15 | fn read_file(name: String, pager: minus::Pager) -> Result<(), Box> { 16 | let file = File::open(name)?; 17 | let changes = || { 18 | let mut buff = String::new(); 19 | let mut buf_reader = BufReader::new(file); 20 | buf_reader.read_to_string(&mut buff)?; 21 | pager.push_str(&buff)?; 22 | Result::<(), Box>::Ok(()) 23 | }; 24 | 25 | let pager = pager.clone(); 26 | let res1 = thread::spawn(|| minus::dynamic_paging(pager)); 27 | let res2 = changes(); 28 | res1.join().unwrap()?; 29 | res2?; 30 | Ok(()) 31 | } 32 | 33 | fn main() -> Result<(), Box> { 34 | // Get the file name from the command line 35 | // Typically, you want to use something like clap here, but we are not doing it 36 | // here to make the example simple 37 | let arguments: Vec = args().collect(); 38 | // Check if there are is at least two arguments including the program name 39 | if arguments.len() < 2 { 40 | // You probably want to do a graceful exit, but we are panicking here to make 41 | // example short 42 | panic!("Not enough arguments"); 43 | } 44 | // Get the filename 45 | let filename = arguments[1].clone(); 46 | // Initialize the configuration 47 | let output = minus::Pager::new(); 48 | output.set_prompt(&filename)?; 49 | read_file(filename, output) 50 | } 51 | -------------------------------------------------------------------------------- /examples/msg-tokio.rs: -------------------------------------------------------------------------------- 1 | use minus::error::MinusError; 2 | use std::time::Duration; 3 | use tokio::{join, task::spawn_blocking, time::sleep}; 4 | 5 | #[tokio::main] 6 | async fn main() -> Result<(), MinusError> { 7 | let output = minus::Pager::new(); 8 | 9 | let increment = async { 10 | for i in 0..=10_u32 { 11 | output.push_str(&format!("{}\n", i))?; 12 | sleep(Duration::from_millis(100)).await; 13 | } 14 | output.send_message("No more output to come")?; 15 | Result::<_, MinusError>::Ok(()) 16 | }; 17 | 18 | let output = output.clone(); 19 | let (res1, res2) = join!( 20 | spawn_blocking(move || minus::dynamic_paging(output)), 21 | increment 22 | ); 23 | res1.unwrap()?; 24 | res2?; 25 | Ok(()) 26 | } 27 | -------------------------------------------------------------------------------- /examples/static-no-overflow.rs: -------------------------------------------------------------------------------- 1 | use minus::error::MinusError; 2 | use minus::{page_all, Pager}; 3 | use std::fmt::Write; 4 | 5 | fn main() -> Result<(), MinusError> { 6 | let mut pager = Pager::new(); 7 | pager.set_run_no_overflow(true)?; 8 | for i in 0..=10u32 { 9 | writeln!(pager, "{}", i)?; 10 | } 11 | page_all(pager)?; 12 | Ok(()) 13 | } 14 | -------------------------------------------------------------------------------- /examples/static.rs: -------------------------------------------------------------------------------- 1 | use minus::error::MinusError; 2 | 3 | fn main() -> Result<(), MinusError> { 4 | let output = minus::Pager::new(); 5 | 6 | for i in 0..=100 { 7 | output.push_str(&format!("{}\n", i))?; 8 | } 9 | 10 | minus::page_all(output)?; 11 | Ok(()) 12 | } 13 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1710146030, 9 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "flake-utils_2": { 22 | "inputs": { 23 | "systems": "systems_2" 24 | }, 25 | "locked": { 26 | "lastModified": 1705309234, 27 | "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", 28 | "owner": "numtide", 29 | "repo": "flake-utils", 30 | "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "numtide", 35 | "repo": "flake-utils", 36 | "type": "github" 37 | } 38 | }, 39 | "nixpkgs": { 40 | "locked": { 41 | "lastModified": 1710283812, 42 | "narHash": "sha256-F+s4//HwNEXtgxZ6PLoe5khDTmUukPYbjCvx7us2vww=", 43 | "owner": "nixos", 44 | "repo": "nixpkgs", 45 | "rev": "73bf415737ceb66a6298f806f600cfe4dccd0a41", 46 | "type": "github" 47 | }, 48 | "original": { 49 | "owner": "nixos", 50 | "ref": "nixpkgs-unstable", 51 | "repo": "nixpkgs", 52 | "type": "github" 53 | } 54 | }, 55 | "nixpkgs_2": { 56 | "locked": { 57 | "lastModified": 1706487304, 58 | "narHash": "sha256-LE8lVX28MV2jWJsidW13D2qrHU/RUUONendL2Q/WlJg=", 59 | "owner": "NixOS", 60 | "repo": "nixpkgs", 61 | "rev": "90f456026d284c22b3e3497be980b2e47d0b28ac", 62 | "type": "github" 63 | }, 64 | "original": { 65 | "owner": "NixOS", 66 | "ref": "nixpkgs-unstable", 67 | "repo": "nixpkgs", 68 | "type": "github" 69 | } 70 | }, 71 | "root": { 72 | "inputs": { 73 | "flake-utils": "flake-utils", 74 | "nixpkgs": "nixpkgs", 75 | "rust-overlay": "rust-overlay" 76 | } 77 | }, 78 | "rust-overlay": { 79 | "inputs": { 80 | "flake-utils": "flake-utils_2", 81 | "nixpkgs": "nixpkgs_2" 82 | }, 83 | "locked": { 84 | "lastModified": 1710295923, 85 | "narHash": "sha256-B7wIarZOh5nNnj4GTOOYcxAwVGTO8y0dRSOzd6PtYE8=", 86 | "owner": "oxalica", 87 | "repo": "rust-overlay", 88 | "rev": "a30facbf72f29e5c930f394f637559f46a855e8b", 89 | "type": "github" 90 | }, 91 | "original": { 92 | "owner": "oxalica", 93 | "repo": "rust-overlay", 94 | "type": "github" 95 | } 96 | }, 97 | "systems": { 98 | "locked": { 99 | "lastModified": 1681028828, 100 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 101 | "owner": "nix-systems", 102 | "repo": "default", 103 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 104 | "type": "github" 105 | }, 106 | "original": { 107 | "owner": "nix-systems", 108 | "repo": "default", 109 | "type": "github" 110 | } 111 | }, 112 | "systems_2": { 113 | "locked": { 114 | "lastModified": 1681028828, 115 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 116 | "owner": "nix-systems", 117 | "repo": "default", 118 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 119 | "type": "github" 120 | }, 121 | "original": { 122 | "owner": "nix-systems", 123 | "repo": "default", 124 | "type": "github" 125 | } 126 | } 127 | }, 128 | "root": "root", 129 | "version": 7 130 | } 131 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | # Specify the source of Home Manager and Nixpkgs. 4 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 5 | rust-overlay.url = "github:oxalica/rust-overlay"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { nixpkgs, rust-overlay, flake-utils, ... }: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let 12 | overlays = [ (import rust-overlay) ]; 13 | pkgs = import nixpkgs { 14 | inherit system overlays; 15 | }; 16 | 17 | rust-toolchain = pkgs.rust-bin.stable."1.67.0".default.override { 18 | extensions = [ "rust-src" "rust-analyzer" ]; 19 | }; 20 | 21 | in 22 | with pkgs; 23 | { 24 | devShells = { 25 | default = mkShell { 26 | packages = [ just ] ++ [ rust-toolchain ]; 27 | }; 28 | }; 29 | } 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/core/commands.rs: -------------------------------------------------------------------------------- 1 | //! Provides the [`Command`] enum and all its related implementations 2 | //! 3 | //! This module only declares the [Command] type. To know how they are handled internally see 4 | //! the [`ev_handler`](super::ev_handler). 5 | 6 | use std::fmt::Debug; 7 | 8 | use crate::{ 9 | input::{InputClassifier, InputEvent}, 10 | ExitStrategy, LineNumbers, 11 | }; 12 | 13 | #[cfg(feature = "search")] 14 | use crate::search::SearchOpts; 15 | 16 | /// Different events that can be encountered while the pager is running 17 | #[non_exhaustive] 18 | pub enum Command { 19 | // User input 20 | UserInput(InputEvent), 21 | 22 | // Data related 23 | AppendData(String), 24 | SetData(String), 25 | 26 | // Prompt related 27 | SendMessage(String), 28 | ShowPrompt(bool), 29 | SetPrompt(String), 30 | 31 | // Screen output configurations 32 | LineWrapping(bool), 33 | SetLineNumbers(LineNumbers), 34 | FollowOutput(bool), 35 | 36 | // Configuration options 37 | SetExitStrategy(ExitStrategy), 38 | SetInputClassifier(Box), 39 | AddExitCallback(Box), 40 | #[cfg(feature = "static_output")] 41 | SetRunNoOverflow(bool), 42 | #[cfg(feature = "search")] 43 | IncrementalSearchCondition(Box bool + Send + Sync + 'static>), 44 | 45 | // Internal commands 46 | FormatRedrawPrompt, 47 | FormatRedrawDisplay, 48 | } 49 | 50 | impl PartialEq for Command { 51 | fn eq(&self, other: &Self) -> bool { 52 | match (self, other) { 53 | (Self::SetData(d1), Self::SetData(d2)) 54 | | (Self::AppendData(d1), Self::AppendData(d2)) 55 | | (Self::SetPrompt(d1), Self::SetPrompt(d2)) 56 | | (Self::SendMessage(d1), Self::SendMessage(d2)) => d1 == d2, 57 | (Self::LineWrapping(d1), Self::LineWrapping(d2)) => d1 == d2, 58 | (Self::SetLineNumbers(d1), Self::SetLineNumbers(d2)) => d1 == d2, 59 | (Self::ShowPrompt(d1), Self::ShowPrompt(d2)) => d1 == d2, 60 | (Self::SetExitStrategy(d1), Self::SetExitStrategy(d2)) => d1 == d2, 61 | #[cfg(feature = "static_output")] 62 | (Self::SetRunNoOverflow(d1), Self::SetRunNoOverflow(d2)) => d1 == d2, 63 | (Self::SetInputClassifier(_), Self::SetInputClassifier(_)) 64 | | (Self::AddExitCallback(_), Self::AddExitCallback(_)) => true, 65 | #[cfg(feature = "search")] 66 | (Self::IncrementalSearchCondition(_), Self::IncrementalSearchCondition(_)) => true, 67 | _ => false, 68 | } 69 | } 70 | } 71 | 72 | impl Debug for Command { 73 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 74 | match self { 75 | Self::SetData(text) => write!(f, "SetData({:?})", text), 76 | Self::AppendData(text) => write!(f, "AppendData({:?})", text), 77 | Self::SetPrompt(text) => write!(f, "SetPrompt({:?})", text), 78 | Self::SendMessage(text) => write!(f, "SendMessage({:?})", text), 79 | Self::SetLineNumbers(ln) => write!(f, "SetLineNumbers({:?})", ln), 80 | Self::LineWrapping(lw) => write!(f, "LineWrapping({:?})", lw), 81 | Self::SetExitStrategy(es) => write!(f, "SetExitStrategy({:?})", es), 82 | Self::SetInputClassifier(_) => write!(f, "SetInputClassifier"), 83 | Self::ShowPrompt(show) => write!(f, "ShowPrompt({show:?})"), 84 | Self::FormatRedrawPrompt => write!(f, "FormatRedrawPrompt"), 85 | Self::FormatRedrawDisplay => write!(f, "FormatRedrawDisplay"), 86 | #[cfg(feature = "search")] 87 | Self::IncrementalSearchCondition(_) => write!(f, "IncrementalSearchCondition"), 88 | Self::AddExitCallback(_) => write!(f, "AddExitCallback"), 89 | #[cfg(feature = "static_output")] 90 | Self::SetRunNoOverflow(val) => write!(f, "SetRunNoOverflow({val:?})"), 91 | Self::UserInput(input) => write!(f, "UserInput({input:?})"), 92 | Self::FollowOutput(follow_output) => write!(f, "FollowOutput({follow_output:?})"), 93 | } 94 | } 95 | } 96 | 97 | impl Command { 98 | #[allow(dead_code)] 99 | pub(crate) const fn is_exit_event(&self) -> bool { 100 | matches!(self, Self::UserInput(InputEvent::Exit)) 101 | } 102 | 103 | #[allow(dead_code)] 104 | pub(crate) const fn is_movement(&self) -> bool { 105 | matches!(self, Self::UserInput(InputEvent::UpdateUpperMark(_))) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/core/init.rs: -------------------------------------------------------------------------------- 1 | //! Contains functions that initialize minus 2 | //! 3 | //! This module provides two main functions:- 4 | //! * The [`init_core`] function which is responsible for setting the initial state of the 5 | //! Pager, do environment checks and initializing various core functions on either async 6 | //! tasks or native threads depending on the feature set 7 | //! 8 | //! * The [`start_reactor`] function displays the displays the output and also polls 9 | //! the [`Receiver`] held inside the [`Pager`] for events. Whenever a event is 10 | //! detected, it reacts to it accordingly. 11 | #[cfg(feature = "static_output")] 12 | use crate::minus_core::utils::display; 13 | use crate::{ 14 | error::MinusError, 15 | input::InputEvent, 16 | minus_core::{ 17 | commands::Command, 18 | ev_handler::handle_event, 19 | utils::{display::draw_full, term}, 20 | RunMode, 21 | }, 22 | Pager, PagerState, 23 | }; 24 | 25 | use crossbeam_channel::{Receiver, Sender, TrySendError}; 26 | use crossterm::event; 27 | use std::{ 28 | io::{stdout, Stdout}, 29 | panic, 30 | sync::{ 31 | atomic::{AtomicBool, Ordering}, 32 | Arc, 33 | }, 34 | }; 35 | 36 | #[cfg(feature = "static_output")] 37 | use {super::utils::display::write_raw_lines, crossterm::tty::IsTty}; 38 | 39 | #[cfg(feature = "search")] 40 | use parking_lot::Condvar; 41 | use parking_lot::Mutex; 42 | 43 | use super::{utils::display::draw_for_change, CommandQueue, RUNMODE}; 44 | 45 | /// The main entry point of minus 46 | /// 47 | /// This is called by both [`dynamic_paging`](crate::dynamic_paging) and 48 | /// [`page_all`](crate::page_all) functions. 49 | /// 50 | /// It first receives all events present inside the [`Pager`]'s receiver 51 | /// and creates the initial state that to be stored inside the [`PagerState`] 52 | /// 53 | /// Then it checks if the minus is running in static mode and does some checks:- 54 | /// * If standard output is not a terminal screen, that is if it is a file or block 55 | /// device, minus will write all the data at once to the stdout and quit 56 | /// 57 | /// * If the size of the data is less than the available number of rows in the terminal 58 | /// then it displays everything on the main stdout screen at once and quits. This 59 | /// behaviour can be turned off if [`Pager::set_run_no_overflow(true)`] is called 60 | /// by the main application 61 | // Sorry... this behaviour would have been cool to have in async mode, just think about it!!! Many 62 | // implementations were proposed but none were perfect 63 | // It is because implementing this especially with line wrapping and terminal scrolling 64 | // is a a nightmare because terminals are really naughty and more when you have to fight with it 65 | // using your library... your only weapon 66 | // So we just don't take any more proposals about this. It is really frustating to 67 | // to thoroughly test each implementation and fix out all rough edges around it 68 | /// Next it initializes the runtime and calls [`start_reactor`] and a [`event reader`]` which is 69 | /// selected based on the enabled feature set:- 70 | /// 71 | /// # Errors 72 | /// 73 | /// Setting/cleaning up the terminal can fail and IO to/from the terminal can 74 | /// fail. 75 | /// 76 | /// [`event reader`]: event_reader 77 | #[allow(clippy::module_name_repetitions)] 78 | #[allow(clippy::too_many_lines)] 79 | pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusError> { 80 | #[allow(unused_mut)] 81 | let mut out = stdout(); 82 | // Is the event reader running 83 | #[cfg(feature = "search")] 84 | let input_thread_running = Arc::new((Mutex::new(true), Condvar::new())); 85 | 86 | #[allow(unused_mut)] 87 | let mut ps = crate::state::PagerState::generate_initial_state(&pager.rx, &mut out)?; 88 | 89 | { 90 | let mut runmode = super::RUNMODE.lock(); 91 | assert!(runmode.is_uninitialized(), "Failed to set the RUNMODE. This is caused probably because another instance of minus is already running"); 92 | *runmode = rm; 93 | drop(runmode); 94 | } 95 | 96 | // Static mode checks 97 | #[cfg(feature = "static_output")] 98 | if *RUNMODE.lock() == RunMode::Static { 99 | // If stdout is not a tty, write everything and quit 100 | if !out.is_tty() { 101 | write_raw_lines(&mut out, &[ps.screen.orig_text], None)?; 102 | let mut rm = RUNMODE.lock(); 103 | *rm = RunMode::Uninitialized; 104 | drop(rm); 105 | return Ok(()); 106 | } 107 | // If number of lines of text is less than available rows, write everything and quit 108 | // unless run_no_overflow is set to true 109 | if ps.screen.formatted_lines_count() <= ps.rows && !ps.run_no_overflow { 110 | write_raw_lines(&mut out, &ps.screen.formatted_lines, Some("\r"))?; 111 | ps.exit(); 112 | let mut rm = RUNMODE.lock(); 113 | *rm = RunMode::Uninitialized; 114 | drop(rm); 115 | return Ok(()); 116 | } 117 | } 118 | 119 | // Setup terminal, adjust line wraps and get rows 120 | term::setup(&out)?; 121 | 122 | // Has the user quit 123 | let is_exited = Arc::new(AtomicBool::new(false)); 124 | let is_exited2 = is_exited.clone(); 125 | 126 | { 127 | let panic_hook = panic::take_hook(); 128 | panic::set_hook(Box::new(move |pinfo| { 129 | is_exited2.store(true, std::sync::atomic::Ordering::SeqCst); 130 | // While silently ignoring error is considered a bad practice, we are forced to do it here 131 | // as we cannot use the ? and panicking here will cause UB. 132 | drop(term::cleanup( 133 | stdout(), 134 | &crate::ExitStrategy::PagerQuit, 135 | true, 136 | )); 137 | panic_hook(pinfo); 138 | })); 139 | } 140 | 141 | let ps_mutex = Arc::new(Mutex::new(ps)); 142 | 143 | let evtx = pager.tx.clone(); 144 | let rx = pager.rx.clone(); 145 | let out = stdout(); 146 | 147 | let p1 = ps_mutex.clone(); 148 | 149 | #[cfg(feature = "search")] 150 | let input_thread_running2 = input_thread_running.clone(); 151 | 152 | std::thread::scope(|s| -> crate::Result { 153 | let out = Arc::new(out); 154 | let out_copy = out.clone(); 155 | let is_exited3 = is_exited.clone(); 156 | let is_exited4 = is_exited.clone(); 157 | 158 | let t1 = s.spawn(move || { 159 | let res = event_reader( 160 | &evtx, 161 | &p1, 162 | #[cfg(feature = "search")] 163 | &input_thread_running2, 164 | &is_exited3, 165 | ); 166 | 167 | if res.is_err() { 168 | is_exited3.store(true, std::sync::atomic::Ordering::SeqCst); 169 | let mut rm = RUNMODE.lock(); 170 | *rm = RunMode::Uninitialized; 171 | drop(rm); 172 | term::cleanup(out.as_ref(), &crate::ExitStrategy::PagerQuit, true)?; 173 | } 174 | res 175 | }); 176 | let t2 = s.spawn(move || { 177 | let res = start_reactor( 178 | &rx, 179 | &ps_mutex, 180 | &out_copy, 181 | #[cfg(feature = "search")] 182 | &input_thread_running, 183 | &is_exited4, 184 | ); 185 | 186 | if res.is_err() { 187 | is_exited4.store(true, std::sync::atomic::Ordering::SeqCst); 188 | let mut rm = RUNMODE.lock(); 189 | *rm = RunMode::Uninitialized; 190 | drop(rm); 191 | term::cleanup(out_copy.as_ref(), &crate::ExitStrategy::PagerQuit, true)?; 192 | } 193 | res 194 | }); 195 | 196 | let r1 = t1.join().unwrap(); 197 | let r2 = t2.join().unwrap(); 198 | 199 | r1?; 200 | r2?; 201 | Ok(()) 202 | }) 203 | } 204 | 205 | /// Continuously displays the output and reacts to events 206 | /// 207 | /// This function displays the output continuously while also checking for user inputs. 208 | /// 209 | /// Whenever a event like a user input or instruction from the main application is detected 210 | /// it will call [`handle_event`] to take required action for the event. 211 | /// Then it will be do some checks if it is really necessory to redraw the screen 212 | /// and redraw if it event requires it to do so. 213 | /// 214 | /// For example if all rows in a terminal aren't filled and a 215 | /// [`AppendData`](super::events::Event::AppendData) event occurs, it is absolutely necessory 216 | /// to update the screen immediately; while if all rows are filled, we can omit to redraw the 217 | /// screen. 218 | #[allow(clippy::too_many_lines)] 219 | fn start_reactor( 220 | rx: &Receiver, 221 | ps: &Arc>, 222 | out: &Stdout, 223 | #[cfg(feature = "search")] input_thread_running: &Arc<(Mutex, Condvar)>, 224 | is_exited: &Arc, 225 | ) -> Result<(), MinusError> { 226 | let mut out_lock = out.lock(); 227 | let mut command_queue = CommandQueue::new(); 228 | 229 | { 230 | let mut p = ps.lock(); 231 | 232 | draw_full(&mut out_lock, &mut p)?; 233 | 234 | if p.follow_output { 235 | draw_for_change(&mut out_lock, &mut p, &mut (usize::MAX - 1))?; 236 | } 237 | } 238 | 239 | let run_mode = *RUNMODE.lock(); 240 | match run_mode { 241 | #[cfg(feature = "dynamic_output")] 242 | RunMode::Dynamic => loop { 243 | if is_exited.load(Ordering::SeqCst) { 244 | let mut rm = RUNMODE.lock(); 245 | *rm = RunMode::Uninitialized; 246 | drop(rm); 247 | break; 248 | } 249 | 250 | let next_command = if command_queue.is_empty() { 251 | rx.recv() 252 | } else { 253 | Ok(command_queue.pop_front().unwrap()) 254 | }; 255 | 256 | if let Ok(command) = next_command { 257 | let mut p = ps.lock(); 258 | handle_event( 259 | command, 260 | &mut out_lock, 261 | &mut p, 262 | &mut command_queue, 263 | is_exited, 264 | #[cfg(feature = "search")] 265 | input_thread_running, 266 | )?; 267 | } 268 | }, 269 | #[cfg(feature = "static_output")] 270 | RunMode::Static => { 271 | { 272 | let mut p = ps.lock(); 273 | if p.follow_output { 274 | display::draw_for_change(&mut out_lock, &mut p, &mut (usize::MAX - 1))?; 275 | } 276 | } 277 | 278 | loop { 279 | if is_exited.load(Ordering::SeqCst) { 280 | // Cleanup the screen 281 | // 282 | // This is not needed in dynamic paging because this is already handled by handle_event 283 | term::cleanup(&mut out_lock, &ps.lock().exit_strategy, true)?; 284 | 285 | let mut rm = RUNMODE.lock(); 286 | *rm = RunMode::Uninitialized; 287 | drop(rm); 288 | 289 | break; 290 | } 291 | let next_command = if command_queue.is_empty() { 292 | rx.recv() 293 | } else { 294 | Ok(command_queue.pop_front().unwrap()) 295 | }; 296 | 297 | if let Ok(command) = next_command { 298 | let mut p = ps.lock(); 299 | handle_event( 300 | command, 301 | &mut out_lock, 302 | &mut p, 303 | &mut command_queue, 304 | is_exited, 305 | #[cfg(feature = "search")] 306 | input_thread_running, 307 | )?; 308 | } 309 | } 310 | } 311 | RunMode::Uninitialized => panic!( 312 | "Static variable RUNMODE set to uninitialized.\ 313 | This is most likely a bug. Please open an issue to the developers" 314 | ), 315 | } 316 | Ok(()) 317 | } 318 | 319 | fn event_reader( 320 | evtx: &Sender, 321 | ps: &Arc>, 322 | #[cfg(feature = "search")] user_input_active: &Arc<(Mutex, Condvar)>, 323 | is_exited: &Arc, 324 | ) -> Result<(), MinusError> { 325 | loop { 326 | if is_exited.load(Ordering::SeqCst) { 327 | break; 328 | } 329 | 330 | #[cfg(feature = "search")] 331 | { 332 | let (lock, cvar) = (&user_input_active.0, &user_input_active.1); 333 | cvar.wait_while(&mut lock.lock(), |pending| !*pending); 334 | } 335 | 336 | if event::poll(std::time::Duration::from_millis(100)) 337 | .map_err(|e| MinusError::HandleEvent(e.into()))? 338 | { 339 | let ev = event::read().map_err(|e| MinusError::HandleEvent(e.into()))?; 340 | let mut guard = ps.lock(); 341 | // Get the events 342 | let input = guard.input_classifier.classify_input(ev, &guard); 343 | if let Some(iev) = input { 344 | if !matches!(iev, InputEvent::Number(_)) { 345 | guard.prefix_num.clear(); 346 | guard.format_prompt(); 347 | } 348 | if let Err(TrySendError::Disconnected(_)) = evtx.try_send(Command::UserInput(iev)) { 349 | break; 350 | } 351 | } else { 352 | guard.prefix_num.clear(); 353 | guard.format_prompt(); 354 | } 355 | } 356 | } 357 | Result::<(), MinusError>::Ok(()) 358 | } 359 | -------------------------------------------------------------------------------- /src/core/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | pub mod commands; 4 | pub mod ev_handler; 5 | #[cfg(any(feature = "dynamic_output", feature = "static_output"))] 6 | pub mod init; 7 | pub mod utils; 8 | pub static RUNMODE: parking_lot::Mutex = parking_lot::const_mutex(RunMode::Uninitialized); 9 | 10 | use commands::Command; 11 | 12 | /// A [VecDeque] to hold [Command]s to be executed after the current command has been executed 13 | /// 14 | /// Many [Command]s in minus require additional commands to be executed once the current command's 15 | /// main objective has ben completed. For example the [SetLineNumbers](Command::SetLineNumbers) 16 | /// requires the text data to be reformatted and repainted on the screen. Hence it can push that 17 | /// command to this to be executed once it itself has completed executing. 18 | /// 19 | /// This also takes into account [RUNMODE] before inserting data. The means that it will ensure that 20 | /// [RUNMODE] is not uninitialized before pushing any data into the queue. Hence it is best used 21 | /// case is while declaring handlers for [Command::UserInput]. 22 | /// 23 | /// This is a FIFO type hence the command that enters first gets executed first. 24 | pub struct CommandQueue(VecDeque); 25 | 26 | impl CommandQueue { 27 | /// Create a new CommandQueue with default size of 10. 28 | pub fn new() -> Self { 29 | Self(VecDeque::with_capacity(10)) 30 | } 31 | /// Create a new CommandQueue with zero memory allocation. 32 | /// 33 | /// This is useful when we have to pass this type to [handle_event](ev_handler::handle_event) 34 | /// but it is sure that this won't be used. 35 | pub fn new_zero() -> Self { 36 | Self(VecDeque::with_capacity(0)) 37 | } 38 | /// Returns true if the queue is empty. 39 | pub fn is_empty(&self) -> bool { 40 | self.0.is_empty() 41 | } 42 | /// Store `value` only if [RUNMODE] is not unintialized. 43 | /// 44 | /// # Panics 45 | /// This function will panic if it is called in an environment where [RUNMODE] is 46 | /// uninitialized. 47 | pub fn push_back(&mut self, value: Command) { 48 | assert!(!RUNMODE.lock().is_uninitialized(), "CommandQueue::push_back() caled when RUNMODE is not set. This is most likely a bug. Please report the issue on minus's issue tracker on Github."); 49 | self.0.push_back(value); 50 | } 51 | /// Store `value` without checking [RUNMODE]. 52 | /// 53 | /// This is only meant to be used as an optimization over [push_back](CommandQueue::push_back) 54 | /// when it is absolutely sure that [RUNMODE] isn't uninitialized. Hence calling this in an 55 | /// enviroment where [RUNMODE] is uninitialized can lead to unexpect slowdowns. 56 | pub fn push_back_unchecked(&mut self, value: Command) { 57 | self.0.push_back(value); 58 | } 59 | pub fn pop_front(&mut self) -> Option { 60 | self.0.pop_front() 61 | } 62 | } 63 | 64 | /// Define the modes in which minus can run 65 | #[derive(Copy, Clone, PartialEq, Eq)] 66 | pub enum RunMode { 67 | #[cfg(feature = "static_output")] 68 | Static, 69 | #[cfg(feature = "dynamic_output")] 70 | Dynamic, 71 | Uninitialized, 72 | } 73 | 74 | impl RunMode { 75 | /// Returns true if minus hasn't started 76 | /// 77 | /// # Example 78 | /// ``` 79 | /// use minus::RunMode; 80 | /// 81 | /// let runmode = RunMode::Uninitialized; 82 | /// assert_eq!(runmode.is_uninitialized(), true); 83 | /// ``` 84 | #[must_use] 85 | pub fn is_uninitialized(self) -> bool { 86 | self == Self::Uninitialized 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/core/utils/display/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use crossterm::{ 4 | cursor::MoveTo, 5 | execute, queue, 6 | terminal::{Clear, ClearType}, 7 | }; 8 | 9 | use std::{cmp::Ordering, convert::TryInto, io::Write}; 10 | 11 | use super::term; 12 | use crate::screen::Row; 13 | use crate::{error::MinusError, minus_core, LineNumbers, PagerState}; 14 | 15 | /// How should the incoming text be drawn on the screen 16 | #[derive(Debug, PartialEq, Eq)] 17 | pub enum AppendStyle<'a> { 18 | /// Draw only the region that needs to change 19 | PartialUpdate(&'a [Row]), 20 | 21 | /// Redraw the entire screen 22 | FullRedraw, 23 | } 24 | 25 | /// Handles drawing of screen based on movement 26 | /// 27 | /// Refreshing the entire terminal can be costly, especially on high resolution displays and this cost can turns out to be 28 | /// very high if that redrawing is required on every movement of the pager, even for small changes. 29 | /// This function calculates what part of screen needs to be redrawed on scrolling up/down and based on that, it redraws 30 | /// only that part of the terminal. 31 | pub fn draw_for_change( 32 | out: &mut impl Write, 33 | ps: &mut PagerState, 34 | new_upper_mark: &mut usize, 35 | ) -> Result<(), MinusError> { 36 | let line_count = ps.screen.formatted_lines_count(); 37 | 38 | // Reduce one row for prompt/messages 39 | // 40 | // NOTE This should be the value of rows that should be used throughout this function. 41 | // Don't use PagerState::rows, it might lead to wrong output 42 | let writable_rows = ps.rows.saturating_sub(1); 43 | 44 | // Calculate the lower_bound for current and new upper marks 45 | // by adding either the rows or line_count depending on the minimality 46 | let lower_bound = ps.upper_mark.saturating_add(writable_rows.min(line_count)); 47 | let new_lower_bound = new_upper_mark.saturating_add(writable_rows.min(line_count)); 48 | 49 | // If the lower_bound is greater than the available line count, we set it to such a value 50 | // so that the last page can be displayed entirely, i.e never scroll past the last line 51 | if new_lower_bound > line_count { 52 | *new_upper_mark = line_count.saturating_sub(writable_rows); 53 | } 54 | 55 | let delta = new_upper_mark.abs_diff(ps.upper_mark); 56 | // Sometimes the value of delta is too large that we can rather use the value of the writable rows to 57 | // achieve the same effect with better performance. This means that we have draw to less lines to the terminal 58 | // 59 | // Think of it like this:- 60 | // Let's say the current upper mark is at 100 and writable rows is 25. Now if there is a jump of 200th line, 61 | // then instead of writing 100 lines, we can just jump to the 200 line and display the next 25 lines from there on. 62 | // 63 | // Hence here we can take the minimum of the delta or writable rows for displaying 64 | // 65 | // NOTE that the large delta case may not always be true in case of scrolling down. Actually this method produces 66 | // wrong output if this is not the case hence we still rely on using lower bounds method. But for scrolling up, we 67 | // need this value whatever the value of delta be. 68 | let normalized_delta = delta.min(writable_rows); 69 | 70 | let lines = match (*new_upper_mark).cmp(&ps.upper_mark) { 71 | Ordering::Greater => { 72 | // Scroll down `normalized_delta` lines, and put the cursor one line above, where the old prompt would present. 73 | // Clear it off and start displaying new dta. 74 | queue!( 75 | out, 76 | crossterm::terminal::ScrollUp(normalized_delta.try_into().unwrap()) 77 | )?; 78 | term::move_cursor( 79 | out, 80 | 0, 81 | ps.rows 82 | .saturating_sub(normalized_delta + 1) 83 | .try_into() 84 | .unwrap(), 85 | false, 86 | )?; 87 | queue!(out, Clear(ClearType::CurrentLine))?; 88 | 89 | if delta < writable_rows { 90 | ps.screen 91 | .get_formatted_lines_with_bounds(lower_bound, new_lower_bound) 92 | } else { 93 | ps.screen.get_formatted_lines_with_bounds( 94 | *new_upper_mark, 95 | new_upper_mark.saturating_add(normalized_delta), 96 | ) 97 | } 98 | } 99 | Ordering::Less => { 100 | execute!( 101 | out, 102 | crossterm::terminal::ScrollDown(normalized_delta.try_into().unwrap()) 103 | )?; 104 | term::move_cursor(out, 0, 0, false)?; 105 | 106 | ps.screen.get_formatted_lines_with_bounds( 107 | *new_upper_mark, 108 | new_upper_mark.saturating_add(normalized_delta), 109 | ) 110 | } 111 | Ordering::Equal => return Ok(()), 112 | }; 113 | 114 | write_lines( 115 | out, 116 | lines, 117 | ps.cols, 118 | ps.screen.line_wrapping, 119 | ps.left_mark, 120 | ps.line_numbers.is_on(), 121 | ps.screen.line_count(), 122 | )?; 123 | 124 | ps.upper_mark = *new_upper_mark; 125 | 126 | if ps.show_prompt { 127 | super::display::write_prompt(out, &ps.displayed_prompt, ps.rows.try_into().unwrap())?; 128 | } 129 | out.flush()?; 130 | 131 | Ok(()) 132 | } 133 | 134 | /// Write given text at the prompt site 135 | pub fn write_prompt(out: &mut impl Write, text: &str, rows: u16) -> Result<(), MinusError> { 136 | write!(out, "{mv}\r{prompt}", mv = MoveTo(0, rows), prompt = text)?; 137 | out.flush()?; 138 | Ok(()) 139 | } 140 | 141 | // The below functions are just a subset of functionality of the above draw_for_change function. 142 | // Although, separate they are tightly coupled together. 143 | 144 | /// Completely redraws the screen 145 | /// 146 | /// The function will first print out the lines from the current upper_mark. This is handled inside the [`write_lines`] 147 | /// function. 148 | /// 149 | /// Then it will check if there is any message to display. 150 | /// - If there is one, it will display it at the prompt site 151 | /// - If there isn't one, it will display the prompt in place of it 152 | pub fn draw_full(out: &mut impl Write, ps: &mut PagerState) -> Result<(), MinusError> { 153 | super::term::move_cursor(out, 0, 0, false)?; 154 | queue!(out, Clear(ClearType::All))?; 155 | 156 | write_from_pagerstate(out, ps)?; 157 | 158 | let pager_rows: u16 = ps.rows.try_into().map_err(|_| MinusError::Conversion)?; 159 | 160 | if ps.show_prompt { 161 | write_prompt(out, &ps.displayed_prompt, pager_rows)?; 162 | } 163 | 164 | out.flush().map_err(MinusError::Draw) 165 | } 166 | 167 | pub fn draw_append_text( 168 | out: &mut impl Write, 169 | rows: usize, 170 | prev_unterminated: usize, 171 | prev_fmt_lines_count: usize, 172 | append_style: &AppendStyle, 173 | ) -> Result<(), MinusError> { 174 | let AppendStyle::PartialUpdate(fmt_text) = append_style else { 175 | unreachable!() 176 | }; 177 | 178 | if prev_fmt_lines_count < rows { 179 | // Move the cursor to the very next line after the last displayed line 180 | term::move_cursor( 181 | out, 182 | 0, 183 | prev_fmt_lines_count 184 | .saturating_sub(prev_unterminated) 185 | .try_into() 186 | .unwrap(), 187 | false, 188 | )?; 189 | // available_rows -> Rows that are still unfilled 190 | // rows - number of lines displayed -1 (for prompt) 191 | // For example if 20 rows are in total in a terminal 192 | // and 10 rows are already occupied, then this will be equal to 9 193 | let available_rows = rows.saturating_sub( 194 | prev_fmt_lines_count 195 | .saturating_sub(prev_unterminated) 196 | .saturating_add(1), 197 | ); 198 | // Minimum amount of text that an be appended 199 | // If available_rows is less, than this will be available rows else it will be 200 | // the length of the formatted text 201 | // 202 | // If number of rows in terminal is 23 with 20 rows filled and another 5 lines are given 203 | // This woll be equal to 3 as available rows will be 3 204 | // If in the above example only 2 lines need to be added, this will be equal to 2 205 | let num_appendable = fmt_text.len().min(available_rows); 206 | if num_appendable >= 1 { 207 | crossterm::execute!(out, crossterm::terminal::Clear(ClearType::CurrentLine))?; 208 | } 209 | for line in &fmt_text[0..num_appendable] { 210 | write!(out, "{}\n\r", line)?; 211 | } 212 | out.flush()?; 213 | } 214 | Ok(()) 215 | } 216 | 217 | /// Write the lines to the terminal 218 | /// 219 | /// Note: Although this function can take any type that implements [Write] however it assumes that 220 | /// it behaves like a terminal i.e it must set rows and cols in [PagerState]. 221 | /// If you want to write directly to a file without this preassumption, then use the [write_lines] 222 | /// function. 223 | /// 224 | /// Draws (at most) `rows -1` lines, where the first line to display is 225 | /// [`PagerState::upper_mark`]. This function will always try to display as much lines as 226 | /// possible within `rows -1`. 227 | /// 228 | /// It always skips one row at the bottom as a site for the prompt or any message that may be sent. 229 | /// 230 | /// This function ensures that upper mark never exceeds a value such that adding upper mark and available rows exceeds 231 | /// the number of lines of text data. This rule is disobeyed in only one special case which is if number of lines of 232 | /// text is less than available rows. In this situation, upper mark is always 0. 233 | #[allow(clippy::too_many_arguments)] 234 | pub fn write_text_checked( 235 | out: &mut impl Write, 236 | lines: &[String], 237 | mut upper_mark: usize, 238 | rows: usize, 239 | cols: usize, 240 | line_wrapping: bool, 241 | left_mark: usize, 242 | line_numbers: LineNumbers, 243 | total_line_count: usize, 244 | ) -> Result<(), MinusError> { 245 | let line_count = lines.len(); 246 | 247 | // Reduce one row for prompt/messages 248 | let writable_rows = rows.saturating_sub(1); 249 | 250 | // Calculate the lower_mark by adding either the rows or line_count depending 251 | // on the minimality 252 | let mut lower_mark = upper_mark.saturating_add(writable_rows.min(line_count)); 253 | 254 | // If the lower_bound is greater than the available line count, we set it to such a value 255 | // so that the last page can be displayed entirely, i.e never scroll past the last line 256 | if lower_mark > line_count { 257 | upper_mark = line_count.saturating_sub(writable_rows); 258 | lower_mark = upper_mark.saturating_add(writable_rows.min(line_count)); 259 | } 260 | 261 | // Add \r to ensure cursor is placed at the beginning of each row 262 | let display_lines: &[String] = &lines[upper_mark..lower_mark]; 263 | 264 | term::move_cursor(out, 0, 0, false)?; 265 | term::clear_entire_screen(out, false)?; 266 | 267 | write_lines( 268 | out, 269 | display_lines, 270 | cols, 271 | line_wrapping, 272 | left_mark, 273 | line_numbers.is_on(), 274 | total_line_count, 275 | ) 276 | } 277 | 278 | pub fn write_from_pagerstate(out: &mut impl Write, ps: &mut PagerState) -> Result<(), MinusError> { 279 | let line_count = ps.screen.formatted_lines_count(); 280 | 281 | // Reduce one row for prompt/messages 282 | let writable_rows = ps.rows.saturating_sub(1); 283 | 284 | // Calculate the lower_mark by adding either the rows or line_count depending 285 | // on the minimality 286 | let lower_mark = ps.upper_mark.saturating_add(writable_rows.min(line_count)); 287 | if lower_mark > line_count { 288 | ps.upper_mark = line_count.saturating_sub(writable_rows); 289 | } 290 | 291 | // Add \r to ensure cursor is placed at the beginning of each row 292 | let display_lines: &[String] = ps 293 | .screen 294 | .get_formatted_lines_with_bounds(ps.upper_mark, lower_mark); 295 | 296 | write_lines( 297 | out, 298 | display_lines, 299 | ps.cols, 300 | ps.screen.line_wrapping, 301 | ps.left_mark, 302 | ps.line_numbers.is_on(), 303 | ps.screen.line_count(), 304 | ) 305 | } 306 | 307 | pub fn write_lines( 308 | out: &mut impl Write, 309 | lines: &[String], 310 | cols: usize, 311 | line_wrapping: bool, 312 | left_mark: usize, 313 | line_numbers: bool, 314 | line_count: usize, 315 | ) -> crate::Result { 316 | if line_wrapping { 317 | write_raw_lines(out, lines, Some("\r")) 318 | } else { 319 | write_lines_in_horizontal_scroll(out, lines, cols, left_mark, line_numbers, line_count) 320 | } 321 | } 322 | 323 | pub fn write_lines_in_horizontal_scroll( 324 | out: &mut impl Write, 325 | lines: &[String], 326 | cols: usize, 327 | start: usize, 328 | line_numbers: bool, 329 | line_count: usize, 330 | ) -> crate::Result { 331 | let line_number_ascii_seq_len = if line_numbers { 8 } else { 0 }; 332 | let line_number_padding = if line_numbers { 333 | minus_core::utils::digits(line_count) + LineNumbers::EXTRA_PADDING + 3 334 | } else { 335 | 0 336 | }; 337 | let shifted_start = if line_numbers { 338 | start + line_number_padding + line_number_ascii_seq_len 339 | } else { 340 | start 341 | }; 342 | 343 | for line in lines { 344 | let end = shifted_start + cols.min(line.len().saturating_sub(shifted_start)) 345 | - line_number_padding; 346 | 347 | if start < line.len() { 348 | if line_numbers { 349 | writeln!( 350 | out, 351 | "\r{}{}", 352 | &line[0..line_number_padding + line_number_ascii_seq_len], 353 | &line[shifted_start..end] 354 | )?; 355 | } else { 356 | writeln!(out, "\r{}", &line[shifted_start..end])?; 357 | } 358 | } else { 359 | writeln!(out, "\r")?; 360 | } 361 | } 362 | Ok(()) 363 | } 364 | 365 | /// Write lines to the the output 366 | /// 367 | /// Outputs all the `lines` to `out` without any preassumption about terminals. 368 | /// `initial` tells any extra text to be inserted before each line. For functions that use this 369 | /// function over terminals, this should be set to `\r` to avoid broken display. 370 | /// The `\r` resets the cursor to the start of the line. 371 | pub fn write_raw_lines( 372 | out: &mut impl Write, 373 | lines: &[String], 374 | initial: Option<&str>, 375 | ) -> Result<(), MinusError> { 376 | for line in lines { 377 | writeln!(out, "{}{line}", initial.unwrap_or(""))?; 378 | } 379 | Ok(()) 380 | } 381 | 382 | #[cfg(test)] 383 | mod tests; 384 | -------------------------------------------------------------------------------- /src/core/utils/display/tests.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::shadow_unrelated)] 2 | #![allow(clippy::cast_possible_truncation)] 3 | use super::{draw_for_change, draw_full, write_from_pagerstate, write_prompt}; 4 | use crate::{LineNumbers, PagerState}; 5 | use std::fmt::Write; 6 | 7 | // * In some places, where test lines are close to the row, 1 should be added 8 | // to the rows because `write_lines` does care about the prompt 9 | 10 | // The pager assumes 80 columns and 10 rows in tests 11 | // Wherever the tests require this 80x10 configuration, no explicit assignment is done 12 | // In other cases, the tests do set the their required values 13 | 14 | #[test] 15 | fn short_no_line_numbers() { 16 | let lines = "A line\nAnother line"; 17 | let mut pager = PagerState::new().unwrap(); 18 | 19 | pager.screen.orig_text = lines.to_string(); 20 | pager.format_lines(); 21 | 22 | let mut out = Vec::with_capacity(lines.len()); 23 | 24 | assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); 25 | 26 | assert_eq!( 27 | "\rA line\n\rAnother line\n", 28 | String::from_utf8(out).expect("Should have written valid UTF-8") 29 | ); 30 | assert_eq!(pager.upper_mark, 0); 31 | 32 | let mut out = Vec::with_capacity(lines.len()); 33 | pager.upper_mark += 1; 34 | 35 | assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); 36 | 37 | // The number of lines is less than 'rows' so 'upper_mark' will be 0 even 38 | // if we set it to 1. This is done because everything can be displayed without problems. 39 | assert_eq!( 40 | "\rA line\n\rAnother line\n", 41 | String::from_utf8(out).expect("Should have written valid UTF-8") 42 | ); 43 | assert_eq!(pager.upper_mark, 0); 44 | } 45 | 46 | #[test] 47 | fn long_no_line_numbers() { 48 | let lines = "A line\nAnother line\nThird line\nFourth line"; 49 | 50 | // Displaying as much of the lines as possible from the start. 51 | let mut out = Vec::with_capacity(lines.len()); 52 | let mut pager = PagerState::new().unwrap(); 53 | // One extra line for prompt 54 | pager.rows = 4; 55 | pager.screen.orig_text = lines.to_string(); 56 | pager.format_lines(); 57 | 58 | assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); 59 | 60 | assert_eq!( 61 | "\rA line\n\rAnother line\n\rThird line\n", 62 | String::from_utf8(out).expect("Should have written valid UTF-8") 63 | ); 64 | assert_eq!(pager.upper_mark, 0); 65 | 66 | // This ensures that asking for a position other than 0 works. 67 | let mut out = Vec::with_capacity(lines.len()); 68 | pager.screen.orig_text = "Another line\nThird line\nFourth line\nFifth line\n".to_string(); 69 | pager.upper_mark = 1; 70 | pager.format_lines(); 71 | 72 | assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); 73 | 74 | assert_eq!( 75 | "\rThird line\n\rFourth line\n\rFifth line\n", 76 | String::from_utf8(out).expect("Should have written valid UTF-8") 77 | ); 78 | assert_eq!(pager.upper_mark, 1); 79 | 80 | // This test ensures that as much text as possible will be displayed, even 81 | // when less is asked for. 82 | let mut out = Vec::with_capacity(lines.len()); 83 | pager.upper_mark = 2; 84 | 85 | assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); 86 | 87 | assert_eq!( 88 | "\rThird line\n\rFourth line\n\rFifth line\n", 89 | String::from_utf8(out).expect("Should have written valid UTF-8") 90 | ); 91 | assert_eq!(pager.upper_mark, 1); 92 | } 93 | 94 | #[test] 95 | fn short_with_line_numbers() { 96 | let lines = "A line\nAnother line"; 97 | 98 | let mut out = Vec::with_capacity(lines.len()); 99 | let mut pager = PagerState::new().unwrap(); 100 | pager.screen.orig_text = lines.to_string(); 101 | pager.line_numbers = LineNumbers::Enabled; 102 | pager.format_lines(); 103 | 104 | assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); 105 | 106 | assert_eq!( 107 | "\r 1. A line\n\r 2. Another line\n", 108 | String::from_utf8(out).expect("Should have written valid UTF-8") 109 | ); 110 | assert_eq!(pager.upper_mark, 0); 111 | 112 | let mut out = Vec::with_capacity(lines.len()); 113 | pager.upper_mark = 1; 114 | pager.line_numbers = LineNumbers::AlwaysOn; 115 | 116 | assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); 117 | 118 | // The number of lines is less than 'rows' so 'upper_mark' will be 0 even 119 | // if we set it to 1. This is done because everything can be displayed without problems. 120 | assert_eq!( 121 | "\r 1. A line\n\r 2. Another line\n", 122 | String::from_utf8(out).expect("Should have written valid UTF-8") 123 | ); 124 | assert_eq!(pager.upper_mark, 0); 125 | } 126 | 127 | #[test] 128 | fn long_with_line_numbers() { 129 | let lines = "A line\nAnother line\nThird line\nFourth line"; 130 | 131 | // Displaying as much of the lines as possible from the start. 132 | let mut out = Vec::with_capacity(lines.len()); 133 | let mut pager = PagerState::new().unwrap(); 134 | pager.rows = 4; 135 | pager.screen.orig_text = lines.to_string(); 136 | pager.line_numbers = LineNumbers::Enabled; 137 | pager.format_lines(); 138 | 139 | assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); 140 | 141 | assert_eq!( 142 | "\r 1. A line\n\r 2. Another line\n\r 3. Third line\n", 143 | String::from_utf8(out).expect("Should have written valid UTF-8") 144 | ); 145 | assert_eq!(pager.upper_mark, 0); 146 | 147 | // This ensures that asking for a position other than 0 works. 148 | let mut out = Vec::with_capacity(lines.len()); 149 | pager.upper_mark = 1; 150 | 151 | assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); 152 | 153 | assert_eq!( 154 | "\r 2. Another line\n\r 3. Third line\n\r 4. Fourth line\n", 155 | String::from_utf8(out).expect("Should have written valid UTF-8") 156 | ); 157 | assert_eq!(pager.upper_mark, 1); 158 | 159 | // This test ensures that as much text as possible will be displayed, even 160 | // when less is asked for. 161 | let mut out = Vec::with_capacity(lines.len()); 162 | pager.upper_mark = 2; 163 | 164 | assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); 165 | 166 | assert_eq!( 167 | "\r 2. Another line\n\r 3. Third line\n\r 4. Fourth line\n", 168 | String::from_utf8(out).expect("Should have written valid UTF-8") 169 | ); 170 | assert_eq!(pager.upper_mark, 1); 171 | } 172 | 173 | #[test] 174 | fn big_line_numbers_are_padded() { 175 | let lines = { 176 | let mut l = String::with_capacity(450); 177 | for i in 0..110 { 178 | writeln!(&mut l, "L{i}").unwrap(); 179 | } 180 | l 181 | }; 182 | 183 | let mut out = Vec::with_capacity(lines.len()); 184 | let mut pager = PagerState::new().unwrap(); 185 | pager.upper_mark = 95; 186 | pager.rows = 11; 187 | pager.screen.orig_text = lines; 188 | pager.line_numbers = LineNumbers::AlwaysOn; 189 | pager.format_lines(); 190 | 191 | assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); 192 | 193 | // The padding should have inserted a space before the numbers that are less than 100. 194 | assert_eq!( 195 | "\r 96. L95\n\r 97. L96\n\r 98. L97\n\r 99. L98\n\r 100. \ 196 | L99\n\r 101. L100\n\r 102. L101\n\r 103. L102\n\r 104. L103\n\r 105. L104\n", 197 | String::from_utf8(out).expect("Should have written valid UTF-8") 198 | ); 199 | assert_eq!(pager.upper_mark, 95); 200 | } 201 | 202 | #[test] 203 | fn line_numbers_not() { 204 | #[allow(clippy::enum_glob_use)] 205 | use LineNumbers::*; 206 | 207 | assert_eq!(AlwaysOn, !AlwaysOn); 208 | assert_eq!(AlwaysOff, !AlwaysOff); 209 | assert_eq!(Enabled, !Disabled); 210 | assert_eq!(Disabled, !Enabled); 211 | } 212 | 213 | #[test] 214 | fn line_numbers_invertible() { 215 | #[allow(clippy::enum_glob_use)] 216 | use LineNumbers::*; 217 | 218 | assert!(!AlwaysOn.is_invertible()); 219 | assert!(!AlwaysOff.is_invertible()); 220 | assert!(Enabled.is_invertible()); 221 | assert!(Disabled.is_invertible()); 222 | } 223 | 224 | #[test] 225 | fn draw_short_no_line_numbers() { 226 | let lines = "A line\nAnother line"; 227 | 228 | let mut out = Vec::with_capacity(lines.len()); 229 | let mut pager = PagerState::new().unwrap(); 230 | pager.screen.orig_text = lines.to_string(); 231 | pager.line_numbers = LineNumbers::AlwaysOff; 232 | pager.format_lines(); 233 | 234 | assert!(draw_full(&mut out, &mut pager).is_ok()); 235 | 236 | assert!(String::from_utf8(out) 237 | .expect("Should have written valid UTF-8") 238 | .contains("\rA line\n\rAnother line")); 239 | assert_eq!(pager.upper_mark, 0); 240 | 241 | let mut out = Vec::with_capacity(lines.len()); 242 | pager.upper_mark = 1; 243 | 244 | assert!(draw_full(&mut out, &mut pager).is_ok()); 245 | 246 | // The number of lines is less than 'rows' so 'upper_mark' will be 0 even 247 | // if we set it to 1. This is done because everything can be displayed without problems. 248 | assert!(String::from_utf8(out) 249 | .expect("Should have written valid UTF-8") 250 | .contains("\rA line\n\rAnother line")); 251 | assert_eq!(pager.upper_mark, 0); 252 | } 253 | 254 | #[test] 255 | fn draw_long_no_line_numbers() { 256 | let lines = "A line\nAnother line\nThird line\nFourth line"; 257 | 258 | // Displaying as much of the lines as possible from the start. 259 | let mut out = Vec::with_capacity(lines.len()); 260 | let mut pager = PagerState::new().unwrap(); 261 | pager.rows = 3; 262 | pager.screen.orig_text = lines.to_string(); 263 | pager.format_lines(); 264 | 265 | assert!(draw_full(&mut out, &mut pager).is_ok()); 266 | 267 | assert!(String::from_utf8(out) 268 | .expect("Should have written valid UTF-8") 269 | .contains("\rA line\n\rAnother line")); 270 | assert_eq!(pager.upper_mark, 0); 271 | 272 | // This ensures that asking for a position other than 0 works. 273 | let mut out = Vec::with_capacity(lines.len()); 274 | pager.upper_mark = 1; 275 | 276 | assert!(draw_full(&mut out, &mut pager).is_ok()); 277 | 278 | assert!(String::from_utf8(out) 279 | .expect("Should have written valid UTF-8") 280 | .contains("\rAnother line\n\rThird line")); 281 | assert_eq!(pager.upper_mark, 1); 282 | 283 | // This test ensures that as much text as possible will be displayed, even 284 | // when less is asked for. 285 | let mut out = Vec::with_capacity(lines.len()); 286 | pager.upper_mark = 3; 287 | 288 | assert!(draw_full(&mut out, &mut pager).is_ok()); 289 | 290 | assert!(String::from_utf8(out) 291 | .expect("Should have written valid UTF-8") 292 | .contains("\rThird line\n\rFourth line")); 293 | assert_eq!(pager.upper_mark, 2); 294 | } 295 | 296 | #[test] 297 | fn draw_short_with_line_numbers() { 298 | let lines = "A line\nAnother line"; 299 | let mut out = Vec::with_capacity(lines.len()); 300 | let mut pager = PagerState::new().unwrap(); 301 | pager.screen.orig_text = lines.to_string(); 302 | pager.line_numbers = LineNumbers::Enabled; 303 | pager.format_lines(); 304 | 305 | assert!(draw_full(&mut out, &mut pager).is_ok()); 306 | assert!(String::from_utf8(out) 307 | .expect("Should have written valid UTF-8") 308 | .contains("\r 1. A line\n\r 2. Another line")); 309 | assert_eq!(pager.upper_mark, 0); 310 | 311 | let mut out = Vec::with_capacity(lines.len()); 312 | pager.upper_mark = 1; 313 | 314 | assert!(draw_full(&mut out, &mut pager).is_ok()); 315 | 316 | // The number of lines is less than 'rows' so 'upper_mark' will be 0 even 317 | // if we set it to 1. This is done because everything can be displayed without problems. 318 | assert!(String::from_utf8(out) 319 | .expect("Should have written valid UTF-8") 320 | .contains("\r 1. A line\n\r 2. Another line")); 321 | assert_eq!(pager.upper_mark, 0); 322 | } 323 | 324 | #[test] 325 | fn draw_long_with_line_numbers() { 326 | let lines = "A line\nAnother line\nThird line\nFourth line"; 327 | 328 | // Displaying as much of the lines as possible from the start. 329 | let mut out = Vec::with_capacity(lines.len()); 330 | let mut pager = PagerState::new().unwrap(); 331 | pager.rows = 3; 332 | pager.screen.orig_text = lines.to_string(); 333 | pager.line_numbers = LineNumbers::Enabled; 334 | pager.format_lines(); 335 | 336 | assert!(draw_full(&mut out, &mut pager).is_ok()); 337 | 338 | assert!(String::from_utf8(out) 339 | .expect("Should have written valid UTF-8") 340 | .contains("\r 1. A line\n\r 2. Another line")); 341 | assert_eq!(pager.upper_mark, 0); 342 | 343 | // This ensures that asking for a position other than 0 works. 344 | let mut out = Vec::with_capacity(lines.len()); 345 | pager.upper_mark = 1; 346 | 347 | assert!(draw_full(&mut out, &mut pager).is_ok()); 348 | 349 | assert!(String::from_utf8(out) 350 | .expect("Should have written valid UTF-8") 351 | .contains("\r 2. Another line\n\r 3. Third line")); 352 | assert_eq!(pager.upper_mark, 1); 353 | 354 | // This test ensures that as much text as possible will be displayed, even 355 | // when less is asked for. 356 | let mut out = Vec::with_capacity(lines.len()); 357 | pager.upper_mark = 3; 358 | 359 | assert!(draw_full(&mut out, &mut pager).is_ok()); 360 | 361 | assert!(String::from_utf8(out) 362 | .expect("Should have written valid UTF-8") 363 | .contains("\r 3. Third line\n\r 4. Fourth line")); 364 | assert_eq!(pager.upper_mark, 2); 365 | } 366 | 367 | #[test] 368 | fn draw_big_line_numbers_are_padded() { 369 | let lines = { 370 | let mut l = String::with_capacity(450); 371 | for i in 0..110 { 372 | writeln!(&mut l, "L{i}").unwrap(); 373 | } 374 | l 375 | }; 376 | 377 | let mut out = Vec::with_capacity(lines.len()); 378 | let mut pager = PagerState::new().unwrap(); 379 | pager.upper_mark = 95; 380 | pager.screen.orig_text = lines; 381 | pager.line_numbers = LineNumbers::Enabled; 382 | pager.format_lines(); 383 | 384 | assert!(draw_full(&mut out, &mut pager).is_ok()); 385 | 386 | // The padding should have inserted a space before the numbers that are less than 100. 387 | assert!(String::from_utf8(out) 388 | .expect("Should have written valid UTF-8") 389 | .contains( 390 | "\r 96. L95\n\r 97. L96\n\r 98. L97\n\r 99. L98\n\r 100. L99\n\r 101. L100\n\r 102. L101\n\r 103. L102\n\r 104. L103", 391 | ) 392 | ); 393 | assert_eq!(pager.upper_mark, 95); 394 | } 395 | 396 | #[test] 397 | fn draw_wrapping_line_numbers() { 398 | let lines = (0..3) 399 | .map(|l| format!("Line {l}: This is the line who is {l}")) 400 | .collect::>() 401 | .join("\n"); 402 | 403 | let mut out = Vec::new(); 404 | let mut pager = PagerState::new().unwrap(); 405 | pager.screen.orig_text = lines; 406 | pager.cols = 30; 407 | pager.upper_mark = 2; 408 | pager.line_numbers = LineNumbers::Enabled; 409 | pager.format_lines(); 410 | 411 | assert!(draw_full(&mut out, &mut pager).is_ok()); 412 | 413 | let written = String::from_utf8(out).expect("Should have written valid UTF-8"); 414 | let expected = " 2. Line 1: This is the\n\r line who is 1\n\r 3. Line 2: This is the\n\r line who is 2"; 415 | assert!(written.contains(expected)); 416 | } 417 | 418 | #[test] 419 | fn draw_help_message() { 420 | let lines = "A line\nAnother line"; 421 | 422 | let mut out = Vec::with_capacity(lines.len()); 423 | let mut pager = PagerState::new().unwrap(); 424 | pager.screen.orig_text = lines.to_string(); 425 | pager.line_numbers = LineNumbers::AlwaysOff; 426 | pager.format_prompt(); 427 | 428 | draw_full(&mut out, &mut pager).expect("Should have written"); 429 | 430 | let res = String::from_utf8(out).expect("Should have written valid UTF-8"); 431 | assert!(res.contains("minus")); 432 | } 433 | 434 | #[test] 435 | fn test_draw_no_overflow() { 436 | const TEXT: &str = "This is a line of text to the pager"; 437 | let mut out = Vec::with_capacity(TEXT.len()); 438 | let mut pager = PagerState::new().unwrap(); 439 | pager.screen.orig_text = TEXT.to_string(); 440 | pager.format_lines(); 441 | draw_full(&mut out, &mut pager).unwrap(); 442 | assert!(String::from_utf8(out) 443 | .expect("Should have written valid UTF-8") 444 | .contains(TEXT)); 445 | } 446 | 447 | #[cfg(test)] 448 | mod draw_for_change_tests { 449 | use super::{draw_for_change, write_prompt}; 450 | use crate::state::PagerState; 451 | use crossterm::{ 452 | cursor::MoveTo, 453 | terminal::{Clear, ClearType, ScrollDown, ScrollUp}, 454 | }; 455 | use std::fmt::Write as FmtWrite; 456 | use std::io::Write as IOWrite; 457 | 458 | fn create_pager_state() -> PagerState { 459 | let lines = { 460 | let mut l = String::with_capacity(450); 461 | for i in 0..100 { 462 | writeln!(&mut l, "L{i}").unwrap(); 463 | } 464 | l 465 | }; 466 | let mut ps = PagerState::new().unwrap(); 467 | ps.upper_mark = 0; 468 | ps.screen.orig_text = lines; 469 | ps.format_lines(); 470 | ps.format_prompt(); 471 | ps 472 | } 473 | 474 | #[test] 475 | fn small_scrolldown() { 476 | let mut ps = create_pager_state(); 477 | let mut out = Vec::with_capacity(100); 478 | 479 | let mut res = Vec::new(); 480 | write!( 481 | res, 482 | "{}{}{}", 483 | ScrollUp(3), 484 | MoveTo(0, ps.rows as u16 - 4), 485 | Clear(ClearType::CurrentLine) 486 | ) 487 | .unwrap(); 488 | for line in &ps.screen.formatted_lines[9..12] { 489 | writeln!(res, "\r{line}").unwrap(); 490 | } 491 | write_prompt(&mut res, &ps.displayed_prompt, ps.rows as u16).unwrap(); 492 | 493 | draw_for_change(&mut out, &mut ps, &mut 3).unwrap(); 494 | 495 | assert_eq!(out, res); 496 | } 497 | 498 | #[test] 499 | fn large_scrolldown() { 500 | let mut ps = create_pager_state(); 501 | let mut out = Vec::with_capacity(100); 502 | 503 | let mut res = Vec::new(); 504 | write!( 505 | res, 506 | "{}{}{}", 507 | ScrollUp(9), 508 | MoveTo(0, 0), 509 | Clear(ClearType::CurrentLine) 510 | ) 511 | .unwrap(); 512 | for line in &ps.screen.formatted_lines[50..59] { 513 | writeln!(res, "\r{line}").unwrap(); 514 | } 515 | write_prompt(&mut res, &ps.displayed_prompt, ps.rows as u16).unwrap(); 516 | 517 | draw_for_change(&mut out, &mut ps, &mut 50).unwrap(); 518 | 519 | assert_eq!(out, res); 520 | } 521 | 522 | #[test] 523 | fn no_overflow_change() { 524 | let mut ps = create_pager_state(); 525 | ps.screen.formatted_lines.truncate(5); 526 | let mut out = Vec::with_capacity(100); 527 | let mut new_upper_mark = 10; 528 | 529 | let res = Vec::new(); 530 | 531 | draw_for_change(&mut out, &mut ps, &mut new_upper_mark).unwrap(); 532 | 533 | assert_eq!(out, res); 534 | } 535 | 536 | #[test] 537 | fn large_scrollup() { 538 | let mut ps = create_pager_state(); 539 | let mut out = Vec::with_capacity(100); 540 | ps.upper_mark = 80; 541 | 542 | let mut res = Vec::new(); 543 | write!(res, "{}{}", ScrollDown(9), MoveTo(0, 0),).unwrap(); 544 | for line in &ps.screen.formatted_lines[20..29] { 545 | writeln!(res, "\r{line}").unwrap(); 546 | } 547 | write_prompt(&mut res, &ps.displayed_prompt, ps.rows as u16).unwrap(); 548 | 549 | draw_for_change(&mut out, &mut ps, &mut 20).unwrap(); 550 | 551 | dbg!(String::from_utf8_lossy(&out)); 552 | dbg!(String::from_utf8_lossy(&res)); 553 | 554 | assert_eq!(out, res); 555 | } 556 | 557 | #[test] 558 | fn small_scrollup() { 559 | let mut ps = create_pager_state(); 560 | let mut out = Vec::with_capacity(100); 561 | ps.upper_mark = 60; 562 | 563 | let mut res = Vec::new(); 564 | write!(res, "{}{}", ScrollDown(9), MoveTo(0, 0),).unwrap(); 565 | for line in &ps.screen.formatted_lines[50..59] { 566 | writeln!(res, "\r{line}").unwrap(); 567 | } 568 | write_prompt(&mut res, &ps.displayed_prompt, ps.rows as u16).unwrap(); 569 | 570 | draw_for_change(&mut out, &mut ps, &mut 50).unwrap(); 571 | 572 | dbg!(String::from_utf8_lossy(&out)); 573 | dbg!(String::from_utf8_lossy(&res)); 574 | 575 | assert_eq!(out, res); 576 | } 577 | } 578 | -------------------------------------------------------------------------------- /src/core/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod display; 2 | pub mod term; 3 | 4 | /// Return the number of digits in `num` 5 | pub const fn digits(num: usize) -> usize { 6 | (if num == 0 { 0 } else { num.ilog10() as usize }) + 1 7 | } 8 | 9 | /// Stores the location of first row each line 10 | /// 11 | /// Due to line wrapping, each line may or may not occupy exactly one row on the terminal 12 | /// Hence we nned to keep track where the first row o each line is positioned in the entire text 13 | /// array. 14 | #[derive(Debug)] 15 | pub struct LinesRowMap(Vec); 16 | 17 | impl LinesRowMap { 18 | pub const fn new() -> Self { 19 | Self(Vec::new()) 20 | } 21 | 22 | pub fn append(&mut self, idx: &mut Self, clean_append: bool) { 23 | if !clean_append { 24 | self.0.pop(); 25 | } 26 | self.0.append(&mut idx.0); 27 | } 28 | 29 | pub fn insert(&mut self, ln: usize, clean_append: bool) { 30 | if !clean_append { 31 | self.0.pop(); 32 | } 33 | self.0.push(ln); 34 | } 35 | 36 | pub fn get(&self, ln: usize) -> Option<&usize> { 37 | self.0.get(ln) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/core/utils/term.rs: -------------------------------------------------------------------------------- 1 | //! Contains functions for dealing with setup, cleanup 2 | 3 | #![allow(dead_code)] 4 | 5 | use crate::error::{CleanupError, MinusError, SetupError}; 6 | use crossterm::{ 7 | cursor, event, execute, queue, 8 | terminal::{self, Clear}, 9 | tty::IsTty, 10 | }; 11 | use std::io; 12 | 13 | /// Setup the terminal 14 | /// 15 | /// It will 16 | /// - Switch the terminal's view to the [alternate screen] 17 | /// - Then enable [raw mode] 18 | /// - Clear the entire screen and hide the cursor. 19 | /// 20 | /// # Errors 21 | /// The function will return with an error if `stdout` is not a terminal. It will qlso fail 22 | /// if it cannot executo commands on the terminal See [`SetupError`]. 23 | /// 24 | /// [alternate screen]: ../../../crossterm/terminal/index.html#alternate-screen 25 | /// [raw mode]: ../../../crossterm/terminal/index.html#raw-mode 26 | // This function should be kept close to `cleanup` to help ensure both are 27 | // doing the opposite of the other. 28 | pub fn setup(stdout: &io::Stdout) -> std::result::Result<(), SetupError> { 29 | let mut out = stdout.lock(); 30 | 31 | if out.is_tty() { 32 | Ok(()) 33 | } else { 34 | Err(SetupError::InvalidTerminal) 35 | }?; 36 | 37 | execute!(out, terminal::EnterAlternateScreen) 38 | .map_err(|e| SetupError::AlternateScreen(e.into()))?; 39 | terminal::enable_raw_mode().map_err(|e| SetupError::RawMode(e.into()))?; 40 | execute!(out, event::EnableMouseCapture) 41 | .map_err(|e| SetupError::EnableMouseCapture(e.into()))?; 42 | execute!(out, cursor::Hide).map_err(|e| SetupError::HideCursor(e.into()))?; 43 | Ok(()) 44 | } 45 | 46 | /// Cleans up the terminal 47 | /// 48 | /// The function will clean up the terminal and set it back to its original state, 49 | /// before the pager was setup and called. 50 | /// - First the cursor is displayed 51 | /// - [Raw mode] is disabled 52 | /// - Switch the terminal's view to the main screen 53 | /// 54 | /// ## Errors 55 | /// The function will return with an error if it fails to do execute commands on the 56 | /// terminal. See [`CleanupError`] 57 | /// 58 | /// [raw mode]: ../../../crossterm/terminal/index.html#raw-mode 59 | pub fn cleanup( 60 | mut out: impl io::Write, 61 | es: &crate::ExitStrategy, 62 | cleanup_screen: bool, 63 | ) -> std::result::Result<(), CleanupError> { 64 | if cleanup_screen { 65 | // Reverse order of setup. 66 | execute!(out, cursor::Show).map_err(|e| CleanupError::ShowCursor(e.into()))?; 67 | execute!(out, event::DisableMouseCapture) 68 | .map_err(|e| CleanupError::DisableMouseCapture(e.into()))?; 69 | terminal::disable_raw_mode().map_err(|e| CleanupError::DisableRawMode(e.into()))?; 70 | execute!(out, terminal::LeaveAlternateScreen) 71 | .map_err(|e| CleanupError::LeaveAlternateScreen(e.into()))?; 72 | } 73 | 74 | if *es == crate::ExitStrategy::ProcessQuit { 75 | std::process::exit(0); 76 | } else { 77 | Ok(()) 78 | } 79 | } 80 | 81 | /// Moves the terminal cursor to given x, y coordinates 82 | /// 83 | /// The `flush` parameter will immediately flush the buffer if it is set to `true` 84 | pub fn move_cursor( 85 | out: &mut impl io::Write, 86 | x: u16, 87 | y: u16, 88 | flush: bool, 89 | ) -> Result<(), MinusError> { 90 | queue!(out, cursor::MoveTo(x, y))?; 91 | if flush { 92 | out.flush()?; 93 | } 94 | Ok(()) 95 | } 96 | 97 | pub fn clear_entire_screen(out: &mut impl io::Write, flush: bool) -> crate::Result { 98 | queue!(out, Clear(terminal::ClearType::All))?; 99 | if flush { 100 | out.flush()?; 101 | } 102 | Ok(()) 103 | } 104 | -------------------------------------------------------------------------------- /src/dynamic_pager.rs: -------------------------------------------------------------------------------- 1 | use crate::error::MinusError; 2 | use crate::minus_core::init; 3 | use crate::Pager; 4 | 5 | /// Starts a asynchronously running pager 6 | /// 7 | /// This means that data and configuration can be fed into the pager while it is running. 8 | /// 9 | /// See [examples](../index.html#examples) on how to use this function. 10 | /// 11 | /// # Panics 12 | /// This function will panic if another instance of minus is already running. 13 | /// 14 | /// # Errors 15 | /// The function will return with an error if it encounters a error during paging. 16 | #[cfg_attr(docsrs, doc(cfg(feature = "dynamic_output")))] 17 | #[allow(clippy::needless_pass_by_value)] 18 | pub fn dynamic_paging(pager: Pager) -> Result<(), MinusError> { 19 | init::init_core(&pager, crate::RunMode::Dynamic) 20 | } 21 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! Provides error types that are used in various places 2 | //! 3 | //! Some types provided are just present there to avoid leaking 4 | //! upstream error types 5 | 6 | use crate::minus_core::commands::Command; 7 | use std::io; 8 | 9 | /// An operation on the terminal failed, for example resizing it. 10 | /// 11 | /// You can get more information about this error by calling 12 | /// [`source`](std::error::Error::source) on it. 13 | #[derive(Debug, thiserror::Error)] 14 | #[error(transparent)] 15 | #[allow(clippy::module_name_repetitions)] 16 | pub struct TermError( 17 | // This member is private to avoid leaking the crossterm error type up the 18 | // dependency chain. 19 | #[from] io::Error, 20 | ); 21 | 22 | /// There was an error while compiling the regex 23 | #[derive(Debug, thiserror::Error)] 24 | #[error(transparent)] 25 | #[allow(clippy::module_name_repetitions)] 26 | #[cfg(feature = "search")] 27 | #[cfg_attr(docsrs, doc(cfg(feature = "search")))] 28 | pub struct RegexError( 29 | // This member is private to avoid leaking the regex error type up the 30 | // dependency chain. 31 | #[from] regex::Error, 32 | ); 33 | 34 | /// Errors that can occur during setup. 35 | #[derive(Debug, thiserror::Error)] 36 | #[allow(clippy::module_name_repetitions)] 37 | pub enum SetupError { 38 | #[error("The standard output is not a valid terminal")] 39 | InvalidTerminal, 40 | 41 | #[error("Failed to switch to alternate screen")] 42 | AlternateScreen(TermError), 43 | 44 | #[error("Failed to enable raw mode")] 45 | RawMode(TermError), 46 | 47 | #[error("Failed to hide the cursor")] 48 | HideCursor(TermError), 49 | 50 | #[error("Failed to enable mouse capture")] 51 | EnableMouseCapture(TermError), 52 | 53 | #[error("Couldn't determine the terminal size")] 54 | TerminalSize(TermError), 55 | } 56 | 57 | /// Errors that can occur during clean up. 58 | #[derive(Debug, thiserror::Error)] 59 | #[allow(clippy::module_name_repetitions)] 60 | pub enum CleanupError { 61 | #[error("Failed to disable mouse capture")] 62 | DisableMouseCapture(TermError), 63 | 64 | #[error("Failed to show the cursor")] 65 | ShowCursor(TermError), 66 | 67 | #[error("Failed to disable raw mode")] 68 | DisableRawMode(TermError), 69 | 70 | #[error("Failed to switch back to main screen")] 71 | LeaveAlternateScreen(TermError), 72 | } 73 | 74 | /// Errors that can happen during runtime. 75 | #[derive(Debug, thiserror::Error)] 76 | #[allow(clippy::module_name_repetitions)] 77 | pub enum MinusError { 78 | #[error("Failed to initialize the terminal")] 79 | Setup(#[from] SetupError), 80 | 81 | #[error("Failed to clean up the terminal")] 82 | Cleanup(#[from] CleanupError), 83 | 84 | #[error("Failed to draw the new data")] 85 | Draw(#[from] std::io::Error), 86 | 87 | #[error("Failed to handle terminal event")] 88 | HandleEvent(TermError), 89 | 90 | #[error("Failed to do an operation on the cursor")] 91 | Cursor(#[from] TermError), 92 | 93 | #[error("Failed to send formatted data to the pager")] 94 | FmtWriteError(#[from] std::fmt::Error), 95 | 96 | #[error("Failed to send data to the receiver")] 97 | Communication(#[from] crossbeam_channel::SendError), 98 | 99 | #[error("Failed to convert between some primitives")] 100 | Conversion, 101 | 102 | #[error(transparent)] 103 | #[cfg(feature = "search")] 104 | #[cfg_attr(docsrs, doc(cfg(feature = "search")))] 105 | SearchExpError(#[from] RegexError), 106 | 107 | #[cfg(feature = "tokio")] 108 | #[error(transparent)] 109 | #[cfg_attr(docsrs, doc(cfg(feature = "tokio")))] 110 | JoinError(#[from] tokio::task::JoinError), 111 | } 112 | 113 | // Just for convenience helper which is useful in many places 114 | #[cfg(feature = "search")] 115 | impl From for MinusError { 116 | fn from(e: regex::Error) -> Self { 117 | Self::SearchExpError(RegexError::from(e)) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/input/definitions/keydefs.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use super::{Token, MODIFIERS}; 4 | use std::collections::HashMap; 5 | 6 | use crossterm::event::{KeyCode, KeyEvent, KeyEventState, KeyModifiers}; 7 | use once_cell::sync::Lazy; 8 | 9 | static SPECIAL_KEYS: Lazy> = Lazy::new(|| { 10 | let mut map = HashMap::new(); 11 | 12 | map.insert("enter", KeyCode::Enter); 13 | map.insert("tab", KeyCode::Tab); 14 | map.insert("backtab", KeyCode::BackTab); 15 | map.insert("backspace", KeyCode::Backspace); 16 | map.insert("up", KeyCode::Up); 17 | map.insert("down", KeyCode::Down); 18 | map.insert("right", KeyCode::Right); 19 | map.insert("left", KeyCode::Left); 20 | map.insert("pageup", KeyCode::PageUp); 21 | map.insert("pagedown", KeyCode::PageDown); 22 | map.insert("home", KeyCode::Home); 23 | map.insert("end", KeyCode::End); 24 | map.insert("insert", KeyCode::Insert); 25 | map.insert("delete", KeyCode::Delete); 26 | map.insert("esc", KeyCode::Esc); 27 | map.insert("f1", KeyCode::F(1)); 28 | map.insert("f2", KeyCode::F(2)); 29 | map.insert("f3", KeyCode::F(3)); 30 | map.insert("f4", KeyCode::F(4)); 31 | map.insert("f5", KeyCode::F(5)); 32 | map.insert("f6", KeyCode::F(6)); 33 | map.insert("f7", KeyCode::F(7)); 34 | map.insert("f8", KeyCode::F(8)); 35 | map.insert("f9", KeyCode::F(9)); 36 | map.insert("f10", KeyCode::F(10)); 37 | map.insert("f11", KeyCode::F(11)); 38 | map.insert("f12", KeyCode::F(12)); 39 | map.insert("dash", KeyCode::Char('-')); 40 | map.insert("space", KeyCode::Char(' ')); 41 | 42 | map 43 | }); 44 | 45 | struct KeySeq { 46 | code: Option, 47 | modifiers: KeyModifiers, 48 | } 49 | 50 | impl Default for KeySeq { 51 | fn default() -> Self { 52 | Self { 53 | code: None, 54 | modifiers: KeyModifiers::NONE, 55 | } 56 | } 57 | } 58 | 59 | pub fn parse_key_event(text: &str) -> KeyEvent { 60 | let token_list = super::parse_tokens(text); 61 | 62 | KeySeq::gen_keyevent_from_tokenlist(&token_list, text) 63 | } 64 | 65 | impl KeySeq { 66 | fn gen_keyevent_from_tokenlist(token_list: &[Token], text: &str) -> KeyEvent { 67 | let mut ks = Self::default(); 68 | 69 | let mut token_iter = token_list.iter().peekable(); 70 | 71 | while let Some(token) = token_iter.peek() { 72 | match token { 73 | Token::Separator => { 74 | token_iter.next(); 75 | assert!( 76 | !(token_iter.peek() == Some(&&Token::Separator)), 77 | "'{}': Multiple separators found consecutively", 78 | text 79 | ); 80 | } 81 | Token::SingleChar(c) => { 82 | token_iter.next(); 83 | if let Some(m) = MODIFIERS.get(c) { 84 | if token_iter.next() == Some(&Token::Separator) { 85 | assert!( 86 | !ks.modifiers.contains(*m), 87 | "'{}': Multiple instances of same modifier given", 88 | text 89 | ); 90 | ks.modifiers.insert(*m); 91 | } else if ks.code.is_none() { 92 | ks.code = Some(KeyCode::Char(*c)); 93 | } else { 94 | panic!("'{}' Invalid key input sequence given", text); 95 | } 96 | } else if ks.code.is_none() { 97 | ks.code = Some(KeyCode::Char(*c)); 98 | } else { 99 | panic!("'{}': Invalid key input sequence given", text); 100 | } 101 | } 102 | Token::MultipleChar(c) => { 103 | let c = c.to_ascii_lowercase().to_string(); 104 | SPECIAL_KEYS.get(c.as_str()).map_or_else( 105 | || panic!("'{}': Invalid key input sequence given", text), 106 | |key| { 107 | if ks.code.is_none() { 108 | ks.code = Some(*key); 109 | } else { 110 | panic!("'{}': Invalid key input sequence given", text); 111 | } 112 | }, 113 | ); 114 | token_iter.next(); 115 | } 116 | } 117 | } 118 | KeyEvent { 119 | code: ks.code.unwrap_or(KeyCode::Null), 120 | modifiers: ks.modifiers, 121 | kind: crossterm::event::KeyEventKind::Press, 122 | state: KeyEventState::NONE, 123 | } 124 | } 125 | } 126 | 127 | #[cfg(test)] 128 | #[test] 129 | #[allow(clippy::too_many_lines)] 130 | fn test_parse_key_event() { 131 | assert_eq!( 132 | parse_key_event("up"), 133 | KeyEvent { 134 | code: KeyCode::Up, 135 | modifiers: KeyModifiers::NONE, 136 | kind: crossterm::event::KeyEventKind::Press, 137 | state: KeyEventState::NONE, 138 | } 139 | ); 140 | assert_eq!( 141 | parse_key_event("k"), 142 | KeyEvent { 143 | code: KeyCode::Char('k'), 144 | modifiers: KeyModifiers::NONE, 145 | kind: crossterm::event::KeyEventKind::Press, 146 | state: KeyEventState::NONE, 147 | } 148 | ); 149 | assert_eq!( 150 | parse_key_event("j"), 151 | KeyEvent { 152 | code: KeyCode::Char('j'), 153 | modifiers: KeyModifiers::NONE, 154 | kind: crossterm::event::KeyEventKind::Press, 155 | state: KeyEventState::NONE, 156 | } 157 | ); 158 | assert_eq!( 159 | parse_key_event("down"), 160 | KeyEvent { 161 | code: KeyCode::Down, 162 | modifiers: KeyModifiers::NONE, 163 | kind: crossterm::event::KeyEventKind::Press, 164 | state: KeyEventState::NONE, 165 | } 166 | ); 167 | assert_eq!( 168 | parse_key_event("down"), 169 | KeyEvent { 170 | code: KeyCode::Down, 171 | modifiers: KeyModifiers::NONE, 172 | kind: crossterm::event::KeyEventKind::Press, 173 | state: KeyEventState::NONE, 174 | } 175 | ); 176 | assert_eq!( 177 | parse_key_event("enter"), 178 | KeyEvent { 179 | code: KeyCode::Enter, 180 | modifiers: KeyModifiers::NONE, 181 | kind: crossterm::event::KeyEventKind::Press, 182 | state: KeyEventState::NONE, 183 | } 184 | ); 185 | assert_eq!( 186 | parse_key_event("c-u"), 187 | KeyEvent { 188 | code: KeyCode::Char('u'), 189 | modifiers: KeyModifiers::CONTROL, 190 | kind: crossterm::event::KeyEventKind::Press, 191 | state: KeyEventState::NONE, 192 | } 193 | ); 194 | assert_eq!( 195 | parse_key_event("c-d"), 196 | KeyEvent { 197 | code: KeyCode::Char('d'), 198 | modifiers: KeyModifiers::CONTROL, 199 | kind: crossterm::event::KeyEventKind::Press, 200 | state: KeyEventState::NONE, 201 | } 202 | ); 203 | assert_eq!( 204 | parse_key_event("g"), 205 | KeyEvent { 206 | code: KeyCode::Char('g'), 207 | modifiers: KeyModifiers::NONE, 208 | kind: crossterm::event::KeyEventKind::Press, 209 | state: KeyEventState::NONE, 210 | } 211 | ); 212 | assert_eq!( 213 | parse_key_event("s-g"), 214 | KeyEvent { 215 | code: KeyCode::Char('g'), 216 | modifiers: KeyModifiers::SHIFT, 217 | kind: crossterm::event::KeyEventKind::Press, 218 | state: KeyEventState::NONE, 219 | } 220 | ); 221 | assert_eq!( 222 | parse_key_event("G"), 223 | KeyEvent { 224 | code: KeyCode::Char('G'), 225 | modifiers: KeyModifiers::NONE, 226 | kind: crossterm::event::KeyEventKind::Press, 227 | state: KeyEventState::NONE, 228 | } 229 | ); 230 | assert_eq!( 231 | parse_key_event("pageup"), 232 | KeyEvent { 233 | code: KeyCode::PageUp, 234 | modifiers: KeyModifiers::NONE, 235 | kind: crossterm::event::KeyEventKind::Press, 236 | state: KeyEventState::NONE, 237 | } 238 | ); 239 | assert_eq!( 240 | parse_key_event("pagedown"), 241 | KeyEvent { 242 | code: KeyCode::PageDown, 243 | modifiers: KeyModifiers::NONE, 244 | kind: crossterm::event::KeyEventKind::Press, 245 | state: KeyEventState::NONE, 246 | } 247 | ); 248 | assert_eq!( 249 | parse_key_event("c-l"), 250 | KeyEvent { 251 | code: KeyCode::Char('l'), 252 | modifiers: KeyModifiers::CONTROL, 253 | kind: crossterm::event::KeyEventKind::Press, 254 | state: KeyEventState::NONE, 255 | } 256 | ); 257 | assert_eq!( 258 | parse_key_event("q"), 259 | KeyEvent { 260 | code: KeyCode::Char('q'), 261 | modifiers: KeyModifiers::NONE, 262 | kind: crossterm::event::KeyEventKind::Press, 263 | state: KeyEventState::NONE, 264 | } 265 | ); 266 | assert_eq!( 267 | parse_key_event("c-c"), 268 | KeyEvent { 269 | code: KeyCode::Char('c'), 270 | modifiers: KeyModifiers::CONTROL, 271 | kind: crossterm::event::KeyEventKind::Press, 272 | state: KeyEventState::NONE, 273 | } 274 | ); 275 | assert_eq!( 276 | parse_key_event("/"), 277 | KeyEvent { 278 | code: KeyCode::Char('/'), 279 | modifiers: KeyModifiers::NONE, 280 | kind: crossterm::event::KeyEventKind::Press, 281 | state: KeyEventState::NONE, 282 | } 283 | ); 284 | assert_eq!( 285 | parse_key_event("?"), 286 | KeyEvent { 287 | code: KeyCode::Char('?'), 288 | modifiers: KeyModifiers::NONE, 289 | kind: crossterm::event::KeyEventKind::Press, 290 | state: KeyEventState::NONE, 291 | } 292 | ); 293 | assert_eq!( 294 | parse_key_event("n"), 295 | KeyEvent { 296 | code: KeyCode::Char('n'), 297 | modifiers: KeyModifiers::NONE, 298 | kind: crossterm::event::KeyEventKind::Press, 299 | state: KeyEventState::NONE, 300 | } 301 | ); 302 | assert_eq!( 303 | parse_key_event("p"), 304 | KeyEvent { 305 | code: KeyCode::Char('p'), 306 | modifiers: KeyModifiers::NONE, 307 | kind: crossterm::event::KeyEventKind::Press, 308 | state: KeyEventState::NONE, 309 | } 310 | ); 311 | assert_eq!( 312 | parse_key_event("c-s-h"), 313 | KeyEvent { 314 | code: KeyCode::Char('h'), 315 | modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT, 316 | kind: crossterm::event::KeyEventKind::Press, 317 | state: KeyEventState::NONE, 318 | } 319 | ); 320 | } 321 | -------------------------------------------------------------------------------- /src/input/definitions/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::uninlined_format_args)] 2 | 3 | pub mod keydefs; 4 | pub mod mousedefs; 5 | 6 | use crossterm::event::KeyModifiers; 7 | use once_cell::sync::Lazy; 8 | use std::collections::HashMap; 9 | 10 | fn parse_tokens(mut text: &str) -> Vec { 11 | assert!( 12 | text.chars().all(|c| c.is_ascii()), 13 | "'{}': Non ascii sequence found in input sequence", 14 | text 15 | ); 16 | text = text.trim(); 17 | assert!( 18 | text.chars().any(|c| !c.is_whitespace()), 19 | "'{}': Whitespace character found in input sequence", 20 | text 21 | ); 22 | 23 | let mut token_list = Vec::with_capacity(text.len()); 24 | 25 | let mut chars_peek = text.chars().peekable(); 26 | 27 | let mut s = String::with_capacity(5); 28 | 29 | let flush_s = |s: &mut String, token_list: &mut Vec| { 30 | match s.len() { 31 | 1 => token_list.push(Token::SingleChar(s.chars().next().unwrap())), 32 | 2.. => token_list.push(Token::MultipleChar(s.clone())), 33 | _ => {} 34 | } 35 | s.clear(); 36 | }; 37 | 38 | while let Some(chr) = chars_peek.peek() { 39 | match chr { 40 | '-' => { 41 | flush_s(&mut s, &mut token_list); 42 | token_list.push(Token::Separator); 43 | } 44 | c => { 45 | s.push(*c); 46 | } 47 | } 48 | chars_peek.next(); 49 | } 50 | flush_s(&mut s, &mut token_list); 51 | 52 | token_list 53 | } 54 | 55 | pub static MODIFIERS: Lazy> = Lazy::new(|| { 56 | let mut map = HashMap::new(); 57 | map.insert('m', KeyModifiers::ALT); 58 | map.insert('c', KeyModifiers::CONTROL); 59 | map.insert('s', KeyModifiers::SHIFT); 60 | 61 | map 62 | }); 63 | 64 | #[derive(Debug, PartialEq)] 65 | enum Token { 66 | Separator, // - 67 | SingleChar(char), 68 | MultipleChar(String), 69 | } 70 | -------------------------------------------------------------------------------- /src/input/definitions/mousedefs.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use super::{Token, MODIFIERS}; 4 | use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; 5 | use once_cell::sync::Lazy; 6 | 7 | static MOUSE_ACTIONS: Lazy> = Lazy::new(|| { 8 | let mut map = HashMap::new(); 9 | 10 | map.insert("left:down", MouseEventKind::Down(MouseButton::Left)); 11 | map.insert("right:down", MouseEventKind::Down(MouseButton::Right)); 12 | map.insert("mid:down", MouseEventKind::Down(MouseButton::Middle)); 13 | 14 | map.insert("left:up", MouseEventKind::Up(MouseButton::Left)); 15 | map.insert("right:up", MouseEventKind::Up(MouseButton::Right)); 16 | map.insert("mid:up", MouseEventKind::Up(MouseButton::Middle)); 17 | 18 | map.insert("left:drag", MouseEventKind::Drag(MouseButton::Left)); 19 | map.insert("right:drag", MouseEventKind::Drag(MouseButton::Right)); 20 | map.insert("mid:drag", MouseEventKind::Drag(MouseButton::Middle)); 21 | 22 | map.insert("move", MouseEventKind::Moved); 23 | map.insert("scroll:up", MouseEventKind::ScrollUp); 24 | map.insert("scroll:down", MouseEventKind::ScrollDown); 25 | 26 | map 27 | }); 28 | 29 | pub fn parse_mouse_event(text: &str) -> MouseEvent { 30 | let token_list = super::parse_tokens(text); 31 | gen_mouse_event_from_tokenlist(&token_list, text) 32 | } 33 | 34 | fn gen_mouse_event_from_tokenlist(token_list: &[Token], text: &str) -> MouseEvent { 35 | let mut kind = None; 36 | let mut modifiers = KeyModifiers::NONE; 37 | 38 | let mut token_iter = token_list.iter().peekable(); 39 | 40 | while let Some(token) = token_iter.peek() { 41 | match token { 42 | Token::Separator => { 43 | token_iter.next(); 44 | assert!( 45 | !(token_iter.peek() == Some(&&Token::Separator)), 46 | "'{}': Multiple separators found consecutively", 47 | text 48 | ); 49 | } 50 | Token::SingleChar(c) => { 51 | token_iter.next(); 52 | MODIFIERS.get(c).map_or_else( 53 | || { 54 | panic!("'{}': Invalid keymodifier '{}' given", text, c); 55 | }, 56 | |m| { 57 | if token_iter.next() == Some(&Token::Separator) { 58 | assert!( 59 | !modifiers.contains(*m), 60 | "'{}': Multiple instances of same modifier given", 61 | text 62 | ); 63 | modifiers.insert(*m); 64 | } else { 65 | panic!("'{}' Invalid key input sequence given", text); 66 | } 67 | }, 68 | ); 69 | } 70 | Token::MultipleChar(c) => { 71 | let c = c.to_ascii_lowercase().to_string(); 72 | MOUSE_ACTIONS.get(c.as_str()).map_or_else( 73 | || panic!("'{}': Invalid key input sequence given", text), 74 | |k| { 75 | if kind.is_none() { 76 | kind = Some(*k); 77 | } else { 78 | panic!("'{}': Invalid key input sequence given", text); 79 | } 80 | }, 81 | ); 82 | token_iter.next(); 83 | } 84 | } 85 | } 86 | MouseEvent { 87 | kind: kind.unwrap_or_else(|| panic!("No MouseEventKind found for '{}", text)), 88 | modifiers, 89 | row: 0, 90 | column: 0, 91 | } 92 | } 93 | 94 | #[cfg(test)] 95 | mod tests { 96 | use super::parse_mouse_event; 97 | use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; 98 | 99 | #[test] 100 | fn test_without_modifiers() { 101 | assert_eq!( 102 | parse_mouse_event("left:down"), 103 | MouseEvent { 104 | kind: MouseEventKind::Down(MouseButton::Left), 105 | modifiers: KeyModifiers::NONE, 106 | row: 0, 107 | column: 0, 108 | } 109 | ); 110 | 111 | assert_eq!( 112 | parse_mouse_event("mid:up"), 113 | MouseEvent { 114 | kind: MouseEventKind::Up(MouseButton::Middle), 115 | modifiers: KeyModifiers::NONE, 116 | row: 0, 117 | column: 0, 118 | } 119 | ); 120 | 121 | assert_eq!( 122 | parse_mouse_event("right:down"), 123 | MouseEvent { 124 | kind: MouseEventKind::Down(MouseButton::Right), 125 | modifiers: KeyModifiers::NONE, 126 | row: 0, 127 | column: 0, 128 | } 129 | ); 130 | assert_eq!( 131 | parse_mouse_event("scroll:up"), 132 | MouseEvent { 133 | kind: MouseEventKind::ScrollUp, 134 | modifiers: KeyModifiers::NONE, 135 | row: 0, 136 | column: 0, 137 | } 138 | ); 139 | assert_eq!( 140 | parse_mouse_event("move"), 141 | MouseEvent { 142 | kind: MouseEventKind::Moved, 143 | modifiers: KeyModifiers::NONE, 144 | row: 0, 145 | column: 0, 146 | } 147 | ); 148 | } 149 | 150 | #[test] 151 | fn test_with_modifiers() { 152 | assert_eq!( 153 | parse_mouse_event("m-left:down"), 154 | MouseEvent { 155 | kind: MouseEventKind::Down(MouseButton::Left), 156 | modifiers: KeyModifiers::ALT, 157 | row: 0, 158 | column: 0, 159 | } 160 | ); 161 | 162 | assert_eq!( 163 | parse_mouse_event("m-c-mid:up"), 164 | MouseEvent { 165 | kind: MouseEventKind::Up(MouseButton::Middle), 166 | modifiers: KeyModifiers::ALT | KeyModifiers::CONTROL, 167 | row: 0, 168 | column: 0, 169 | } 170 | ); 171 | assert_eq!( 172 | parse_mouse_event("c-scroll:up"), 173 | MouseEvent { 174 | kind: MouseEventKind::ScrollUp, 175 | modifiers: KeyModifiers::CONTROL, 176 | row: 0, 177 | column: 0, 178 | } 179 | ); 180 | assert_eq!( 181 | parse_mouse_event("m-c-s-move"), 182 | MouseEvent { 183 | kind: MouseEventKind::Moved, 184 | modifiers: KeyModifiers::SHIFT | KeyModifiers::ALT | KeyModifiers::CONTROL, 185 | row: 0, 186 | column: 0, 187 | } 188 | ); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/input/hashed_event_register.rs: -------------------------------------------------------------------------------- 1 | //! Provides the [`HashedEventRegister`] and related items 2 | //! 3 | //! This module holds the [`HashedEventRegister`] which is a [`HashMap`] that stores events and their associated 4 | //! callbacks. When the user does an action on the terminal, the event is scanned and matched against this register. 5 | //! If their is a match related to that event, the associated callback is called 6 | 7 | use super::{InputClassifier, InputEvent}; 8 | use crate::PagerState; 9 | use crossterm::event::{Event, MouseEvent}; 10 | use std::{ 11 | collections::hash_map::RandomState, collections::HashMap, hash::BuildHasher, hash::Hash, 12 | sync::Arc, 13 | }; 14 | 15 | /// A convenient type for the return type of [`HashedEventRegister::get`] 16 | type EventReturnType = Arc InputEvent + Send + Sync>; 17 | 18 | // ////////////////////////////// 19 | // EVENTWRAPPER TYPE 20 | // ////////////////////////////// 21 | 22 | #[derive(Clone, Eq)] 23 | enum EventWrapper { 24 | ExactMatchEvent(Event), 25 | WildEvent, 26 | } 27 | 28 | impl From for EventWrapper { 29 | fn from(e: Event) -> Self { 30 | Self::ExactMatchEvent(e) 31 | } 32 | } 33 | 34 | impl From<&Event> for EventWrapper { 35 | fn from(e: &Event) -> Self { 36 | Self::ExactMatchEvent(e.clone()) 37 | } 38 | } 39 | 40 | impl PartialEq for EventWrapper { 41 | fn eq(&self, other: &Self) -> bool { 42 | match (self, other) { 43 | ( 44 | Self::ExactMatchEvent(Event::Mouse(MouseEvent { 45 | kind, modifiers, .. 46 | })), 47 | Self::ExactMatchEvent(Event::Mouse(MouseEvent { 48 | kind: o_kind, 49 | modifiers: o_modifiers, 50 | .. 51 | })), 52 | ) => kind == o_kind && modifiers == o_modifiers, 53 | ( 54 | Self::ExactMatchEvent(Event::Resize(..)), 55 | Self::ExactMatchEvent(Event::Resize(..)), 56 | ) 57 | | (Self::WildEvent, Self::WildEvent) => true, 58 | (Self::ExactMatchEvent(ev), Self::ExactMatchEvent(o_ev)) => ev == o_ev, 59 | _ => false, 60 | } 61 | } 62 | } 63 | 64 | impl Hash for EventWrapper { 65 | fn hash(&self, state: &mut H) { 66 | let tag = std::mem::discriminant(self); 67 | tag.hash(state); 68 | match self { 69 | Self::ExactMatchEvent(Event::Mouse(MouseEvent { 70 | kind, modifiers, .. 71 | })) => { 72 | kind.hash(state); 73 | modifiers.hash(state); 74 | } 75 | Self::WildEvent | Self::ExactMatchEvent(Event::Resize(..)) => {} 76 | Self::ExactMatchEvent(v) => { 77 | v.hash(state); 78 | } 79 | } 80 | } 81 | } 82 | 83 | // ///////////////////////////////////////////////// 84 | // HASHED EVENT REGISTER TYPE AND ITS APIs 85 | // //////////////////////////////////////////////// 86 | 87 | /// A hash store for events and it's related callback 88 | /// 89 | /// Each item is a key value pair, where the key is a event and it's value is a callback. When a 90 | /// event occurs, it is matched inside and when the related match is found, it's related callback 91 | /// is called. 92 | pub struct HashedEventRegister(HashMap); 93 | 94 | impl HashedEventRegister { 95 | /// Create a new [HashedEventRegister] with the default hasher 96 | #[must_use] 97 | pub fn with_default_hasher() -> Self { 98 | Self::new(RandomState::new()) 99 | } 100 | } 101 | 102 | impl Default for HashedEventRegister { 103 | /// Create a new [HashedEventRegister] with the default hasher and insert the default bindings 104 | fn default() -> Self { 105 | let mut event_register = Self::new(RandomState::new()); 106 | super::generate_default_bindings(&mut event_register); 107 | event_register 108 | } 109 | } 110 | 111 | impl InputClassifier for HashedEventRegister 112 | where 113 | S: BuildHasher, 114 | { 115 | fn classify_input(&self, ev: Event, ps: &crate::PagerState) -> Option { 116 | self.get(&ev).map(|c| c(ev, ps)) 117 | } 118 | } 119 | 120 | // #################### 121 | // GENERAL FUNCTIONS 122 | // #################### 123 | impl HashedEventRegister 124 | where 125 | S: BuildHasher, 126 | { 127 | /// Create a new HashedEventRegister with the Hasher `s` 128 | pub fn new(s: S) -> Self { 129 | Self(HashMap::with_hasher(s)) 130 | } 131 | 132 | /// Adds a callback to handle all events that failed to match 133 | /// 134 | /// Sometimes there are bunch of keys having equal importance that should have the same 135 | /// callback, for instance all the numbers on the keyboard. To handle these types of scenerios 136 | /// this is extremely useful. This callback is called when no event matches the incoming event, 137 | /// then we just match whether the event is a keyboard number and perform the required action. 138 | /// 139 | /// This is also helpful when you need to do some action, like sending a message when the user 140 | /// presses wrong keyboard/mouse buttons. 141 | pub fn insert_wild_event_matcher( 142 | &mut self, 143 | cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, 144 | ) { 145 | self.0.insert(EventWrapper::WildEvent, Arc::new(cb)); 146 | } 147 | 148 | fn get(&self, k: &Event) -> Option<&EventReturnType> { 149 | self.0 150 | .get(&k.into()) 151 | .map_or_else(|| self.0.get(&EventWrapper::WildEvent), |k| Some(k)) 152 | } 153 | 154 | /// Adds a callback for handling resize events 155 | /// 156 | /// # Example 157 | /// These are from the original sources 158 | /// ``` 159 | /// use minus::input::{InputEvent, HashedEventRegister, crossterm_event::Event}; 160 | /// 161 | /// let mut input_register = HashedEventRegister::default(); 162 | /// 163 | /// input_register.add_resize_event(|ev, _| { 164 | /// let (cols, rows) = if let Event::Resize(cols, rows) = ev { 165 | /// (cols, rows) 166 | /// } else { 167 | /// unreachable!(); 168 | /// }; 169 | /// InputEvent::UpdateTermArea(cols as usize, rows as usize) 170 | /// }); 171 | /// ``` 172 | pub fn add_resize_event( 173 | &mut self, 174 | cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, 175 | ) { 176 | let v = Arc::new(cb); 177 | // The 0, 0 are present just to ensure everything compiles and they can be anything. 178 | // These values are never hashed or stored into the HashedEventRegister 179 | self.0 180 | .insert(EventWrapper::ExactMatchEvent(Event::Resize(0, 0)), v); 181 | } 182 | 183 | /// Removes the currently active resize event callback 184 | pub fn remove_resize_event(&mut self) { 185 | self.0 186 | .remove(&EventWrapper::ExactMatchEvent(Event::Resize(0, 0))); 187 | } 188 | } 189 | 190 | // ############################### 191 | // KEYBOARD SPECIFIC FUNCTIONS 192 | // ############################### 193 | impl HashedEventRegister 194 | where 195 | S: BuildHasher, 196 | { 197 | /// Add all elemnts of `desc` as key bindings that minus should respond to with the callback `cb` 198 | /// 199 | /// You should prefer using the [add_key_events_checked](HashedEventRegister::add_key_events_checked) 200 | /// over this one. 201 | /// 202 | /// # Example 203 | /// ``` 204 | /// use minus::input::{InputEvent, HashedEventRegister, crossterm_event}; 205 | /// 206 | /// let mut input_register = HashedEventRegister::default(); 207 | /// 208 | /// input_register.add_key_events(&["down"], |_, ps| { 209 | /// InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(1)) 210 | /// }); 211 | /// ``` 212 | pub fn add_key_events( 213 | &mut self, 214 | desc: &[&str], 215 | cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, 216 | ) { 217 | let v = Arc::new(cb); 218 | for k in desc { 219 | self.0.insert( 220 | Event::Key(super::definitions::keydefs::parse_key_event(k)).into(), 221 | v.clone(), 222 | ); 223 | } 224 | } 225 | 226 | /// Add all elemnts of `desc` as key bindings that minus should respond to with the callback `cb` 227 | /// 228 | /// This will panic if you the keybinding has been previously defined, unless the `remap` 229 | /// is set to true. This helps preventing accidental overrides of your keybindings. 230 | /// 231 | /// Prefer using this over [add_key_events](HashedEventRegister::add_key_events). 232 | /// 233 | /// # Example 234 | /// ```should_panic 235 | /// use minus::input::{InputEvent, HashedEventRegister, crossterm_event}; 236 | /// 237 | /// let mut input_register = HashedEventRegister::default(); 238 | /// 239 | /// input_register.add_key_events_checked(&["down"], |_, ps| { 240 | /// InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(1)) 241 | /// }, false); 242 | /// ``` 243 | pub fn add_key_events_checked( 244 | &mut self, 245 | desc: &[&str], 246 | cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, 247 | remap: bool, 248 | ) { 249 | let v = Arc::new(cb); 250 | for k in desc { 251 | let def: EventWrapper = 252 | Event::Key(super::definitions::keydefs::parse_key_event(k)).into(); 253 | assert!(self.0.contains_key(&def) && remap, ""); 254 | self.0.insert(def, v.clone()); 255 | } 256 | } 257 | 258 | /// Removes the callback associated with the all the elements of `desc`. 259 | /// 260 | /// ``` 261 | /// use minus::input::{InputEvent, HashedEventRegister, crossterm_event}; 262 | /// 263 | /// let mut input_register = HashedEventRegister::default(); 264 | /// 265 | /// input_register.remove_key_events(&["down"]) 266 | /// ``` 267 | pub fn remove_key_events(&mut self, desc: &[&str]) { 268 | for k in desc { 269 | self.0 270 | .remove(&Event::Key(super::definitions::keydefs::parse_key_event(k)).into()); 271 | } 272 | } 273 | } 274 | 275 | // ############################### 276 | // MOUSE SPECIFIC FUNCTIONS 277 | // ############################### 278 | impl HashedEventRegister 279 | where 280 | S: BuildHasher, 281 | { 282 | /// Add all elemnts of `desc` as mouse bindings that minus should respond to with the callback `cb` 283 | /// 284 | /// You should prefer using the [add_mouse_events_checked](HashedEventRegister::add_mouse_events_checked) 285 | /// over this one. 286 | /// 287 | /// # Example 288 | /// ``` 289 | /// use minus::input::{InputEvent, HashedEventRegister}; 290 | /// 291 | /// let mut input_register = HashedEventRegister::default(); 292 | /// 293 | /// input_register.add_mouse_events(&["scroll:down"], |_, ps| { 294 | /// InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(5)) 295 | /// }); 296 | /// ``` 297 | pub fn add_mouse_events( 298 | &mut self, 299 | desc: &[&str], 300 | cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, 301 | ) { 302 | let v = Arc::new(cb); 303 | for k in desc { 304 | self.0.insert( 305 | Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into(), 306 | v.clone(), 307 | ); 308 | } 309 | } 310 | 311 | /// Add all elemnts of `desc` as mouse bindings that minus should respond to with the callback `cb` 312 | /// 313 | /// This will panic if you the keybinding has been previously defined, unless the `remap` 314 | /// is set to true. This helps preventing accidental overrides of your keybindings. 315 | /// 316 | /// Prefer using this over [add_mouse_events](HashedEventRegister::add_mouse_events). 317 | /// # Example 318 | /// ```should_panic 319 | /// use minus::input::{InputEvent, HashedEventRegister}; 320 | /// 321 | /// let mut input_register = HashedEventRegister::default(); 322 | /// 323 | /// input_register.add_mouse_events_checked(&["scroll:down"], |_, ps| { 324 | /// InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(5)) 325 | /// }, false); 326 | /// ``` 327 | pub fn add_mouse_events_checked( 328 | &mut self, 329 | desc: &[&str], 330 | cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, 331 | remap: bool, 332 | ) { 333 | let v = Arc::new(cb); 334 | for k in desc { 335 | let def: EventWrapper = 336 | Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into(); 337 | assert!(self.0.contains_key(&def) && remap, ""); 338 | self.0.insert(def, v.clone()); 339 | } 340 | } 341 | 342 | /// Removes the callback associated with the all the elements of `desc`. 343 | /// 344 | /// ``` 345 | /// use minus::input::{InputEvent, HashedEventRegister, crossterm_event}; 346 | /// 347 | /// let mut input_register = HashedEventRegister::default(); 348 | /// 349 | /// input_register.remove_mouse_events(&["scroll:down"]) 350 | /// ``` 351 | pub fn remove_mouse_events(&mut self, mouse: &[&str]) { 352 | for k in mouse { 353 | self.0 354 | .remove(&Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into()); 355 | } 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /src/input/tests.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "search")] 2 | use crate::SearchMode; 3 | use crate::{input::InputEvent, LineNumbers, PagerState}; 4 | use crossterm::event::{ 5 | Event, KeyCode, KeyEvent, KeyEventState, KeyModifiers, MouseEvent, MouseEventKind, 6 | }; 7 | 8 | // Just a transparent function to fix incompatibility issues between 9 | // versions 10 | // TODO: Remove this later in favour of how handle_event should actually be called 11 | fn handle_input(ev: Event, p: &PagerState) -> Option { 12 | p.input_classifier.classify_input(ev, p) 13 | } 14 | 15 | // Keyboard navigation 16 | #[test] 17 | #[allow(clippy::too_many_lines)] 18 | fn test_kb_nav() { 19 | let mut pager = PagerState::new().unwrap(); 20 | pager.upper_mark = 12; 21 | pager.line_numbers = LineNumbers::Enabled; 22 | pager.rows = 5; 23 | 24 | { 25 | let ev = Event::Key(KeyEvent { 26 | code: KeyCode::Down, 27 | modifiers: KeyModifiers::NONE, 28 | kind: crossterm::event::KeyEventKind::Press, 29 | state: KeyEventState::NONE, 30 | }); 31 | 32 | assert_eq!( 33 | Some(InputEvent::UpdateUpperMark(pager.upper_mark + 1)), 34 | handle_input(ev, &pager) 35 | ); 36 | } 37 | 38 | { 39 | let ev = Event::Key(KeyEvent { 40 | code: KeyCode::Up, 41 | modifiers: KeyModifiers::NONE, 42 | kind: crossterm::event::KeyEventKind::Press, 43 | state: KeyEventState::NONE, 44 | }); 45 | assert_eq!( 46 | Some(InputEvent::UpdateUpperMark(pager.upper_mark - 1)), 47 | handle_input(ev, &pager) 48 | ); 49 | } 50 | 51 | { 52 | let ev = Event::Key(KeyEvent { 53 | code: KeyCode::Char('g'), 54 | modifiers: KeyModifiers::NONE, 55 | kind: crossterm::event::KeyEventKind::Press, 56 | state: KeyEventState::NONE, 57 | }); 58 | assert_eq!( 59 | Some(InputEvent::UpdateUpperMark(0)), 60 | handle_input(ev, &pager) 61 | ); 62 | } 63 | 64 | { 65 | let ev = Event::Key(KeyEvent { 66 | code: KeyCode::PageUp, 67 | modifiers: KeyModifiers::NONE, 68 | kind: crossterm::event::KeyEventKind::Press, 69 | state: KeyEventState::NONE, 70 | }); 71 | assert_eq!( 72 | // rows is 5, therefore upper_mark = upper_mark - rows -1 73 | Some(InputEvent::UpdateUpperMark(8)), 74 | handle_input(ev, &pager) 75 | ); 76 | } 77 | 78 | { 79 | let ev = Event::Key(KeyEvent { 80 | code: KeyCode::Char('g'), 81 | modifiers: KeyModifiers::SHIFT, 82 | kind: crossterm::event::KeyEventKind::Press, 83 | state: KeyEventState::NONE, 84 | }); 85 | assert_eq!( 86 | Some(InputEvent::UpdateUpperMark(usize::MAX - 1)), 87 | handle_input(ev, &pager) 88 | ); 89 | } 90 | 91 | { 92 | let ev = Event::Key(KeyEvent { 93 | code: KeyCode::Char('G'), 94 | modifiers: KeyModifiers::NONE, 95 | kind: crossterm::event::KeyEventKind::Press, 96 | state: KeyEventState::NONE, 97 | }); 98 | assert_eq!( 99 | Some(InputEvent::UpdateUpperMark(usize::MAX - 1)), 100 | handle_input(ev, &pager) 101 | ); 102 | } 103 | 104 | { 105 | let ev = Event::Key(KeyEvent { 106 | code: KeyCode::Char('G'), 107 | modifiers: KeyModifiers::SHIFT, 108 | kind: crossterm::event::KeyEventKind::Press, 109 | state: KeyEventState::NONE, 110 | }); 111 | assert_eq!( 112 | Some(InputEvent::UpdateUpperMark(usize::MAX - 1)), 113 | handle_input(ev, &pager) 114 | ); 115 | } 116 | 117 | { 118 | let ev = Event::Key(KeyEvent { 119 | code: KeyCode::PageDown, 120 | modifiers: KeyModifiers::NONE, 121 | kind: crossterm::event::KeyEventKind::Press, 122 | state: KeyEventState::NONE, 123 | }); 124 | assert_eq!( 125 | // rows is 5, therefore upper_mark = upper_mark - rows -1 126 | Some(InputEvent::UpdateUpperMark(16)), 127 | handle_input(ev, &pager) 128 | ); 129 | } 130 | 131 | { 132 | // Half page down 133 | let ev = Event::Key(KeyEvent { 134 | code: KeyCode::Char('d'), 135 | modifiers: KeyModifiers::CONTROL, 136 | kind: crossterm::event::KeyEventKind::Press, 137 | state: KeyEventState::NONE, 138 | }); 139 | // Rows is 5 and upper_mark is at 12 so result should be 14 140 | assert_eq!( 141 | Some(InputEvent::UpdateUpperMark(14)), 142 | handle_input(ev, &pager) 143 | ); 144 | } 145 | 146 | { 147 | // Half page up 148 | let ev = Event::Key(KeyEvent { 149 | code: KeyCode::Char('u'), 150 | modifiers: KeyModifiers::CONTROL, 151 | kind: crossterm::event::KeyEventKind::Press, 152 | state: KeyEventState::NONE, 153 | }); 154 | // Rows is 5 and upper_mark is at 12 so result should be 10 155 | assert_eq!( 156 | Some(InputEvent::UpdateUpperMark(10)), 157 | handle_input(ev, &pager) 158 | ); 159 | } 160 | { 161 | // Space for page down 162 | let ev = Event::Key(KeyEvent { 163 | code: KeyCode::Char(' '), 164 | modifiers: KeyModifiers::NONE, 165 | kind: crossterm::event::KeyEventKind::Press, 166 | state: KeyEventState::NONE, 167 | }); 168 | // rows is 5, therefore upper_mark = upper_mark - rows -1 169 | assert_eq!( 170 | Some(InputEvent::UpdateUpperMark(16)), 171 | handle_input(ev, &pager) 172 | ); 173 | } 174 | { 175 | // Enter key for one line down when no message on prompt 176 | let ev = Event::Key(KeyEvent { 177 | code: KeyCode::Enter, 178 | modifiers: KeyModifiers::NONE, 179 | kind: crossterm::event::KeyEventKind::Press, 180 | state: KeyEventState::NONE, 181 | }); 182 | // therefore upper_mark += 1 183 | assert_eq!( 184 | Some(InputEvent::UpdateUpperMark(13)), 185 | handle_input(ev, &pager) 186 | ); 187 | } 188 | } 189 | 190 | #[test] 191 | fn test_restore_prompt() { 192 | let mut pager = PagerState::new().unwrap(); 193 | pager.message = Some("Prompt message".to_string()); 194 | { 195 | // Enter key for one line down when no message on prompt 196 | let ev = Event::Key(KeyEvent { 197 | code: KeyCode::Enter, 198 | modifiers: KeyModifiers::NONE, 199 | kind: crossterm::event::KeyEventKind::Press, 200 | state: KeyEventState::NONE, 201 | }); 202 | // therefore upper_mark += 1 203 | assert_eq!( 204 | Some(InputEvent::RestorePrompt), 205 | pager.input_classifier.classify_input(ev, &pager) 206 | ); 207 | } 208 | } 209 | 210 | #[test] 211 | fn test_mouse_nav() { 212 | let mut pager = PagerState::new().unwrap(); 213 | pager.upper_mark = 12; 214 | pager.line_numbers = LineNumbers::Enabled; 215 | pager.rows = 5; 216 | { 217 | let ev = Event::Mouse(MouseEvent { 218 | kind: MouseEventKind::ScrollDown, 219 | row: 0, 220 | column: 0, 221 | modifiers: KeyModifiers::NONE, 222 | }); 223 | 224 | assert_eq!( 225 | Some(InputEvent::UpdateUpperMark(pager.upper_mark + 5)), 226 | handle_input(ev, &pager) 227 | ); 228 | } 229 | 230 | { 231 | let ev = Event::Mouse(MouseEvent { 232 | kind: MouseEventKind::ScrollUp, 233 | row: 0, 234 | column: 0, 235 | modifiers: KeyModifiers::NONE, 236 | }); 237 | assert_eq!( 238 | Some(InputEvent::UpdateUpperMark(pager.upper_mark - 5)), 239 | handle_input(ev, &pager) 240 | ); 241 | } 242 | } 243 | 244 | #[test] 245 | fn test_saturation() { 246 | let mut pager = PagerState::new().unwrap(); 247 | pager.upper_mark = 12; 248 | pager.line_numbers = LineNumbers::Enabled; 249 | pager.rows = 5; 250 | 251 | { 252 | let ev = Event::Key(KeyEvent { 253 | code: KeyCode::Down, 254 | modifiers: KeyModifiers::NONE, 255 | kind: crossterm::event::KeyEventKind::Press, 256 | state: KeyEventState::NONE, 257 | }); 258 | // PagerState for local use 259 | let mut pager = PagerState::new().unwrap(); 260 | pager.upper_mark = usize::MAX; 261 | pager.line_numbers = LineNumbers::Enabled; 262 | pager.rows = 5; 263 | assert_eq!( 264 | Some(InputEvent::UpdateUpperMark(usize::MAX)), 265 | handle_input(ev, &pager) 266 | ); 267 | } 268 | 269 | { 270 | let ev = Event::Key(KeyEvent { 271 | code: KeyCode::Up, 272 | modifiers: KeyModifiers::NONE, 273 | kind: crossterm::event::KeyEventKind::Press, 274 | state: KeyEventState::NONE, 275 | }); 276 | // PagerState for local use 277 | let mut pager = PagerState::new().unwrap(); 278 | pager.upper_mark = usize::MIN; 279 | pager.line_numbers = LineNumbers::Enabled; 280 | pager.rows = 5; 281 | assert_eq!( 282 | Some(InputEvent::UpdateUpperMark(usize::MIN)), 283 | handle_input(ev, &pager) 284 | ); 285 | } 286 | } 287 | 288 | #[test] 289 | fn test_misc_events() { 290 | let mut pager = PagerState::new().unwrap(); 291 | pager.upper_mark = 12; 292 | pager.line_numbers = LineNumbers::Enabled; 293 | pager.rows = 5; 294 | 295 | { 296 | let ev = Event::Resize(42, 35); 297 | assert_eq!( 298 | Some(InputEvent::UpdateTermArea(42, 35)), 299 | handle_input(ev, &pager) 300 | ); 301 | } 302 | 303 | { 304 | let ev = Event::Key(KeyEvent { 305 | code: KeyCode::Char('l'), 306 | modifiers: KeyModifiers::CONTROL, 307 | kind: crossterm::event::KeyEventKind::Press, 308 | state: KeyEventState::NONE, 309 | }); 310 | assert_eq!( 311 | Some(InputEvent::UpdateLineNumber(!pager.line_numbers)), 312 | handle_input(ev, &pager) 313 | ); 314 | } 315 | 316 | { 317 | let ev = Event::Key(KeyEvent { 318 | code: KeyCode::Char('q'), 319 | modifiers: KeyModifiers::NONE, 320 | kind: crossterm::event::KeyEventKind::Press, 321 | state: KeyEventState::NONE, 322 | }); 323 | assert_eq!(Some(InputEvent::Exit), handle_input(ev, &pager)); 324 | } 325 | 326 | { 327 | let ev = Event::Key(KeyEvent { 328 | code: KeyCode::Char('c'), 329 | modifiers: KeyModifiers::CONTROL, 330 | kind: crossterm::event::KeyEventKind::Press, 331 | state: KeyEventState::NONE, 332 | }); 333 | assert_eq!(Some(InputEvent::Exit), handle_input(ev, &pager)); 334 | } 335 | 336 | { 337 | let ev = Event::Key(KeyEvent { 338 | code: KeyCode::Char('a'), 339 | modifiers: KeyModifiers::NONE, 340 | kind: crossterm::event::KeyEventKind::Press, 341 | state: KeyEventState::NONE, 342 | }); 343 | assert_eq!(Some(InputEvent::Ignore), handle_input(ev, &pager)); 344 | } 345 | 346 | { 347 | let ev = Event::Key(KeyEvent { 348 | code: KeyCode::Char('5'), 349 | modifiers: KeyModifiers::NONE, 350 | kind: crossterm::event::KeyEventKind::Press, 351 | state: KeyEventState::NONE, 352 | }); 353 | assert_eq!(Some(InputEvent::Number('5')), handle_input(ev, &pager)); 354 | } 355 | } 356 | 357 | #[test] 358 | #[allow(clippy::too_many_lines)] 359 | #[cfg(feature = "search")] 360 | fn test_search_bindings() { 361 | let mut pager = PagerState::new().unwrap(); 362 | pager.upper_mark = 12; 363 | pager.line_numbers = LineNumbers::Enabled; 364 | pager.rows = 5; 365 | 366 | { 367 | let ev = Event::Key(KeyEvent { 368 | code: KeyCode::Char('/'), 369 | modifiers: KeyModifiers::NONE, 370 | kind: crossterm::event::KeyEventKind::Press, 371 | state: KeyEventState::NONE, 372 | }); 373 | assert_eq!( 374 | Some(InputEvent::Search(SearchMode::Forward)), 375 | handle_input(ev, &pager) 376 | ); 377 | } 378 | 379 | { 380 | let ev = Event::Key(KeyEvent { 381 | code: KeyCode::Char('?'), 382 | modifiers: KeyModifiers::NONE, 383 | kind: crossterm::event::KeyEventKind::Press, 384 | state: KeyEventState::NONE, 385 | }); 386 | assert_eq!( 387 | Some(InputEvent::Search(SearchMode::Reverse)), 388 | handle_input(ev, &pager) 389 | ); 390 | } 391 | { 392 | pager.search_state.search_mode = SearchMode::Forward; 393 | // NextMatch and PrevMatch forward search 394 | let next_event = Event::Key(KeyEvent { 395 | code: KeyCode::Char('n'), 396 | modifiers: KeyModifiers::NONE, 397 | kind: crossterm::event::KeyEventKind::Press, 398 | state: KeyEventState::NONE, 399 | }); 400 | let prev_event = Event::Key(KeyEvent { 401 | code: KeyCode::Char('p'), 402 | modifiers: KeyModifiers::NONE, 403 | kind: crossterm::event::KeyEventKind::Press, 404 | state: KeyEventState::NONE, 405 | }); 406 | 407 | assert_eq!( 408 | pager.input_classifier.classify_input(next_event, &pager), 409 | Some(InputEvent::MoveToNextMatch(1)) 410 | ); 411 | assert_eq!( 412 | pager.input_classifier.classify_input(prev_event, &pager), 413 | Some(InputEvent::MoveToPrevMatch(1)) 414 | ); 415 | } 416 | 417 | { 418 | pager.search_state.search_mode = SearchMode::Reverse; 419 | // NextMatch and PrevMatch reverse search 420 | let next_event = Event::Key(KeyEvent { 421 | code: KeyCode::Char('n'), 422 | modifiers: KeyModifiers::NONE, 423 | kind: crossterm::event::KeyEventKind::Press, 424 | state: KeyEventState::NONE, 425 | }); 426 | let prev_event = Event::Key(KeyEvent { 427 | code: KeyCode::Char('p'), 428 | modifiers: KeyModifiers::NONE, 429 | kind: crossterm::event::KeyEventKind::Press, 430 | state: KeyEventState::NONE, 431 | }); 432 | 433 | assert_eq!( 434 | pager.input_classifier.classify_input(next_event, &pager), 435 | Some(InputEvent::MoveToPrevMatch(1)) 436 | ); 437 | assert_eq!( 438 | pager.input_classifier.classify_input(prev_event, &pager), 439 | Some(InputEvent::MoveToNextMatch(1)) 440 | ); 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_cfg))] 2 | // When no feature is active this crate is unusable but contains lots of 3 | // unused imports and dead code. To avoid useless warnings about this they 4 | // are allowed when no feature is active. 5 | #![cfg_attr( 6 | not(any(feature = "dynamic_output", feature = "static_output")), 7 | allow(unused_imports), 8 | allow(dead_code) 9 | )] 10 | #![deny(clippy::all)] 11 | #![warn(clippy::pedantic)] 12 | #![warn(clippy::nursery)] 13 | #![allow(clippy::doc_markdown)] 14 | #![cfg_attr(doctest, doc = include_str!("../README.md"))] 15 | 16 | //! `minus`: A library for asynchronous terminal [paging], written in Rust. 17 | //! 18 | //! If you want to learn about its motivation and features, please take a look into it's [README]. 19 | //! 20 | //! # Overview 21 | //! When getting started with minus, the two most important concepts to get familier with are: 22 | //! * The [Pager] type: which acts as a bridge between your application and minus. It is used 23 | //! to pass data and configure minus before and after starting the pager. 24 | //! * Initialization functions: This includes the [dynamic_paging] and [page_all] functions which 25 | //! take a [Pager] as argument. They are responsible for generating the initial state and starting 26 | //! the pager. 27 | //! 28 | //! See the docs for the respective items to learn more on its usage. 29 | //! 30 | //! # Examples 31 | //! 32 | //! ## Threads 33 | //! 34 | //! ```rust,no_run 35 | //! use minus::{dynamic_paging, MinusError, Pager}; 36 | //! use std::{ 37 | //! fmt::Write, 38 | //! thread::{spawn, sleep}, 39 | //! time::Duration 40 | //! }; 41 | //! 42 | //! fn main() -> Result<(), MinusError> { 43 | //! // Initialize the pager 44 | //! let mut pager = Pager::new(); 45 | //! // Run the pager in a separate thread 46 | //! let pager2 = pager.clone(); 47 | //! let pager_thread = spawn(move || dynamic_paging(pager2)); 48 | //! 49 | //! for i in 0..=100_u32 { 50 | //! writeln!(pager, "{}", i); 51 | //! sleep(Duration::from_millis(100)); 52 | //! } 53 | //! pager_thread.join().unwrap()?; 54 | //! Ok(()) 55 | //! } 56 | //! ``` 57 | //! 58 | //! ## tokio 59 | //! 60 | //! ```rust,no_run 61 | //! use minus::{dynamic_paging, MinusError, Pager}; 62 | //! use std::time::Duration; 63 | //! use std::fmt::Write; 64 | //! use tokio::{join, task::spawn_blocking, time::sleep}; 65 | //! 66 | //! #[tokio::main] 67 | //! async fn main() -> Result<(), MinusError> { 68 | //! // Initialize the pager 69 | //! let mut pager = Pager::new(); 70 | //! // Asynchronously send data to the pager 71 | //! let increment = async { 72 | //! let mut pager = pager.clone(); 73 | //! for i in 0..=100_u32 { 74 | //! writeln!(pager, "{}", i); 75 | //! sleep(Duration::from_millis(100)).await; 76 | //! } 77 | //! Result::<_, MinusError>::Ok(()) 78 | //! }; 79 | //! // spawn_blocking(dynamic_paging(...)) creates a separate thread managed by the tokio 80 | //! // runtime and runs the async_paging inside it 81 | //! let pager = pager.clone(); 82 | //! let (res1, res2) = join!(spawn_blocking(move || dynamic_paging(pager)), increment); 83 | //! // .unwrap() unwraps any error while creating the tokio task 84 | //! // The ? mark unpacks any error that might have occurred while the 85 | //! // pager is running 86 | //! res1.unwrap()?; 87 | //! res2?; 88 | //! Ok(()) 89 | //! } 90 | //! ``` 91 | //! 92 | //! ## Static output 93 | //! ```rust,no_run 94 | //! use std::fmt::Write; 95 | //! use minus::{MinusError, Pager, page_all}; 96 | //! 97 | //! fn main() -> Result<(), MinusError> { 98 | //! // Initialize a default static configuration 99 | //! let mut output = Pager::new(); 100 | //! // Push numbers blockingly 101 | //! for i in 0..=30 { 102 | //! writeln!(output, "{}", i)?; 103 | //! } 104 | //! // Run the pager 105 | //! minus::page_all(output)?; 106 | //! // Return Ok result 107 | //! Ok(()) 108 | //! } 109 | //! ``` 110 | //! 111 | //! **Note:** 112 | //! In static mode, `minus` doesn't start the pager and just prints the content if the current terminal size can 113 | //! display all lines. You can of course change this behaviour. 114 | //! 115 | //! ## Default keybindings 116 | //! 117 | //! Here is the list of default key/mouse actions handled by `minus`. 118 | //! 119 | //! **A `[n] key` means that you can precede the key by an integer**. 120 | //! 121 | //! | Action | Description | 122 | //! |---------------------|------------------------------------------------------------------------------| 123 | //! | Ctrl+C/q | Quit the pager | 124 | //! | \[n\] Arrow Up/k | Scroll up by n number of line(s). If n is omitted, scroll up by 1 line | 125 | //! | \[n\] Arrow Down/j | Scroll down by n number of line(s). If n is omitted, scroll down by 1 line | 126 | //! | Ctrl+h | Turn off line wrapping and allow horizontal scrolling | 127 | //! | \[n\] Arrow left/h | Scroll left by n number of line(s). If n is omitted, scroll up by 1 line | 128 | //! | \[n\] Arrow right/l | Scroll right by n number of line(s). If n is omitted, scroll down by 1 line | 129 | //! | Page Up | Scroll up by entire page | 130 | //! | Page Down | Scroll down by entire page | 131 | //! | \[n\] Enter | Scroll down by n number of line(s). | 132 | //! | Space | Scroll down by one page | 133 | //! | Ctrl+U/u | Scroll up by half a screen | 134 | //! | Ctrl+D/d | Scroll down by half a screen | 135 | //! | g | Go to the very top of the output | 136 | //! | \[n\] G | Go to the very bottom of the output. If n is present, goes to that line | 137 | //! | Mouse scroll Up | Scroll up by 5 lines | 138 | //! | Mouse scroll Down | Scroll down by 5 lines | 139 | //! | Ctrl+L | Toggle line numbers if not forced enabled/disabled | 140 | //! | Ctrl+f | Toggle [follow-mode] | 141 | //! | / | Start forward search | 142 | //! | ? | Start backward search | 143 | //! | Esc | Cancel search input | 144 | //! | n | Go to the next search match | 145 | //! | p | Go to the next previous match | 146 | //! 147 | //! End-applications are free to change these bindings to better suit their needs. See docs for 148 | //! [Pager::set_input_classifier] function and [input] module. 149 | //! 150 | //! ## Key Bindings Available at Search Prompt 151 | //! 152 | //! | Key Bindings | Description | 153 | //! |-------------------|-----------------------------------------------------| 154 | //! | Esc | Cancel the search | 155 | //! | Enter | Confirm the search query | 156 | //! | Backspace | Remove the character before the cursor | 157 | //! | Delete | Remove the character under the cursor | 158 | //! | Arrow Left | Move cursor towards left | 159 | //! | Arrow right | Move cursor towards right | 160 | //! | Ctrl+Arrow left | Move cursor towards left word by word | 161 | //! | Ctrl+Arrow right | Move cursor towards right word by word | 162 | //! | Home | Move cursor at the beginning pf search query | 163 | //! | End | Move cursor at the end pf search query | 164 | //! 165 | //! Currently these cannot be changed by applications but this may be supported in the future. 166 | //! 167 | //! [`tokio`]: https://docs.rs/tokio 168 | //! [`async-std`]: https://docs.rs/async-std 169 | //! [`Threads`]: std::thread 170 | //! [follow-mode]: struct.Pager.html#method.follow_output 171 | //! [paging]: https://en.wikipedia.org/wiki/Terminal_pager 172 | //! [README]: https://github.com/arijit79/minus#motivation 173 | #[cfg(feature = "dynamic_output")] 174 | mod dynamic_pager; 175 | pub mod error; 176 | pub mod input; 177 | #[path = "core/mod.rs"] 178 | mod minus_core; 179 | mod pager; 180 | pub mod screen; 181 | #[cfg(feature = "search")] 182 | #[cfg_attr(docsrs, doc(cfg(feature = "search")))] 183 | pub mod search; 184 | pub mod state; 185 | #[cfg(feature = "static_output")] 186 | mod static_pager; 187 | 188 | #[cfg(feature = "dynamic_output")] 189 | pub use dynamic_pager::dynamic_paging; 190 | #[cfg(feature = "static_output")] 191 | pub use static_pager::page_all; 192 | 193 | pub use minus_core::RunMode; 194 | #[cfg(feature = "search")] 195 | pub use search::SearchMode; 196 | 197 | pub use error::MinusError; 198 | pub use pager::Pager; 199 | pub use state::PagerState; 200 | 201 | /// A convenient type for `Vec>` 202 | pub type ExitCallbacks = Vec>; 203 | 204 | /// Result type returned by most minus's functions 205 | type Result = std::result::Result; 206 | 207 | /// Behaviour that happens when the pager is exited 208 | #[derive(PartialEq, Clone, Debug, Eq)] 209 | pub enum ExitStrategy { 210 | /// Kill the entire application immediately. 211 | /// 212 | /// This is the preferred option if paging is the last thing you do. For example, 213 | /// the last thing you do in your program is reading from a file or a database and 214 | /// paging it concurrently 215 | /// 216 | /// **This is the default strategy.** 217 | ProcessQuit, 218 | /// Kill the pager only. 219 | /// 220 | /// This is the preferred option if you want to do more stuff after exiting the pager. For example, 221 | /// if you've file system locks or you want to close database connectiions after 222 | /// the pager has done i's job, you probably want to go for this option 223 | PagerQuit, 224 | } 225 | 226 | /// Enum indicating whether to display the line numbers or not. 227 | /// 228 | /// Note that displaying line numbers may be less performant than not doing it. 229 | /// `minus` tries to do as quickly as possible but the numbers and padding 230 | /// still have to be computed. 231 | /// 232 | /// This implements [`Not`](std::ops::Not) to allow turning on/off line numbers 233 | /// when they where not locked in by the binary displaying the text. 234 | #[derive(Debug, PartialEq, Eq, Copy, Clone)] 235 | pub enum LineNumbers { 236 | /// Enable line numbers permanently, cannot be turned off by user. 237 | AlwaysOn, 238 | /// Line numbers should be turned on, although users can turn it off 239 | /// (i.e, set it to `Disabled`). 240 | Enabled, 241 | /// Line numbers should be turned off, although users can turn it on 242 | /// (i.e, set it to `Enabled`). 243 | Disabled, 244 | /// Disable line numbers permanently, cannot be turned on by user. 245 | AlwaysOff, 246 | } 247 | 248 | impl LineNumbers { 249 | const EXTRA_PADDING: usize = 5; 250 | 251 | /// Returns `true` if `self` can be inverted (i.e, `!self != self`), see 252 | /// the documentation for the variants to know if they are invertible or 253 | /// not. 254 | #[allow(dead_code)] 255 | const fn is_invertible(self) -> bool { 256 | matches!(self, Self::Enabled | Self::Disabled) 257 | } 258 | 259 | const fn is_on(self) -> bool { 260 | matches!(self, Self::Enabled | Self::AlwaysOn) 261 | } 262 | } 263 | 264 | impl std::ops::Not for LineNumbers { 265 | type Output = Self; 266 | 267 | fn not(self) -> Self::Output { 268 | use LineNumbers::{Disabled, Enabled}; 269 | 270 | match self { 271 | Enabled => Disabled, 272 | Disabled => Enabled, 273 | ln => ln, 274 | } 275 | } 276 | } 277 | 278 | #[cfg(test)] 279 | mod tests; 280 | -------------------------------------------------------------------------------- /src/pager.rs: -------------------------------------------------------------------------------- 1 | //! Proivdes the [Pager] type 2 | 3 | use crate::{error::MinusError, input, minus_core::commands::Command, ExitStrategy, LineNumbers}; 4 | use crossbeam_channel::{Receiver, Sender}; 5 | use std::fmt; 6 | 7 | #[cfg(feature = "search")] 8 | use crate::search::SearchOpts; 9 | 10 | /// A communication bridge between the main application and the pager. 11 | /// 12 | /// The [Pager] type which is a bridge between your application and running 13 | /// the running pager. Its the single most important type with which you will be interacting the 14 | /// most while working with minus. It allows you to send data, configure UI settings and also 15 | /// configure the key/mouse bindings. 16 | /// 17 | /// You can 18 | /// - send data and 19 | /// - set configuration options 20 | /// 21 | /// before or while the pager is running. 22 | /// 23 | /// [Pager] also implements the [std::fmt::Write] trait which means you can directly call [write!] and 24 | /// [writeln!] macros on it. For example, you can easily do this 25 | /// 26 | /// ``` 27 | /// use minus::Pager; 28 | /// use std::fmt::Write; 29 | /// 30 | /// const WHO: &str = "World"; 31 | /// let mut pager = Pager::new(); 32 | /// 33 | /// // This appends `Hello World` to the end of minus's buffer 34 | /// writeln!(pager, "Hello {WHO}").unwrap(); 35 | /// // which is also equivalent to writing this 36 | /// pager.push_str(format!("Hello {WHO}\n")).unwrap(); 37 | #[derive(Clone)] 38 | pub struct Pager { 39 | pub(crate) tx: Sender, 40 | pub(crate) rx: Receiver, 41 | } 42 | 43 | impl Pager { 44 | /// Initialize a new pager 45 | /// 46 | /// # Example 47 | /// ``` 48 | /// let pager = minus::Pager::new(); 49 | /// ``` 50 | #[must_use] 51 | pub fn new() -> Self { 52 | let (tx, rx) = crossbeam_channel::unbounded(); 53 | Self { tx, rx } 54 | } 55 | 56 | /// Set the output text to this `t` 57 | /// 58 | /// Note that unlike [`Pager::push_str`], this replaces the original text. 59 | /// If you want to append text, use the [`Pager::push_str`] function or the 60 | /// [`write!`]/[`writeln!`] macros 61 | /// 62 | /// # Errors 63 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 64 | /// could not be sent to the receiver 65 | /// 66 | /// # Example 67 | /// ``` 68 | /// let pager = minus::Pager::new(); 69 | /// pager.set_text("This is a line").expect("Failed to send data to the pager"); 70 | /// ``` 71 | pub fn set_text(&self, s: impl Into) -> Result<(), MinusError> { 72 | Ok(self.tx.send(Command::SetData(s.into()))?) 73 | } 74 | 75 | /// Appends text to the pager output. 76 | /// 77 | /// You can also use [`write!`]/[`writeln!`] macros to append data to the pager. 78 | /// The implementation basically calls this function internally. One difference 79 | /// between using the macros and this function is that this does not require `Pager` 80 | /// to be declared mutable while in order to use the macros, you need to declare 81 | /// the `Pager` as mutable. 82 | /// 83 | /// # Errors 84 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 85 | /// could not be sent to the receiver 86 | /// 87 | /// # Example 88 | /// ``` 89 | /// use std::fmt::Write; 90 | /// 91 | /// let mut pager = minus::Pager::new(); 92 | /// pager.push_str("This is some text").expect("Failed to send data to the pager"); 93 | /// // This is same as above 94 | /// write!(pager, "This is some text").expect("Failed to send data to the pager"); 95 | /// ``` 96 | pub fn push_str(&self, s: impl Into) -> Result<(), MinusError> { 97 | Ok(self.tx.send(Command::AppendData(s.into()))?) 98 | } 99 | 100 | /// Set line number configuration for the pager 101 | /// 102 | /// See [`LineNumbers`] for available options 103 | /// 104 | /// # Errors 105 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 106 | /// could not be sent to the receiver 107 | /// 108 | /// # Example 109 | /// ``` 110 | /// use minus::{Pager, LineNumbers}; 111 | /// 112 | /// let pager = Pager::new(); 113 | /// pager.set_line_numbers(LineNumbers::Enabled).expect("Failed to communicate with the pager"); 114 | /// ``` 115 | pub fn set_line_numbers(&self, l: LineNumbers) -> Result<(), MinusError> { 116 | Ok(self.tx.send(Command::SetLineNumbers(l))?) 117 | } 118 | 119 | /// Set the text displayed at the bottom prompt 120 | /// 121 | /// # Panics 122 | /// This function panics if the given text contains newline characters. 123 | /// This is because, the pager reserves only one line for showing the prompt 124 | /// and a newline will cause it to span multiple lines, breaking the display 125 | /// 126 | /// # Errors 127 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 128 | /// could not be sent to the receiver 129 | /// 130 | /// Example 131 | /// ``` 132 | /// use minus::Pager; 133 | /// 134 | /// let pager = Pager::new(); 135 | /// pager.set_prompt("my prompt").expect("Failed to send data to the pager"); 136 | /// ``` 137 | pub fn set_prompt(&self, text: impl Into) -> Result<(), MinusError> { 138 | let text: String = text.into(); 139 | assert!(!text.contains('\n'), "Prompt cannot contain newlines"); 140 | Ok(self.tx.send(Command::SetPrompt(text))?) 141 | } 142 | 143 | /// Send a message to be displayed the prompt area 144 | /// 145 | /// The text message is temporary and will get cleared whenever the use 146 | /// rdoes a action on the terminal like pressing a key or scrolling using the mouse. 147 | /// 148 | /// # Panics 149 | /// This function panics if the given text contains newline characters. 150 | /// This is because, the pager reserves only one line for showing the prompt 151 | /// and a newline will cause it to span multiple lines, breaking the display 152 | /// 153 | /// # Errors 154 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 155 | /// could not be sent to the receiver 156 | /// 157 | /// # Example 158 | /// ``` 159 | /// use minus::Pager; 160 | /// 161 | /// let pager = Pager::new(); 162 | /// pager.send_message("An error occurred").expect("Failed to send data to the pager"); 163 | /// ``` 164 | pub fn send_message(&self, text: impl Into) -> Result<(), MinusError> { 165 | let text: String = text.into(); 166 | assert!(!text.contains('\n'), "Message cannot contain newlines"); 167 | Ok(self.tx.send(Command::SendMessage(text))?) 168 | } 169 | 170 | /// Set the default exit strategy. 171 | /// 172 | /// This controls how the pager will behave when the user presses `q` or `Ctrl+C`. 173 | /// See [`ExitStrategy`] for available options 174 | /// 175 | /// # Errors 176 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 177 | /// could not be sent to the receiver 178 | /// 179 | /// ``` 180 | /// use minus::{Pager, ExitStrategy}; 181 | /// 182 | /// let pager = Pager::new(); 183 | /// pager.set_exit_strategy(ExitStrategy::ProcessQuit).expect("Failed to communicate with the pager"); 184 | /// ``` 185 | pub fn set_exit_strategy(&self, es: ExitStrategy) -> Result<(), MinusError> { 186 | Ok(self.tx.send(Command::SetExitStrategy(es))?) 187 | } 188 | 189 | /// Set whether to display pager if there's less data than 190 | /// available screen height 191 | /// 192 | /// When this is set to false, the pager will simply print all the lines 193 | /// to the main screen and immediately quit if the number of lines to 194 | /// display is less than the available columns in the terminal. 195 | /// Setting this to true will cause a full pager to start and display the data 196 | /// even if there is less number of lines to display than available rows. 197 | /// 198 | /// This is only available in static output mode as the size of the data is 199 | /// known beforehand. 200 | /// In async output the pager can receive more data anytime 201 | /// 202 | /// By default this is set to false 203 | /// 204 | /// # Errors 205 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 206 | /// could not be sent to the receiver 207 | /// 208 | /// ``` 209 | /// use minus::Pager; 210 | /// 211 | /// let pager = Pager::new(); 212 | /// pager.set_run_no_overflow(true).expect("Failed to communicate with the pager"); 213 | /// ``` 214 | #[cfg(feature = "static_output")] 215 | #[cfg_attr(docsrs, doc(cfg(feature = "static_output")))] 216 | pub fn set_run_no_overflow(&self, val: bool) -> Result<(), MinusError> { 217 | Ok(self.tx.send(Command::SetRunNoOverflow(val))?) 218 | } 219 | 220 | /// Whether to allow scrolling horizontally 221 | /// 222 | /// Setting this to `true` implicitly disables line wrapping 223 | /// 224 | /// # Errors 225 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 226 | /// could not be sent to the receiver 227 | /// 228 | /// ``` 229 | /// use minus::Pager; 230 | /// 231 | /// let pager = Pager::new(); 232 | /// pager.horizontal_scroll(true).expect("Failed to communicate with the pager"); 233 | /// ``` 234 | pub fn horizontal_scroll(&self, value: bool) -> Result<(), MinusError> { 235 | Ok(self.tx.send(Command::LineWrapping(!value))?) 236 | } 237 | 238 | /// Set a custom input classifer type. 239 | /// 240 | /// An input classifier type is a type that implements the [InputClassifier] 241 | /// trait. It only has one required function, [InputClassifier::classify_input] 242 | /// which matches user input events and maps them to a [InputEvent]s. 243 | /// When the pager encounters a user input, it calls the input classifier with 244 | /// the event and [PagerState] as parameters. 245 | /// 246 | /// Previously, whenever any application wanted to change the default key/mouse bindings 247 | /// they neededd to create a new type, implement the [InputClassifier] type by copying and 248 | /// pasting the default minus's implementation of it available in the [DefaultInputClassifier] 249 | /// and change the parts they wanted to change. This is not only unergonomic but also 250 | /// extreemely prone to bugs. Hence a newer and much simpler method was developed. 251 | /// This method is still allowed to avoid breaking backwards compatiblity but will be dropped 252 | /// in the next major release. 253 | /// 254 | /// With the newer method, minus already provides a type called [HashedEventRegister] 255 | /// which implementing the [InputClassifier] and is based on a 256 | /// [HashMap] storing all the key/mouse bindings and its associated callback function. 257 | /// This allows easy addition/updation/deletion of the default bindings with simple functions 258 | /// like [HashedEventRegister::add_key_events] and [HashedEventRegister::add_mouse_events] 259 | /// 260 | /// See the [input] module for information about implementing it. 261 | /// 262 | /// # Errors 263 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 264 | /// could not be sent to the receiver 265 | /// 266 | /// [HashedEventRegister::add_key_events]: input::HashedEventRegister::add_key_events 267 | /// [HashedEventRegister::add_mouse_events]: input::HashedEventRegister::add_mouse_events 268 | /// [HashMap]: std::collections::HashMap 269 | /// [PagerState]: crate::state::PagerState 270 | /// [InputEvent]: input::InputEvent 271 | /// [InputClassifier]: input::InputClassifier 272 | /// [InputClassifier::classify_input]: input::InputClassifier 273 | /// [HashedEventRegister]: input::HashedEventRegister 274 | /// [DefaultInputClassifier]: input::DefaultInputClassifier 275 | pub fn set_input_classifier( 276 | &self, 277 | handler: Box, 278 | ) -> Result<(), MinusError> { 279 | Ok(self.tx.send(Command::SetInputClassifier(handler))?) 280 | } 281 | 282 | /// Adds a function that will be called when the user quits the pager 283 | /// 284 | /// Multiple functions can be stored for calling when the user quits. These functions 285 | /// run sequentially in the order they were added 286 | /// 287 | /// # Errors 288 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 289 | /// could not be sent to the receiver 290 | /// 291 | /// # Example 292 | /// ``` 293 | /// use minus::Pager; 294 | /// 295 | /// fn hello() { 296 | /// println!("Hello"); 297 | /// } 298 | /// 299 | /// let pager = Pager::new(); 300 | /// pager.add_exit_callback(Box::new(hello)).expect("Failed to communicate with the pager"); 301 | /// ``` 302 | pub fn add_exit_callback( 303 | &self, 304 | cb: Box, 305 | ) -> Result<(), MinusError> { 306 | Ok(self.tx.send(Command::AddExitCallback(cb))?) 307 | } 308 | 309 | /// Override the condition for running incremental search 310 | /// 311 | /// See [Incremental Search](../search/index.html#incremental-search) to know more on how this 312 | /// works 313 | /// 314 | /// # Errors 315 | /// This function will returns a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 316 | /// could not be send to the receiver end. 317 | #[cfg(feature = "search")] 318 | #[cfg_attr(docsrs, doc(cfg(feature = "search")))] 319 | pub fn set_incremental_search_condition( 320 | &self, 321 | cb: Box bool + Send + Sync + 'static>, 322 | ) -> crate::Result { 323 | self.tx.send(Command::IncrementalSearchCondition(cb))?; 324 | Ok(()) 325 | } 326 | 327 | /// Control whether to show the prompt 328 | /// 329 | /// Many applications don't want the prompt to be displayed at all. This function can be used to completely turn 330 | /// off the prompt. Passing `false` to this will stops the prompt from displaying and instead a blank line will 331 | /// be displayed. 332 | /// 333 | /// Note that This merely stop the prompt from being shown. Your application can still update the 334 | /// prompt and send messages to the user but it won't be shown until the prompt isn't re-enabled. 335 | /// The prompt section will also be used when user opens the search prompt to type a search query. 336 | /// 337 | /// # Errors 338 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 339 | /// could not be sent to the mus's receiving end 340 | /// 341 | /// # Example 342 | /// ``` 343 | /// use minus::Pager; 344 | /// 345 | /// let pager = Pager::new(); 346 | /// pager.show_prompt(false).unwrap(); 347 | /// ``` 348 | pub fn show_prompt(&self, show: bool) -> crate::Result { 349 | self.tx.send(Command::ShowPrompt(show))?; 350 | Ok(()) 351 | } 352 | 353 | /// Configures follow output 354 | /// 355 | /// When set to true, minus ensures that the user's screen always follows the end part of the 356 | /// output. By default it is turned off. 357 | /// 358 | /// This is similar to [InputEvent::FollowOutput](crate::input::InputEvent::FollowOutput) except that 359 | /// this is used to control it from the application's side. 360 | /// 361 | /// # Errors 362 | /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data 363 | /// could not be sent to the mus's receiving end 364 | /// 365 | /// # Example 366 | /// ``` 367 | /// use minus::Pager; 368 | /// 369 | /// let pager = Pager::new(); 370 | /// pager.follow_output(true).unwrap(); 371 | /// ``` 372 | pub fn follow_output(&self, follow_output: bool) -> crate::Result { 373 | self.tx.send(Command::FollowOutput(follow_output))?; 374 | Ok(()) 375 | } 376 | } 377 | 378 | impl Default for Pager { 379 | fn default() -> Self { 380 | Self::new() 381 | } 382 | } 383 | 384 | impl fmt::Write for Pager { 385 | fn write_str(&mut self, s: &str) -> fmt::Result { 386 | self.push_str(s).map_err(|_| fmt::Error) 387 | } 388 | } 389 | -------------------------------------------------------------------------------- /src/screen/tests.rs: -------------------------------------------------------------------------------- 1 | mod unterminated { 2 | use crate::screen::{format_text_block, FormatOpts, Rows}; 3 | 4 | const fn get_append_opts_template(text: &str) -> FormatOpts { 5 | FormatOpts { 6 | buffer: Vec::new(), 7 | text, 8 | attachment: None, 9 | #[cfg(feature = "search")] 10 | search_term: &None, 11 | lines_count: 0, 12 | formatted_lines_count: 0, 13 | cols: 80, 14 | line_numbers: crate::LineNumbers::Disabled, 15 | prev_unterminated: 0, 16 | line_wrapping: true, 17 | } 18 | } 19 | 20 | #[test] 21 | fn test_single_no_endline() { 22 | let append_style = format_text_block(get_append_opts_template("This is a line")); 23 | assert_eq!(1, append_style.num_unterminated); 24 | } 25 | 26 | #[test] 27 | fn test_single_endline() { 28 | let append_style = format_text_block(get_append_opts_template("This is a line\n")); 29 | assert_eq!(0, append_style.num_unterminated); 30 | } 31 | 32 | #[test] 33 | fn test_single_multi_newline() { 34 | let append_style = format_text_block(get_append_opts_template( 35 | "This is a line\nThis is another line\nThis is third line", 36 | )); 37 | assert_eq!(1, append_style.num_unterminated); 38 | } 39 | 40 | #[test] 41 | fn test_single_multi_endline() { 42 | let append_style = format_text_block(get_append_opts_template( 43 | "This is a line\nThis is another line\n", 44 | )); 45 | assert_eq!(0, append_style.num_unterminated); 46 | } 47 | 48 | #[test] 49 | fn test_single_line_wrapping() { 50 | let mut fs = get_append_opts_template("This is a quite lengthy line"); 51 | fs.cols = 20; 52 | let append_style = format_text_block(fs); 53 | assert_eq!(2, append_style.num_unterminated); 54 | } 55 | 56 | #[test] 57 | fn test_single_mid_newline_wrapping() { 58 | let mut fs = get_append_opts_template( 59 | "This is a quite lengthy line\nIt has three lines\nThis is 60 | third line", 61 | ); 62 | fs.cols = 20; 63 | let append_style = format_text_block(fs); 64 | assert_eq!(1, append_style.num_unterminated); 65 | } 66 | 67 | #[test] 68 | fn test_single_endline_wrapping() { 69 | let mut fs = get_append_opts_template( 70 | "This is a quite lengthy line\nIt has three lines\nThis is 71 | third line\n", 72 | ); 73 | fs.cols = 20; 74 | let append_style = format_text_block(fs); 75 | assert_eq!(0, append_style.num_unterminated); 76 | } 77 | 78 | #[test] 79 | fn test_multi_no_endline() { 80 | let append_style = format_text_block(get_append_opts_template("This is a line. ")); 81 | assert_eq!(1, append_style.num_unterminated); 82 | 83 | let mut fs = get_append_opts_template("This is another line"); 84 | fs.prev_unterminated = append_style.num_unterminated; 85 | fs.attachment = Some("This is a line. "); 86 | 87 | let append_style = format_text_block(fs); 88 | assert_eq!(1, append_style.num_unterminated); 89 | } 90 | 91 | #[test] 92 | fn test_multi_endline() { 93 | let append_style = format_text_block(get_append_opts_template("This is a line. ")); 94 | assert_eq!(1, append_style.num_unterminated); 95 | 96 | let mut fs = get_append_opts_template("This is another line\n"); 97 | fs.prev_unterminated = append_style.num_unterminated; 98 | fs.attachment = Some("This is a line. "); 99 | 100 | let append_style = format_text_block(fs); 101 | assert_eq!(0, append_style.num_unterminated); 102 | } 103 | 104 | #[test] 105 | fn test_multi_multiple_newline() { 106 | let append_style = format_text_block(get_append_opts_template("This is a line\n")); 107 | assert_eq!(0, append_style.num_unterminated); 108 | 109 | let mut fs = get_append_opts_template("This is another line\n"); 110 | fs.lines_count = 1; 111 | fs.formatted_lines_count = 1; 112 | fs.attachment = None; 113 | 114 | let append_style = format_text_block(fs); 115 | assert_eq!(0, append_style.num_unterminated); 116 | } 117 | 118 | #[test] 119 | fn test_multi_wrapping() { 120 | let mut fs = get_append_opts_template("This is a line. This is second line. "); 121 | fs.cols = 20; 122 | let append_style = format_text_block(fs); 123 | assert_eq!(2, append_style.num_unterminated); 124 | 125 | let mut fs = get_append_opts_template("This is another line\n"); 126 | fs.cols = 20; 127 | fs.prev_unterminated = append_style.num_unterminated; 128 | fs.attachment = Some("This is a line. This is second line"); 129 | 130 | let append_style = format_text_block(fs); 131 | assert_eq!(0, append_style.num_unterminated); 132 | } 133 | 134 | #[test] 135 | fn test_multi_wrapping_continued() { 136 | let mut fs = get_append_opts_template("This is a line. This is second line. "); 137 | fs.cols = 20; 138 | let append_style = format_text_block(fs); 139 | assert_eq!(2, append_style.num_unterminated); 140 | 141 | let mut fs = get_append_opts_template("This is third line"); 142 | fs.cols = 20; 143 | fs.prev_unterminated = append_style.num_unterminated; 144 | fs.attachment = Some("This is a line. This is second line. "); 145 | 146 | let append_style = format_text_block(fs); 147 | assert_eq!(3, append_style.num_unterminated); 148 | } 149 | 150 | #[test] 151 | fn test_multi_wrapping_last_continued() { 152 | let mut fs = get_append_opts_template("This is a line.\nThis is second line. "); 153 | fs.cols = 20; 154 | let append_style = format_text_block(fs); 155 | assert_eq!(1, append_style.num_unterminated); 156 | 157 | let mut fs = get_append_opts_template("This is third line."); 158 | fs.cols = 20; 159 | fs.prev_unterminated = append_style.num_unterminated; 160 | fs.attachment = Some("This is second line. "); 161 | fs.lines_count = 1; 162 | fs.formatted_lines_count = 2; 163 | 164 | let append_style = format_text_block(fs); 165 | 166 | assert_eq!(2, append_style.num_unterminated); 167 | } 168 | 169 | #[test] 170 | fn test_multi_wrapping_additive() { 171 | let mut fs = get_append_opts_template("This is a line. "); 172 | fs.cols = 20; 173 | let append_style = format_text_block(fs); 174 | assert_eq!(1, append_style.num_unterminated); 175 | 176 | let mut fs = get_append_opts_template("This is second line. "); 177 | fs.cols = 20; 178 | fs.prev_unterminated = append_style.num_unterminated; 179 | fs.attachment = Some("This is a line. "); 180 | 181 | let append_style = format_text_block(fs); 182 | assert_eq!(2, append_style.num_unterminated); 183 | 184 | let mut fs = get_append_opts_template("This is third line"); 185 | fs.cols = 20; 186 | fs.prev_unterminated = append_style.num_unterminated; 187 | fs.attachment = Some("This is a line. This is second line. "); 188 | let append_style = format_text_block(fs); 189 | 190 | assert_eq!(3, append_style.num_unterminated); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | //! Contains types that hold run-time information of the pager. 2 | 3 | #[cfg(feature = "search")] 4 | use crate::search::{SearchMode, SearchOpts}; 5 | 6 | use crate::{ 7 | error::{MinusError, TermError}, 8 | input::{self, HashedEventRegister}, 9 | minus_core::{ 10 | self, 11 | utils::{display::AppendStyle, LinesRowMap}, 12 | CommandQueue, 13 | }, 14 | screen::{self, Screen}, 15 | ExitStrategy, LineNumbers, 16 | }; 17 | use crossterm::{terminal, tty::IsTty}; 18 | #[cfg(feature = "search")] 19 | use parking_lot::Condvar; 20 | use parking_lot::Mutex; 21 | #[cfg(feature = "search")] 22 | use std::collections::BTreeSet; 23 | use std::{ 24 | collections::hash_map::RandomState, 25 | convert::TryInto, 26 | io::stdout, 27 | io::Stdout, 28 | sync::{atomic::AtomicBool, Arc}, 29 | }; 30 | 31 | use crate::minus_core::{commands::Command, ev_handler::handle_event}; 32 | use crossbeam_channel::Receiver; 33 | 34 | #[cfg(feature = "search")] 35 | #[cfg_attr(docsrs, doc(cfg(feature = "search")))] 36 | #[allow(clippy::module_name_repetitions)] 37 | /// Contains information about the current search 38 | pub struct SearchState { 39 | /// Direction of search 40 | /// 41 | /// See [`SearchMode`] for available options 42 | pub search_mode: SearchMode, 43 | /// Stores the most recent search term 44 | pub(crate) search_term: Option, 45 | /// Lines where searches have a match 46 | /// In order to avoid duplicate entries of lines, we keep it in a [`BTreeSet`] 47 | pub(crate) search_idx: BTreeSet, 48 | /// Index of search item currently in focus 49 | /// It should be 0 even when no search is in action 50 | pub(crate) search_mark: usize, 51 | /// Function to run before running an incremental search. 52 | /// 53 | /// If the function returns a `false`, the incremental search is cancelled. 54 | pub(crate) incremental_search_condition: 55 | Box bool + Send + Sync + 'static>, 56 | } 57 | 58 | #[cfg(feature = "search")] 59 | impl Default for SearchState { 60 | fn default() -> Self { 61 | let incremental_search_condition = Box::new(|so: &SearchOpts| { 62 | so.string.len() > 1 63 | && so 64 | .incremental_search_options 65 | .as_ref() 66 | .unwrap() 67 | .screen 68 | .line_count() 69 | <= 5000 70 | }); 71 | Self { 72 | search_mode: SearchMode::Unknown, 73 | search_term: None, 74 | search_idx: BTreeSet::new(), 75 | search_mark: 0, 76 | incremental_search_condition, 77 | } 78 | } 79 | } 80 | 81 | /// Holds all information and configuration about the pager during 82 | /// its run time. 83 | /// 84 | /// This type is exposed so that end-applications can implement the 85 | /// [`InputClassifier`](input::InputClassifier) trait which requires the `PagerState` to be passed 86 | /// as a parameter 87 | /// 88 | /// Various fields are made public so that their values can be accessed while implementing the 89 | /// trait. 90 | #[allow(clippy::module_name_repetitions)] 91 | pub struct PagerState { 92 | /// Configuration for line numbers. See [`LineNumbers`] 93 | pub line_numbers: LineNumbers, 94 | /// Any message to display to the user at the prompt 95 | /// The first element contains the actual message, while the second element tells 96 | /// whether the message has changed since the last display. 97 | pub message: Option, 98 | /// The upper bound of scrolling. 99 | /// 100 | /// This is useful for keeping track of the range of lines which are currently being displayed on 101 | /// the terminal. 102 | /// When `rows - 1` is added to the `upper_mark`, it gives the lower bound of scroll. 103 | /// 104 | /// For example if there are 10 rows is a terminal and the data to display has 50 lines in it 105 | /// If the `upper_mark` is 15, then the first row of the terminal is the 16th line of the data 106 | /// and last row is the 24th line of the data. 107 | pub upper_mark: usize, 108 | /// The left mark of scrolling 109 | /// 110 | /// When this is `> 0`, this amount of text will be truncated from the left side 111 | pub left_mark: usize, 112 | /// Direction of search 113 | /// 114 | /// See [`SearchMode`] for available options 115 | /// 116 | /// **WARNING: This item has been deprecated in favour of [SearchState::search_mode] availlable 117 | /// by the [PagerState::search_state] field. Any new code should prefer using it instead of this one.** 118 | #[cfg(feature = "search")] 119 | #[cfg_attr(docsrs, cfg(feature = "search"))] 120 | pub search_mode: SearchMode, 121 | /// Available rows in the terminal 122 | pub rows: usize, 123 | /// Available columns in the terminal 124 | pub cols: usize, 125 | /// This variable helps in scrolling more than one line at a time 126 | /// It keeps track of all the numbers that have been entered by the user 127 | /// until any of `j`, `k`, `G`, `Up` or `Down` is pressed 128 | pub prefix_num: String, 129 | /// Describes whether minus is running and in which mode 130 | pub running: &'static Mutex, 131 | #[cfg(feature = "search")] 132 | #[cfg_attr(docsrs, cfg(feature = "search"))] 133 | pub search_state: SearchState, 134 | pub screen: Screen, 135 | /// The prompt displayed at the bottom wrapped to available terminal width 136 | pub(crate) prompt: String, 137 | /// The input classifier to be called when a input is detected 138 | pub(crate) input_classifier: Box, 139 | /// Functions to run when the pager quits 140 | pub(crate) exit_callbacks: Vec>, 141 | /// The behaviour to do when user quits the program using `q` or `Ctrl+C` 142 | /// See [`ExitStrategy`] for available options 143 | pub(crate) exit_strategy: ExitStrategy, 144 | /// The prompt that should be displayed to the user, formatted with the 145 | /// current search index and number of matches (if the search feature is enabled), 146 | /// and the current numbers inputted to scroll 147 | pub(crate) displayed_prompt: String, 148 | /// Whether to show the prompt on the screen 149 | pub(crate) show_prompt: bool, 150 | /// Do we want to page if there is no overflow 151 | #[cfg(feature = "static_output")] 152 | pub(crate) run_no_overflow: bool, 153 | pub(crate) lines_to_row_map: LinesRowMap, 154 | /// Value for follow mode. 155 | /// See [follow_output](crate::pager::Pager::follow_output) for more info on follow mode. 156 | pub(crate) follow_output: bool, 157 | } 158 | 159 | impl PagerState { 160 | pub(crate) fn new() -> Result { 161 | let (rows, cols); 162 | 163 | if cfg!(test) { 164 | // In tests, set number of columns to 80 and rows to 10 165 | cols = 80; 166 | rows = 10; 167 | } else if stdout().is_tty() { 168 | // If a proper terminal is present, get size and set it 169 | let size = terminal::size()?; 170 | cols = size.0 as usize; 171 | rows = size.1 as usize; 172 | } else { 173 | // For other cases beyond control 174 | cols = 1; 175 | rows = 1; 176 | }; 177 | 178 | let prompt = std::env::current_exe() 179 | .unwrap_or_else(|_| std::path::PathBuf::from("minus")) 180 | .file_name() 181 | .map_or_else( 182 | || std::ffi::OsString::from("minus"), 183 | std::ffi::OsStr::to_os_string, 184 | ) 185 | .into_string() 186 | .unwrap_or_else(|_| String::from("minus")); 187 | 188 | let mut state = Self { 189 | line_numbers: LineNumbers::Disabled, 190 | upper_mark: 0, 191 | prompt, 192 | running: &minus_core::RUNMODE, 193 | left_mark: 0, 194 | exit_strategy: ExitStrategy::ProcessQuit, 195 | input_classifier: Box::>::default(), 196 | exit_callbacks: Vec::with_capacity(5), 197 | message: None, 198 | screen: Screen::default(), 199 | displayed_prompt: String::new(), 200 | show_prompt: true, 201 | #[cfg(feature = "static_output")] 202 | run_no_overflow: false, 203 | #[cfg(feature = "search")] 204 | search_mode: SearchMode::default(), 205 | #[cfg(feature = "search")] 206 | search_state: SearchState::default(), 207 | // Just to be safe in tests, keep at 1x1 size 208 | cols, 209 | rows, 210 | prefix_num: String::new(), 211 | lines_to_row_map: LinesRowMap::new(), 212 | follow_output: false, 213 | }; 214 | 215 | state.format_prompt(); 216 | Ok(state) 217 | } 218 | 219 | /// Generate the initial [`PagerState`] 220 | /// 221 | /// [`init_core`](crate::minus_core::init::init_core) calls this functions for creating the PagerState. 222 | /// 223 | /// This function creates a default [`PagerState`] and fetches all events present in the receiver 224 | /// to create the initial state. This is done before starting the pager so that 225 | /// the optimizationss can be applied. 226 | /// 227 | /// # Errors 228 | /// This function will return an error if it could not create the default [`PagerState`] or fails 229 | /// to process the events 230 | pub(crate) fn generate_initial_state( 231 | rx: &Receiver, 232 | mut out: &mut Stdout, 233 | ) -> Result { 234 | let mut ps = Self::new()?; 235 | let mut command_queue = CommandQueue::new_zero(); 236 | rx.try_iter().try_for_each(|ev| -> Result<(), MinusError> { 237 | handle_event( 238 | ev, 239 | &mut out, 240 | &mut ps, 241 | &mut command_queue, 242 | &Arc::new(AtomicBool::new(false)), 243 | #[cfg(feature = "search")] 244 | &Arc::new((Mutex::new(true), Condvar::new())), 245 | ) 246 | })?; 247 | Ok(ps) 248 | } 249 | 250 | pub(crate) fn format_lines(&mut self) { 251 | let (buffer, format_result) = screen::make_format_lines( 252 | &self.screen.orig_text, 253 | self.line_numbers, 254 | self.cols, 255 | self.screen.line_wrapping, 256 | #[cfg(feature = "search")] 257 | &self.search_state.search_term, 258 | ); 259 | 260 | #[cfg(feature = "search")] 261 | { 262 | self.search_state.search_idx = format_result.append_search_idx; 263 | } 264 | self.screen.formatted_lines = buffer; 265 | self.lines_to_row_map = format_result.lines_to_row_map; 266 | self.screen.max_line_length = format_result.max_line_length; 267 | 268 | self.screen.unterminated = format_result.num_unterminated; 269 | self.format_prompt(); 270 | } 271 | 272 | /// Reformat the inputted prompt to how it should be displayed 273 | pub(crate) fn format_prompt(&mut self) { 274 | const PROMPT_SPEC: &str = "\x1b[2;40;37m"; 275 | const SEARCH_SPEC: &str = "\x1b[30;44m"; 276 | const INPUT_SPEC: &str = "\x1b[30;43m"; 277 | const MSG_SPEC: &str = "\x1b[30;1;41m"; 278 | const RESET: &str = "\x1b[0m"; 279 | const FOLLOW_MODE_SPEC: &str = "\x1b[1m"; 280 | 281 | // Allocate the string. Add extra space in case for the 282 | // ANSI escape things if we do have characters typed and search showing 283 | let mut format_string = String::with_capacity(self.cols + (SEARCH_SPEC.len() * 5) + 4); 284 | 285 | // Get the string that will contain the search index/match indicator 286 | #[cfg(feature = "search")] 287 | let mut search_str = String::new(); 288 | #[cfg(feature = "search")] 289 | if !self.search_state.search_idx.is_empty() { 290 | search_str.push(' '); 291 | search_str.push_str(&(self.search_state.search_mark + 1).to_string()); 292 | search_str.push('/'); 293 | search_str.push_str(&self.search_state.search_idx.len().to_string()); 294 | search_str.push(' '); 295 | } 296 | 297 | // And get the string that will contain the prefix_num 298 | let mut prefix_str = String::new(); 299 | if !self.prefix_num.is_empty() { 300 | prefix_str.push(' '); 301 | prefix_str.push_str(&self.prefix_num); 302 | prefix_str.push(' '); 303 | } 304 | 305 | // And lastly, the string that contains the prompt or msg 306 | let prompt_str = self.message.as_ref().unwrap_or(&self.prompt); 307 | 308 | #[cfg(feature = "search")] 309 | let search_len = search_str.len(); 310 | #[cfg(not(feature = "search"))] 311 | let search_len = 0; 312 | 313 | let follow_mode_str: &str = if self.follow_output { "[F]" } else { "" }; 314 | 315 | // Calculate how much extra padding in the middle we need between 316 | // the prompt/message and the indicators on the right 317 | let prefix_len = prefix_str.len(); 318 | let extra_space = self 319 | .cols 320 | .saturating_sub(search_len + prefix_len + follow_mode_str.len() + prompt_str.len()); 321 | let dsp_prompt: &str = if extra_space == 0 { 322 | &prompt_str[..self.cols - search_len - prefix_len - follow_mode_str.len()] 323 | } else { 324 | prompt_str 325 | }; 326 | 327 | // push the prompt/msg 328 | if self.message.is_some() { 329 | format_string.push_str(MSG_SPEC); 330 | } else { 331 | format_string.push_str(PROMPT_SPEC); 332 | } 333 | format_string.push_str(dsp_prompt); 334 | format_string.push_str(&" ".repeat(extra_space)); 335 | 336 | // add the prefix_num if it exists 337 | if prefix_len > 0 { 338 | format_string.push_str(INPUT_SPEC); 339 | format_string.push_str(&prefix_str); 340 | } 341 | 342 | // and add the search indicator stuff if it exists 343 | #[cfg(feature = "search")] 344 | if search_len > 0 { 345 | format_string.push_str(SEARCH_SPEC); 346 | format_string.push_str(&search_str); 347 | } 348 | 349 | // add follow-mode indicator 350 | if !follow_mode_str.is_empty() { 351 | format_string.push_str(FOLLOW_MODE_SPEC); 352 | format_string.push_str(follow_mode_str); 353 | } 354 | 355 | format_string.push_str(RESET); 356 | 357 | self.displayed_prompt = format_string; 358 | } 359 | 360 | /// Runs the exit callbacks 361 | pub(crate) fn exit(&mut self) { 362 | for func in &mut self.exit_callbacks { 363 | func(); 364 | } 365 | } 366 | 367 | pub(crate) fn append_str(&mut self, text: &str) -> AppendStyle { 368 | let old_lc = self.screen.line_count(); 369 | let old_lc_dgts = minus_core::utils::digits(old_lc); 370 | let mut append_result = self.screen.push_screen_buf( 371 | text, 372 | self.line_numbers, 373 | self.cols.try_into().unwrap(), 374 | #[cfg(feature = "search")] 375 | &self.search_state.search_term, 376 | ); 377 | let new_lc = self.screen.line_count(); 378 | let new_lc_dgts = minus_core::utils::digits(new_lc); 379 | #[cfg(feature = "search")] 380 | { 381 | let mut append_search_idx = append_result.append_search_idx; 382 | self.search_state.search_idx.append(&mut append_search_idx); 383 | } 384 | self.lines_to_row_map.append( 385 | &mut append_result.lines_to_row_map, 386 | append_result.clean_append, 387 | ); 388 | 389 | if self.line_numbers.is_on() && (new_lc_dgts != old_lc_dgts && old_lc_dgts != 0) { 390 | self.format_lines(); 391 | return AppendStyle::FullRedraw; 392 | } 393 | 394 | let total_rows = self.screen.formatted_lines_count(); 395 | let fmt_lines = &self 396 | .screen 397 | .get_formatted_lines_with_bounds(total_rows - append_result.rows_formatted, total_rows); 398 | AppendStyle::PartialUpdate(fmt_lines) 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /src/static_pager.rs: -------------------------------------------------------------------------------- 1 | //! Contains function for displaying static data 2 | //! 3 | //! This module provides provides the [`page_all`] function to display static output via minus 4 | use crate::minus_core::init; 5 | use crate::{error::MinusError, Pager}; 6 | 7 | /// Display static information to the screen 8 | /// 9 | /// Since it is sure that fed data will never change, minus can do some checks like:- 10 | /// * If stdout is not a tty, minus not start a pager. It will simply print all the data and quit 11 | /// * If there are more rows in the terminal than the number of lines of data to display 12 | /// minus will not start a pager and simply display all data on the main stdout screen. 13 | /// This behaviour can be turned off if 14 | /// [`Pager::set_run_no_overflow(true)`](Pager::set_run_no_overflow) has been 15 | /// called before starting 16 | /// * Since any other event except user inputs will not occur, we can do some optimizations on 17 | /// matching events. 18 | /// 19 | /// See [example](../index.html#static-output) on how to use this function. 20 | /// 21 | /// # Panics 22 | /// This function will panic if another instance of minus is already running. 23 | /// 24 | /// # Errors 25 | /// The function will return with an error if it encounters a error during paging. 26 | #[cfg_attr(docsrs, doc(cfg(feature = "static_output")))] 27 | #[allow(clippy::needless_pass_by_value)] 28 | pub fn page_all(pager: Pager) -> Result<(), MinusError> { 29 | init::init_core(&pager, crate::RunMode::Static) 30 | } 31 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | // Test the implementation of std::fmt::Write on Pager 2 | mod fmt_write { 3 | use crate::{minus_core::commands::Command, Pager}; 4 | use std::fmt::Write; 5 | 6 | #[test] 7 | fn pager_writeln() { 8 | const TEST: &str = "This is a line"; 9 | let mut pager = Pager::new(); 10 | writeln!(pager, "{TEST}").unwrap(); 11 | while let Ok(Command::AppendData(text)) = pager.rx.try_recv() { 12 | if text != "\n" { 13 | assert_eq!(text, TEST.to_string()); 14 | } 15 | } 16 | } 17 | 18 | #[test] 19 | fn test_write() { 20 | const TEST: &str = "This is a line"; 21 | let mut pager = Pager::new(); 22 | write!(pager, "{TEST}").unwrap(); 23 | while let Ok(Command::AppendData(text)) = pager.rx.try_recv() { 24 | assert_eq!(text, TEST.to_string()); 25 | } 26 | } 27 | } 28 | 29 | mod pager_append_str { 30 | use crate::PagerState; 31 | 32 | #[test] 33 | fn sequential_append_str() { 34 | const TEXT1: &str = "This is a line."; 35 | const TEXT2: &str = " This is a follow up line"; 36 | let mut ps = PagerState::new().unwrap(); 37 | ps.append_str(TEXT1); 38 | ps.append_str(TEXT2); 39 | assert_eq!(ps.screen.formatted_lines, vec![format!("{TEXT1}{TEXT2}")]); 40 | assert_eq!(ps.screen.orig_text, TEXT1.to_string() + TEXT2); 41 | } 42 | 43 | #[test] 44 | fn append_sequential_lines() { 45 | const TEXT1: &str = "This is a line."; 46 | const TEXT2: &str = " This is a follow up line"; 47 | let mut ps = PagerState::new().unwrap(); 48 | ps.append_str(&(TEXT1.to_string() + "\n")); 49 | ps.append_str(&(TEXT2.to_string() + "\n")); 50 | 51 | assert_eq!( 52 | ps.screen.formatted_lines, 53 | vec![TEXT1.to_string(), TEXT2.to_string()] 54 | ); 55 | } 56 | 57 | #[test] 58 | fn crlf_write() { 59 | const LINES: [&str; 4] = [ 60 | "hello,\n", 61 | "this is ", 62 | "a test\r\n", 63 | "of weird line endings", 64 | ]; 65 | 66 | let mut ps = PagerState::new().unwrap(); 67 | 68 | for line in LINES { 69 | ps.append_str(line); 70 | } 71 | 72 | assert_eq!( 73 | ps.screen.formatted_lines, 74 | vec![ 75 | "hello,".to_string(), 76 | "this is a test".to_string(), 77 | "of weird line endings".to_string() 78 | ] 79 | ); 80 | } 81 | 82 | #[test] 83 | fn unusual_whitespace() { 84 | const LINES: [&str; 4] = [ 85 | "This line has trailing whitespace ", 86 | " This has leading whitespace\n", 87 | " This has whitespace on both sides ", 88 | "Andthishasnone", 89 | ]; 90 | 91 | let mut ps = PagerState::new().unwrap(); 92 | 93 | for line in LINES { 94 | ps.append_str(line); 95 | } 96 | 97 | assert_eq!( 98 | ps.screen.formatted_lines, 99 | vec![ 100 | "This line has trailing whitespace This has leading whitespace", 101 | " This has whitespace on both sides Andthishasnone" 102 | ] 103 | ); 104 | } 105 | 106 | #[test] 107 | fn appendstr_with_newlines() { 108 | const LINES: [&str; 3] = [ 109 | "this is a normal line with no newline", 110 | "this is an appended line with a newline\n", 111 | "and this is a third line", 112 | ]; 113 | 114 | let mut ps = PagerState::new().unwrap(); 115 | // For the purpose of testing wrapping while appending strs 116 | ps.cols = 15; 117 | 118 | for line in LINES { 119 | ps.append_str(line); 120 | } 121 | 122 | assert_eq!( 123 | ps.screen.formatted_lines, 124 | vec![ 125 | "this is a", 126 | "normal line", 127 | "with no", 128 | "newlinethis is", 129 | "an appended", 130 | "line with a", 131 | "newline", 132 | "and this is a", 133 | "third line" 134 | ] 135 | ); 136 | } 137 | 138 | #[test] 139 | fn incremental_append() { 140 | const LINES: [&str; 4] = [ 141 | "this is a line", 142 | " and this is another", 143 | " and this is yet another\n", 144 | "and this should be on a newline", 145 | ]; 146 | 147 | let mut ps = PagerState::new().unwrap(); 148 | 149 | ps.append_str(LINES[0]); 150 | 151 | assert_eq!(ps.screen.orig_text, LINES[0].to_owned()); 152 | assert_eq!(ps.screen.formatted_lines, vec![LINES[0].to_owned()]); 153 | 154 | ps.append_str(LINES[1]); 155 | 156 | let line = LINES[..2].join(""); 157 | assert_eq!(ps.screen.orig_text, line); 158 | assert_eq!(ps.screen.formatted_lines, vec![line]); 159 | 160 | ps.append_str(LINES[2]); 161 | 162 | let mut line = LINES[..3].join(""); 163 | assert_eq!(ps.screen.orig_text, line); 164 | 165 | line.pop(); 166 | assert_eq!(ps.screen.formatted_lines, vec![line]); 167 | 168 | ps.append_str(LINES[3]); 169 | 170 | let joined = LINES.join(""); 171 | assert_eq!(ps.screen.orig_text, joined); 172 | assert_eq!( 173 | ps.screen.formatted_lines, 174 | joined 175 | .lines() 176 | .map(ToString::to_string) 177 | .collect::>() 178 | ); 179 | } 180 | 181 | #[test] 182 | fn multiple_newlines() { 183 | const TEST: &str = "This\n\n\nhas many\n newlines\n"; 184 | 185 | let mut ps = PagerState::new().unwrap(); 186 | 187 | ps.append_str(TEST); 188 | 189 | assert_eq!(ps.screen.orig_text, TEST.to_owned()); 190 | assert_eq!( 191 | ps.screen.formatted_lines, 192 | TEST.lines() 193 | .map(ToString::to_string) 194 | .collect::>() 195 | ); 196 | 197 | ps.screen.orig_text = TEST.to_string(); 198 | ps.format_lines(); 199 | 200 | assert_eq!(ps.screen.orig_text, TEST.to_owned()); 201 | assert_eq!( 202 | ps.screen.formatted_lines, 203 | TEST.lines() 204 | .map(ToString::to_string) 205 | .collect::>() 206 | ); 207 | } 208 | 209 | #[test] 210 | fn append_floating_newline() { 211 | const TEST: &str = "This is a line with a bunch of\nin between\nbut not at the end"; 212 | let mut ps = PagerState::new().unwrap(); 213 | ps.append_str(TEST); 214 | assert_eq!( 215 | ps.screen.formatted_lines, 216 | vec![ 217 | "This is a line with a bunch of".to_string(), 218 | "in between".to_string(), 219 | "but not at the end".to_owned() 220 | ] 221 | ); 222 | assert_eq!(ps.screen.orig_text, TEST.to_string()); 223 | } 224 | } 225 | 226 | // Test exit callbacks function 227 | #[cfg(feature = "dynamic_output")] 228 | #[test] 229 | fn exit_callback() { 230 | use crate::PagerState; 231 | use std::sync::atomic::Ordering; 232 | use std::sync::{atomic::AtomicBool, Arc}; 233 | 234 | let mut ps = PagerState::new().unwrap(); 235 | let exited = Arc::new(AtomicBool::new(false)); 236 | let exited_within_callback = exited.clone(); 237 | ps.exit_callbacks.push(Box::new(move || { 238 | exited_within_callback.store(true, Ordering::Relaxed); 239 | })); 240 | ps.exit(); 241 | 242 | assert!(exited.load(Ordering::Relaxed)); 243 | } 244 | 245 | mod emit_events { 246 | // Check functions emit correct events on function calls 247 | use crate::{minus_core::commands::Command, ExitStrategy, LineNumbers, Pager}; 248 | 249 | const TEST_STR: &str = "This is sample text"; 250 | #[test] 251 | fn set_text() { 252 | let pager = Pager::new(); 253 | pager.set_text(TEST_STR).unwrap(); 254 | assert_eq!( 255 | Command::SetData(TEST_STR.to_string()), 256 | pager.rx.try_recv().unwrap() 257 | ); 258 | } 259 | 260 | #[test] 261 | fn push_str() { 262 | let pager = Pager::new(); 263 | pager.push_str(TEST_STR).unwrap(); 264 | assert_eq!( 265 | Command::AppendData(TEST_STR.to_string()), 266 | pager.rx.try_recv().unwrap() 267 | ); 268 | } 269 | 270 | #[test] 271 | fn set_prompt() { 272 | let pager = Pager::new(); 273 | pager.set_prompt(TEST_STR).unwrap(); 274 | assert_eq!( 275 | Command::SetPrompt(TEST_STR.to_string()), 276 | pager.rx.try_recv().unwrap() 277 | ); 278 | } 279 | 280 | #[test] 281 | fn send_message() { 282 | let pager = Pager::new(); 283 | pager.send_message(TEST_STR).unwrap(); 284 | assert_eq!( 285 | Command::SendMessage(TEST_STR.to_string()), 286 | pager.rx.try_recv().unwrap() 287 | ); 288 | } 289 | 290 | #[test] 291 | #[cfg(feature = "static_output")] 292 | fn set_run_no_overflow() { 293 | let pager = Pager::new(); 294 | pager.set_run_no_overflow(false).unwrap(); 295 | assert_eq!( 296 | Command::SetRunNoOverflow(false), 297 | pager.rx.try_recv().unwrap() 298 | ); 299 | } 300 | 301 | #[test] 302 | fn set_line_numbers() { 303 | let pager = Pager::new(); 304 | pager.set_line_numbers(LineNumbers::Enabled).unwrap(); 305 | assert_eq!( 306 | Command::SetLineNumbers(LineNumbers::Enabled), 307 | pager.rx.try_recv().unwrap() 308 | ); 309 | } 310 | 311 | #[test] 312 | fn set_exit_strategy() { 313 | let pager = Pager::new(); 314 | pager.set_exit_strategy(ExitStrategy::PagerQuit).unwrap(); 315 | assert_eq!( 316 | Command::SetExitStrategy(ExitStrategy::PagerQuit), 317 | pager.rx.try_recv().unwrap() 318 | ); 319 | } 320 | 321 | #[test] 322 | fn add_exit_callback() { 323 | let func = Box::new(|| println!("Hello")); 324 | let pager = Pager::new(); 325 | pager.add_exit_callback(func.clone()).unwrap(); 326 | 327 | assert_eq!(Command::AddExitCallback(func), pager.rx.try_recv().unwrap()); 328 | } 329 | } 330 | --------------------------------------------------------------------------------