├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ ├── new_app_component.md │ └── question.md ├── PULL_REQUEST_TEMPLATE.md ├── actions-rs │ └── grcov.yml └── workflows │ ├── coverage.yml │ ├── crossterm-windows.yml │ ├── ratatui_crossterm.yml │ └── ratatui_termion.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── README.md ├── docs ├── drawio │ └── lifecycle.drawio ├── en │ ├── advanced.md │ ├── get-started.md │ └── migrating-legacy.md └── images │ ├── cargo │ ├── tui-realm-128.png │ ├── tui-realm-512.png │ ├── tui-realm-64.png │ └── tui-realm-96.png │ ├── demo.gif │ ├── lifecycle.png │ └── tui-realm.svg ├── examples ├── async_ports.rs ├── demo │ ├── app │ │ ├── mod.rs │ │ └── model.rs │ ├── components │ │ ├── clock.rs │ │ ├── counter.rs │ │ ├── label.rs │ │ └── mod.rs │ └── demo.rs └── user_events │ ├── components │ ├── label.rs │ └── mod.rs │ ├── model.rs │ └── user_events.rs ├── rustfmt.toml └── src ├── core ├── application.rs ├── command.rs ├── component.rs ├── event.rs ├── injector.rs ├── mod.rs ├── props │ ├── borders.rs │ ├── dataset.rs │ ├── direction.rs │ ├── input_type.rs │ ├── layout.rs │ ├── mod.rs │ ├── shape.rs │ ├── texts.rs │ └── value.rs ├── state.rs ├── subscription.rs └── view.rs ├── lib.rs ├── listener ├── async_ticker.rs ├── builder.rs ├── mod.rs ├── port.rs ├── port │ ├── async_p.rs │ └── sync.rs ├── task_pool.rs └── worker.rs ├── macros.rs ├── mock ├── components.rs └── mod.rs ├── ratatui.rs ├── terminal.rs ├── terminal ├── adapter.rs ├── adapter │ ├── crossterm.rs │ └── termion.rs ├── event_listener.rs └── event_listener │ ├── crossterm.rs │ ├── crossterm_async.rs │ └── termion.rs └── utils ├── mod.rs ├── parser.rs └── types.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: veeso 2 | ko_fi: veeso 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report of the bug you've encountered 4 | title: "[BUG] - ISSUE_TITLE" 5 | labels: bug 6 | assignees: veeso 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ## Steps to reproduce 15 | 16 | Steps to reproduce the bug you encountered 17 | 18 | ## Expected behaviour 19 | 20 | A clear and concise description of what you expected to happen. 21 | 22 | ## Environment 23 | 24 | - OS: [e.g. GNU/Linux Debian 10] 25 | - Architecture [Arm, x86_64, ...] 26 | - Rust version 27 | - tui-realm version 28 | 29 | ## Additional information 30 | 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea to improve tui-realm 4 | title: "[Feature Request] - FEATURE_TITLE" 5 | labels: "new feature" 6 | assignees: veeso 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | Put here a brief introduction to your suggestion. 13 | 14 | ### Changes 15 | 16 | The following changes to the application are expected 17 | 18 | - ... 19 | 20 | ## Implementation 21 | 22 | Provide any kind of suggestion you propose on how to implement the feature. 23 | If you have none, delete this section. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new_app_component.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New app/component 3 | about: Report a component or an application you made with realm you'd like to see in the project readme 4 | title: "[New App/Component] - CRATE_NAME" 5 | labels: "documentation" 6 | assignees: veeso 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | Put here the following information: 13 | 14 | - a brief description of your component/application 15 | - link to repo and crates on cargo.io 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask what you want about the project 4 | title: "[QUESTION] - TITLE" 5 | labels: question 6 | assignees: veeso 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # ISSUE _NUMBER_ - PULL_REQUEST_TITLE 2 | 3 | Fixes # (issue) 4 | 5 | ## Description 6 | 7 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 8 | 9 | List here your changes 10 | 11 | - I made this... 12 | - I made also that... 13 | 14 | ## Type of change 15 | 16 | Please select relevant options. 17 | 18 | - [ ] Bug fix (non-breaking change which fixes an issue) 19 | - [ ] New feature (non-breaking change which adds functionality) 20 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 21 | - [ ] This change requires a documentation update 22 | 23 | ## Checklist 24 | 25 | - [ ] My code follows the contribution guidelines of this project 26 | - [ ] I have performed a self-review of my own code 27 | - [ ] I have commented my code, particularly in hard-to-understand areas 28 | - [ ] My changes generate no new warnings 29 | - [ ] I formatted the code with `cargo fmt` 30 | - [ ] I checked my code using `cargo clippy` and reports no warnings 31 | - [ ] I have added tests that prove my fix is effective or that my feature works 32 | - [ ] I have introduced no new *C-bindings* 33 | - [ ] The changes I've made are Windows, MacOS, UNIX, Linux compatible (or I've handled them using `cfg target_os`) 34 | - [ ] I increased or maintained the code coverage for the project, compared to the previous commit 35 | -------------------------------------------------------------------------------- /.github/actions-rs/grcov.yml: -------------------------------------------------------------------------------- 1 | branch: false 2 | ignore-not-existing: true 3 | llvm: true 4 | output-type: lcov 5 | ignore: 6 | - "/*" 7 | - "C:/*" 8 | - "../*" 9 | - "src/adapter/*" 10 | - "src/mock/*" 11 | - src/terminal.rs 12 | - src/core/state.rs 13 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | coverage: 10 | name: Generate coverage 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v2 15 | - name: Setup rust toolchain 16 | uses: dtolnay/rust-toolchain@stable 17 | with: 18 | toolchain: stable 19 | - uses: taiki-e/install-action@v2 20 | with: 21 | tool: cargo-llvm-cov 22 | - name: Run tests 23 | run: cargo llvm-cov --no-fail-fast --no-default-features --features derive,serialize,crossterm,async-ports --workspace --lcov --output-path lcov.info 24 | - name: Coveralls 25 | uses: coverallsapp/github-action@v2.3.6 26 | with: 27 | github-token: ${{ secrets.GITHUB_TOKEN }} 28 | file: lcov.info 29 | # currently we only run one coverage report per build 30 | parallel: false 31 | -------------------------------------------------------------------------------- /.github/workflows/crossterm-windows.yml: -------------------------------------------------------------------------------- 1 | name: Crossterm (Windows) 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | build: 10 | runs-on: windows-2019 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: dtolnay/rust-toolchain@stable 15 | with: 16 | toolchain: stable 17 | components: rustfmt, clippy 18 | - name: Test 19 | run: cargo test --no-fail-fast --lib --no-default-features --features derive,serialize,crossterm,async-ports 20 | - name: Examples 21 | run: cargo build --all-targets --examples 22 | - name: Format 23 | run: cargo fmt --all -- --check 24 | - name: Clippy 25 | run: cargo clippy --lib --no-default-features --features derive,serialize,crossterm,async-ports -- -Dwarnings 26 | -------------------------------------------------------------------------------- /.github/workflows/ratatui_crossterm.yml: -------------------------------------------------------------------------------- 1 | name: Ratatui-Crossterm 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: dtolnay/rust-toolchain@stable 15 | with: 16 | toolchain: stable 17 | components: rustfmt, clippy 18 | - name: Test 19 | run: cargo test --no-fail-fast --lib --features derive,serialize,crossterm,async-ports --no-default-features 20 | - name: Examples 21 | run: cargo build --all-targets --examples 22 | - name: Format 23 | run: cargo fmt --all -- --check 24 | - name: Clippy 25 | run: cargo clippy --lib --features derive,serialize,crossterm,async-ports --no-default-features -- -Dwarnings 26 | -------------------------------------------------------------------------------- /.github/workflows/ratatui_termion.yml: -------------------------------------------------------------------------------- 1 | name: Ratatui-Termion 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: dtolnay/rust-toolchain@stable 15 | with: 16 | toolchain: stable 17 | components: rustfmt, clippy 18 | - name: Test 19 | run: cargo test --no-fail-fast --lib --no-default-features --features derive,serialize,termion,async-ports 20 | - name: Examples 21 | run: cargo build --all-targets --no-default-features --features derive,serialize,termion --examples 22 | - name: Format 23 | run: cargo fmt --all -- --check 24 | - name: Clippy 25 | run: cargo clippy --lib --no-default-features --features derive,serialize,termion,async-ports -- -Dwarnings 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | 3 | # Created by https://www.gitignore.io/api/rust 4 | # Edit at https://www.gitignore.io/?templates=rust 5 | 6 | ### Rust ### 7 | # Generated by Cargo 8 | # will have compiled files and executables 9 | /target/ 10 | 11 | # These are backup files generated by rustfmt 12 | **/*.rs.bk 13 | 14 | Cargo.lock 15 | 16 | # End of https://www.gitignore.io/api/rust 17 | 18 | # Macos 19 | .DS_Store 20 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email 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 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | - [Contributing](#contributing) 4 | - [Open an issue](#open-an-issue) 5 | - [Questions](#questions) 6 | - [Bug reports](#bug-reports) 7 | - [Feature requests](#feature-requests) 8 | - [Preferred contributions](#preferred-contributions) 9 | - [Pull Request Process](#pull-request-process) 10 | - [Software guidelines](#software-guidelines) 11 | 12 | --- 13 | 14 | ## Open an issue 15 | 16 | Open an issue when: 17 | 18 | - You have questions or concerns regarding the project or the application itself. 19 | - You have a bug to report. 20 | - You have a feature or a suggestion to improve tui-realm to submit. 21 | 22 | ### Questions 23 | 24 | If you have a question open an issue using the `Question` template. 25 | By default your question should already be labeled with the `question` label, if you need help with your installation, please also add the `help wanted` label. 26 | Check the issue is always assigned to `veeso`. 27 | 28 | ### Bug reports 29 | 30 | If you want to report an issue or a bug you've encountered while using tui-realm, open an issue using the `Bug report` template. 31 | The `Bug` label should already be set and the issue should already be assigned to `veeso`. 32 | Don't set other labels to your issue, not even priority. 33 | 34 | When you open a bug try to be the most precise as possible in describing your issue. I'm not saying you should always be that precise, since sometimes it's very easy for maintainers to understand what you're talking about. Just try to be reasonable to understand sometimes we might not know what you're talking about or we just don't have the technical knowledge you might think. 35 | Please always provide the environment you're working on and consider I only offer support for the latest release of the library. 36 | Last but not least: the template I've written must be used. Full stop. 37 | 38 | Maintainers will may add additional labels to your issue: 39 | 40 | - **duplicate**: the issue is duplicated; the reference to the related issue will be added to your description. Your issue will be closed. 41 | - **priority**: this must be fixed asap 42 | - **sorcery**: it is not possible to find out what's causing your bug, nor is reproducible on our test environments. 43 | - **wontfix**: your bug has a very high ratio between the probability to encounter it and the difficult to fix it, or it just isn't a bug, but a feature. 44 | 45 | ### Feature requests 46 | 47 | Whenever you have a good idea which chould improve the project, it is a good idea to submit it to the project owner. 48 | The first thing you should do though, is not starting to write the code, but is to become concern about how tui-realm works, what kind 49 | of contribution I appreciate and what kind of contribution I won't consider. 50 | Said so, follow these steps: 51 | 52 | - Read the contributing guidelines, entirely 53 | - Think on whether your idea would fit in the project mission and guidelines or not 54 | - Think about the impact your idea would have on the project 55 | - Open an issue using the `feature request` template describing with accuracy your suggestion 56 | - Wait for the maintainer feedback on your idea 57 | 58 | If you want to implement the feature by yourself and your suggestion gets approved, start writing the code. Remember that on [docs.rs](https://docs.rs/tuirealm) there is the documentation for the project. Open a PR related to your issue. See [Pull request process for more details](#pull-request-process) 59 | 60 | It is very important to follow these steps, since it will prevent you from working on a feature that will be rejected and trust me, none of us wants to deal with this situation. 61 | 62 | Always mind that your suggestion, may be rejected: I'll always provide a feedback on the reasons that brought me to reject your feature, just try not to get mad about that. 63 | 64 | --- 65 | 66 | ## Preferred contributions 67 | 68 | At the moment, these kind of contributions are more appreciated and should be preferred: 69 | 70 | - Fix for issues described in [Known Issues](./README.md#known-issues-) or [issues reported by the community](https://github.com/veeso/tui-realm/issues) 71 | - Code optimizations: any optimization to the code is welcome 72 | 73 | For any other kind of contribution, especially for new features, please submit a new issue first. 74 | 75 | ## Pull Request Process 76 | 77 | Let's make it simple and clear: 78 | 79 | 1. Open a PR with an **appropriate label** (e.g. bug, enhancement, ...). 80 | 2. Write a **properly documentation** for your software compliant with **rustdoc** standard. 81 | 3. Write tests for your code 82 | 4. Check your code with `cargo clippy`. 83 | 5. Check if the CI for your commits reports three-green. 84 | 6. Report changes to the PR you opened, writing a report of what you changed and what you have introduced. 85 | 7. Update the `CHANGELOG.md` file with details of changes to the application. In changelog report changes under a chapter called `PR{PULL_REQUEST_NUMBER}` (e.g. PR12). 86 | 8. Assign a maintainer to the reviewers. 87 | 9. Request maintainers to merge your changes. 88 | 89 | ### Software guidelines 90 | 91 | In addition to the process described for the PRs, I've also decided to introduce a list of guidelines to follow when writing the code, that should be followed: 92 | 93 | 1. **Let's stop the NPM apocalypse**: personally I'm against the abuse of dependencies we make in software projects and I think that NodeJS has opened the way to this drama (and has already gone too far). Nowadays nobody cares about adding hundreds of dependencies to their projects. Don't misunderstand me: I think that package managers are cool, but I'm totally against the abuse we're making of them. I think when we work on a project, we should try to use the minor quantity of dependencies as possible, especially because it's not hard to see how many libraries are getting abandoned right now, causing compatibility issues after a while. So please, when working on tui-realm, try not to add useless dependencies. 94 | 2. **No C-bindings**: personally I think that Rust still relies too much on C. And that's bad, really bad. Many libraries in Rust are just wrappers to C libraries, which is a huge problem, especially considering this is a multiplatform project. Everytime you add a C-binding to your project, you're forcing your users to install additional libraries to their systems. Sometimes these libraries are already installed on their systems (as happens for libssh2 or openssl in this case), but sometimes not. So if you really have to add a dependency to this project, please AVOID completely adding C-bounded libraries. 95 | 3. **Test units matter**: Whenever you implement something new to this project, always implement test units which cover the most cases as possible. 96 | 4. **Comments are useful**: Many people say that the code should be that simple to talk by itself about what it does, and comments should then be useless. I personally don't agree. I'm not saying they're wrong, but I'm just saying that this approach has, in my personal opinion, many aspects which are underrated: 97 | 1. What's obvious for me, might not be for the others. 98 | 2. Our capacity to work on a code depends mostly on **time and experience**, not on complexity: I'm not denying complexity matter, but the most decisive factor when working on code is the experience we've acquired working on it and the time we've spent. As the author of the project, I know the project like the back of my hands, but if I didn't work on it for a year, then I would probably have some problems in working on it again as the same speed as before. And do you know what's really time-saving in these cases? Comments. 99 | 100 | --- 101 | 102 | Thank you for any contribution! 103 | Christian `veeso` Visintin 104 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tuirealm" 3 | version = "3.0.0" 4 | authors = ["Christian Visintin "] 5 | edition = "2024" 6 | rust-version = "1.85.1" 7 | categories = ["command-line-utilities"] 8 | description = "A tui-rs framework to build tui interfaces, inspired by React and Elm." 9 | documentation = "https://docs.rs/tuirealm" 10 | homepage = "https://github.com/veeso/tui-realm" 11 | include = ["examples/**/*", "src/**/*", "LICENSE", "README.md", "CHANGELOG.md"] 12 | keywords = ["tui", "terminal"] 13 | license = "MIT" 14 | readme = "README.md" 15 | repository = "https://github.com/veeso/tui-realm" 16 | 17 | [dependencies] 18 | async-trait = { version = "0.1", optional = true } 19 | bitflags = "2" 20 | crossterm = { version = "0.29", optional = true } 21 | lazy-regex = "3" 22 | ratatui = { version = "0.29", default-features = false } 23 | serde = { version = "1", features = ["derive"], optional = true } 24 | termion = { version = "^4", optional = true } 25 | thiserror = "2" 26 | tokio = { version = "1", features = [ 27 | "rt", 28 | "macros", 29 | "time", 30 | ], default-features = false, optional = true } 31 | tokio-util = { version = "0.7", features = [ 32 | "rt", 33 | ], default-features = false, optional = true } 34 | futures-util = { version = "0.3", default-features = false, optional = true } 35 | tuirealm_derive = { version = "2", optional = true } 36 | 37 | [dev-dependencies] 38 | pretty_assertions = "^1" 39 | toml = "^0.8" 40 | tempfile = "^3" 41 | tokio = { version = "1", features = ["full"] } 42 | 43 | [features] 44 | default = ["derive", "crossterm"] 45 | async-ports = [ 46 | "dep:async-trait", 47 | "dep:tokio", 48 | "dep:tokio-util", 49 | "crossterm?/event-stream", 50 | "dep:futures-util", 51 | ] 52 | derive = ["dep:tuirealm_derive"] 53 | serialize = ["dep:serde", "bitflags/serde"] 54 | crossterm = ["dep:crossterm", "ratatui/crossterm"] 55 | termion = ["dep:termion", "ratatui/termion"] 56 | 57 | [[example]] 58 | name = "async-ports" 59 | path = "examples/async_ports.rs" 60 | required-features = ["async-ports", "crossterm"] 61 | 62 | [[example]] 63 | name = "demo" 64 | path = "examples/demo/demo.rs" 65 | required-features = ["crossterm"] 66 | 67 | [[example]] 68 | name = "user-events" 69 | path = "examples/user_events/user_events.rs" 70 | required-features = ["crossterm"] 71 | 72 | [package.metadata.docs.rs] 73 | all-features = true 74 | rustdoc-args = ["--cfg", "docsrs"] 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Christian Visintin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tui-realm 2 | 3 |

4 | logo 5 |

6 | 7 |

~ A ratatui framework inspired by Elm and React ~

8 |

9 | Get started 10 | · 11 | Standard Library 12 | · 13 | Documentation 14 |

15 | 16 |

Developed by @veeso

17 |

Current version: 3.0.0 (21/05/2025)

18 | 19 |

20 | License-MIT 25 | Repo stars 30 | Downloads counter 35 | Latest version 40 | 41 | Ko-fi 45 |

46 |

47 | Ratatui-Crossterm CI 52 | Ratatui-Termion CI 57 | Crossterm CI (Windows) 62 | Coveralls 67 | Docs 72 |

73 | 74 | --- 75 | 76 | - [tui-realm](#tui-realm) 77 | - [About tui-realm 👑](#about-tui-realm-) 78 | - [Features 🎁](#features-) 79 | - [Get started 🏁](#get-started-) 80 | - [Add tui-realm to your Cargo.toml 🦀](#add-tui-realm-to-your-cargotoml-) 81 | - [Enabling other backends ⚠️](#enabling-other-backends-️) 82 | - [Create a tui-realm application 🪂](#create-a-tui-realm-application-) 83 | - [Run examples 🔍](#run-examples-) 84 | - [Standard components library 🎨](#standard-components-library-) 85 | - [Community components 🏘️](#community-components-️) 86 | - [Guides 🎓](#guides-) 87 | - [Documentation 📚](#documentation-) 88 | - [Apps using tui-realm 🚀](#apps-using-tui-realm-) 89 | - [Support the developer ☕](#support-the-developer-) 90 | - [Contributing and issues 🤝🏻](#contributing-and-issues-) 91 | - [Changelog ⏳](#changelog-) 92 | - [License 📃](#license-) 93 | 94 | --- 95 | 96 | ## About tui-realm 👑 97 | 98 | tui-realm is a **framework** for **[ratatui](https://github.com/ratatui-org/ratatui)** to simplify the implementation of terminal user interfaces adding the possibility to work with re-usable components with properties and states, as you'd do in React. But that's not all: the components communicate with the ui engine via a system based on **Messages** and **Events**, providing you with the possibility to implement `update` routines as happens in Elm. In addition, the components are organized inside the **View**, which manages mounting/umounting, focus and event forwarding for you. 99 | 100 | And that also explains the reason of the name: **Realm stands for React and Elm**. 101 | 102 | tui-realm also comes with a standard library of components, which can be added to your dependencies, that you may find very useful. Don't worry, they are optional if you don't want to use them 😉, just follow the guide in [get started](#get-started-). 103 | 104 | ![Demo](/docs/images/demo.gif) 105 | 106 | See tui-realm in action in the [Example](#run-examples-) or if you want to read more about tui-realm start reading the official guide [HERE](docs/en/get-started.md). 107 | 108 | ## Features 🎁 109 | 110 | - ⌨️ **Event-driven** 111 | - ⚛️ Based on **React** and **Elm** 112 | - 🍲 **Boilerplate** code 113 | - 🚀 Quick-setup 114 | - 🎯 Single **focus** and **states** management 115 | - 🙂 Easy to learn 116 | - 🤖 Adaptable to any use case 117 | 118 | --- 119 | 120 | ## Get started 🏁 121 | 122 | > ⚠️ Warning: currently tui-realm supports these backends: crossterm, termion 123 | 124 | ### Add tui-realm to your Cargo.toml 🦀 125 | 126 | If you want the default features, just add tuirealm 1.x version: 127 | 128 | ```toml 129 | tuirealm = "3" 130 | ``` 131 | 132 | otherwise you can specify the features you want to add: 133 | 134 | ```toml 135 | tuirealm = { version = "3", default-features = false, features = [ "derive", "serialize", "termion" ] } 136 | ``` 137 | 138 | Supported features are: 139 | 140 | - `derive` (*default*): add the `#[derive(MockComponent)]` proc macro to automatically implement `MockComponent` for `Component`. [Read more](https://github.com/veeso/tuirealm_derive). 141 | - `serialize`: add the serialize/deserialize trait implementation for `KeyEvent` and `Key`. 142 | - `crossterm`: use the [crossterm](https://github.com/crossterm-rs/crossterm) terminal backend 143 | - `termion`: use the [termion](https://github.com/redox-os/termion) terminal backend 144 | 145 | #### Enabling other backends ⚠️ 146 | 147 | This library supports two backends: `crossterm` and `termion`, and two high 148 | level terminal TUI libraries: `tui` and `ratatui`. Whenever you explicitly 149 | declare any of the TUI library or backend feature sets you should disable the 150 | crate's default features. 151 | 152 | > ❗ The two features can co-exist, even if it doesn't make too much sense. 153 | 154 | Example using crossterm: 155 | 156 | ```toml 157 | tuirealm = { version = "3", default-features = false, features = [ "derive", "crossterm" ]} 158 | ``` 159 | 160 | Example using the termion backend: 161 | 162 | ```toml 163 | tuirealm = { version = "3", default-features = false, features = [ "derive", "termion" ] } 164 | ``` 165 | 166 | ### Create a tui-realm application 🪂 167 | 168 | View how to implement a tui-realm application in the [related guide](/docs/en/get-started.md). 169 | 170 | ### Run examples 🔍 171 | 172 | Still confused about how tui-realm works? Don't worry, try with the examples: 173 | 174 | - [demo](/examples/demo/demo.rs): a simple application which shows how tui-realm works 175 | 176 | ```sh 177 | cargo run --example demo 178 | ``` 179 | 180 | --- 181 | 182 | ## Standard components library 🎨 183 | 184 | Tui-realm comes with an optional standard library of components I thought would have been useful for most of the applications. 185 | If you want to use it, just add the [tui-realm-stdlib](https://github.com/veeso/tui-realm-stdlib) to your `Cargo.toml` dependencies. 186 | 187 | ## Community components 🏘️ 188 | 189 | These components are not included in tui-realm, but have been developed by other users. I like advertising other's contents, so here you can find a list of components you may find useful for your next tui-realm project 💜. 190 | 191 | - [tui-realm-textarea](https://github.com/veeso/tui-realm-textarea) A textarea/editor component developed by [@veeso](https://github.com/veeso) 192 | - [tui-realm-treeview](https://github.com/veeso/tui-realm-treeview) A treeview component developed by [@veeso](https://github.com/veeso) 193 | 194 | Want to add yours? Open an issue using the `New app/component` template 😄 195 | 196 | --- 197 | 198 | ## Guides 🎓 199 | 200 | - [Get Started Guide](/docs/en/get-started.md) 201 | - [Advanced concepts](/docs/en/advanced.md) 202 | 203 | --- 204 | 205 | ## Documentation 📚 206 | 207 | The developer documentation can be found on Rust Docs at 208 | 209 | --- 210 | 211 | ## Apps using tui-realm 🚀 212 | 213 | - [BugStalker](https://github.com/godzie44/BugStalker) 214 | - [cliflux](https://github.com/spencerwi/cliflux) 215 | - [csvs](https://github.com/koma-private/csvs) 216 | - [donmaze](https://github.com/veeso/donmaze) 217 | - [matrix-rust-sdk](https://github.com/matrix-org/matrix-rust-sdk) 218 | - [paat](https://github.com/ebakoba/paat) 219 | - [termusic](https://github.com/tramhao/termusic) 220 | - [termscp](https://github.com/veeso/termscp) 221 | - [tisq](https://crates.io/crates/tisq) 222 | - [todotui](https://github.com/newfla/todotui) 223 | - [tuifeed](https://github.com/veeso/tuifeed) 224 | - [turdle](https://crates.io/crates/turdle) 225 | 226 | Want to add yours? Open an issue using the `New app/component` template 😄 227 | 228 | --- 229 | 230 | ## Support the developer ☕ 231 | 232 | If you like tui-realm and you're grateful for the work I've done, please consider a little donation 🥳 233 | 234 | You can make a donation with one of these platforms: 235 | 236 | [![ko-fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/veeso) 237 | [![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://www.paypal.me/chrisintin) 238 | 239 | --- 240 | 241 | ## Contributing and issues 🤝🏻 242 | 243 | Contributions, bug reports, new features and questions are welcome! 😉 244 | If you have any question or concern, or you want to suggest a new feature, or you want just want to improve tui-realm, feel free to open an issue or a PR. 245 | 246 | Please follow [our contributing guidelines](CONTRIBUTING.md) 247 | 248 | --- 249 | 250 | ## Changelog ⏳ 251 | 252 | View tui-realm's changelog [HERE](CHANGELOG.md) 253 | 254 | --- 255 | 256 | ## License 📃 257 | 258 | tui-realm is licensed under the MIT license. 259 | 260 | You can read the entire license [HERE](LICENSE) 261 | -------------------------------------------------------------------------------- /docs/drawio/lifecycle.drawio: -------------------------------------------------------------------------------- 1 | 7Vtfc6M2EP80nmkf7gYk/vkxcXLXh2aaSdpr71EBnU2CEQNybOfTVxjJIMnGYBtD7vJktFoJtLu/3dVKHsHJfPU1RcnsjgQ4GgEjWI3gzQgAE4w99pNT1gXFdeyCME3DgDOVhMfwDXOiwamLMMCZxEgJiWiYyESfxDH2qURDaUqWMtsPEslvTdAUa4RHH0U69d8woLOC6gG3pP+Bw+lMvNl0xkXPHAlmvpJshgKyrJDg7QhOUkJo8TRfTXCUC0/IpRj3ZU/v9sNSHNMmA2Jio+CBrJ6fHz69GSZY+V76iSsjo2uxYByw9fMmSemMTEmMotuSep2SRRzgfFaDtUqePwlJGNFkxGdM6ZorEy0oYaQZnUe8F69C+l8+/DOwefN7petmxafeNNaiEdN0XR2Vt79XO8txm5YY+IPElH+K6eTtMIomJCLpZr0Q26Zl5HwZTckLrvRcA8Oo9Aj1Q0YppJaLaq8yOCkji9THNRoQRo3SKaY1fNbWZBjWMJljtkY2LsURouGr/B2IG/10y1faBXvgptHCTKDXr524VTsxmtqJK9mJeWE7sfqxEwj7NBQ+7yuKFvxNEzJPSJwvV7Wg0j5y1SxnIcWPCdoIYcmCiWwLOgZVffE345TiVb20deHwAbbH/SEPVpbDffeydP2my2mzitsX484uT/cDdz3jzmqIuz2WdRnYWRrs7oj/8p6h54C+oeformweaJJkK6ayuFAUTmP27LOVY2aq17lcQpZRXvGOeRgEBVBxFr6hp81UuR0nJIzpZhn29ci+yedi2My4oDUIxEy3Cl4EqQP9CFe4VtoV/dg71CPUeHb1eLvU84CzRaSb+6+iJBfIILJdXUnjSypprCnpnsWj7B34I0s2d6tvbwSdIzKBk2L/UVuzk3ZmumL9Rfq6NYszBnVRXTgc1e0+w7r4zAp+rpIkYn6KhiR+ByiCCoxA06hu5pROZLotx/QCpMY59Ekp9AWBBJoC6dRt6WboVZqidYWBB+Ny5vucUNqfaSpu3FFqZAf4oekpBld8QWl+26WcgHKgoZzXUQePb2e3ePsLk4WZSbL8FuLlb79rwsxmKMkfk5T4OMsOC/QJ+S/TjQr+WtAojEW+txt8lX2qYRyoI56mAveznPTtzFVsW1eCux15fjWML+xkqy5263B3O9kenaXd1FmCPpwlFDmm2D1AY9S987M1wP6TBIjinxiyqqAtB+Y1s95Bq1c8HhdP72Gz5ihppt17GNKrE3fZVJPkr1KXsKArp1XjnotHQE8TNOVIUQDHwVV+mpyrJkJZFvqy3uSauyrCw7FPBDIR1NpsuqUtd6X4vm+zMLDzT9g0KBq7beyADQlau9ipBUd7rKS6qm0WC+WjarYYqq+CrjJRIQhtorbRHAgbF+8x6rc+EBqn8fNtTKfZAtAPOv4O/ZefOVfwLCW/H0B6D/SkbRju8xLFlmF4Q7DnPshlvKGj1kmO9oZKBQF05A1NSwaROa73bpYD6vi78W7Cow4HVsee6R+zL2+Tf+zLYc6JxKZHBOM+gahW4LbttkCECqK3FzDPDETtROBARVb7rrb85oGKr+PV8XcEdP2a1DDip9kCr0cfVgxs/9H4KND5iLgtgG57lvweqx6I2ne15BcFnn386t0Ihb8joPd8f+/jfjW/Dzv0C3xw97mVZj3Dq72qF/dE3aDHqzJ6kWCI0dWxnXpAssY9TkMmlLwSfNpNm2FhsnHM3XeZvZ+YaxlnirnQ7ijmtkyWHbeWv6OYqF8K7BKeuiG3KSe5RwRQU0JmJZruAecZgSXqowMvpr8/YBlmq2TT9tolpyo/Q/IpQGTN8n+FBXv570x4+z8= -------------------------------------------------------------------------------- /docs/images/cargo/tui-realm-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/veeso/tui-realm/42e57c7bc0a3afa9c8e56e49697e80411e8f0cd2/docs/images/cargo/tui-realm-128.png -------------------------------------------------------------------------------- /docs/images/cargo/tui-realm-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/veeso/tui-realm/42e57c7bc0a3afa9c8e56e49697e80411e8f0cd2/docs/images/cargo/tui-realm-512.png -------------------------------------------------------------------------------- /docs/images/cargo/tui-realm-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/veeso/tui-realm/42e57c7bc0a3afa9c8e56e49697e80411e8f0cd2/docs/images/cargo/tui-realm-64.png -------------------------------------------------------------------------------- /docs/images/cargo/tui-realm-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/veeso/tui-realm/42e57c7bc0a3afa9c8e56e49697e80411e8f0cd2/docs/images/cargo/tui-realm-96.png -------------------------------------------------------------------------------- /docs/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/veeso/tui-realm/42e57c7bc0a3afa9c8e56e49697e80411e8f0cd2/docs/images/demo.gif -------------------------------------------------------------------------------- /docs/images/lifecycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/veeso/tui-realm/42e57c7bc0a3afa9c8e56e49697e80411e8f0cd2/docs/images/lifecycle.png -------------------------------------------------------------------------------- /examples/async_ports.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use std::time::Duration; 3 | 4 | use tempfile::NamedTempFile; 5 | use tokio::io::AsyncWriteExt as _; 6 | use tokio::runtime::Handle; 7 | use tuirealm::command::{Cmd, CmdResult}; 8 | use tuirealm::event::{Key, KeyEvent}; 9 | use tuirealm::listener::{ListenerResult, PollAsync}; 10 | use tuirealm::props::{Alignment, Color, Style, TextModifiers}; 11 | use tuirealm::ratatui::layout::{Constraint, Direction, Layout, Rect}; 12 | use tuirealm::ratatui::widgets::Paragraph; 13 | use tuirealm::terminal::{CrosstermTerminalAdapter, TerminalAdapter, TerminalBridge}; 14 | use tuirealm::{ 15 | Application, AttrValue, Attribute, Component, Event, EventListenerCfg, Frame, MockComponent, 16 | PollStrategy, Props, State, Sub, SubClause, SubEventClause, Update, 17 | }; 18 | 19 | #[tokio::main] 20 | async fn main() -> Result<(), Box> { 21 | let handle = Handle::current(); 22 | 23 | let event_listener = EventListenerCfg::default() 24 | .with_handle(handle) 25 | .async_crossterm_input_listener(Duration::default(), 3) 26 | .add_async_port(Box::new(AsyncPort::new()), Duration::from_millis(1000), 1); 27 | 28 | let mut app: Application = Application::init(event_listener); 29 | 30 | // subscribe component to clause 31 | app.mount( 32 | Id::Label, 33 | Box::new(Label::default()), 34 | vec![Sub::new( 35 | SubEventClause::User(UserEvent::WroteFile(Duration::ZERO)), 36 | SubClause::Always, 37 | )], 38 | )?; 39 | 40 | app.active(&Id::Label).expect("failed to active"); 41 | 42 | let mut model = Model::new(app, CrosstermTerminalAdapter::new()?); 43 | // Main loop 44 | // NOTE: loop until quit; quit is set in update if AppClose is received from counter 45 | while !model.quit { 46 | // Tick 47 | match model.app.tick(PollStrategy::Once) { 48 | Err(err) => { 49 | panic!("application error {err}"); 50 | } 51 | Ok(messages) if !messages.is_empty() => { 52 | // NOTE: redraw if at least one msg has been processed 53 | model.redraw = true; 54 | for msg in messages { 55 | let mut msg = Some(msg); 56 | while msg.is_some() { 57 | msg = model.update(msg); 58 | } 59 | } 60 | } 61 | _ => {} 62 | } 63 | // Redraw 64 | if model.redraw { 65 | model.view(); 66 | model.redraw = false; 67 | } 68 | } 69 | 70 | model.terminal.restore()?; 71 | 72 | Ok(()) 73 | } 74 | 75 | #[derive(Debug, PartialEq)] 76 | pub enum Msg { 77 | AppClose, 78 | None, 79 | } 80 | 81 | // Let's define the component ids for our application 82 | #[derive(Debug, Eq, PartialEq, Clone, Hash)] 83 | pub enum Id { 84 | Label, 85 | } 86 | 87 | #[derive(Debug, Eq, Clone, Copy, PartialOrd, Ord)] 88 | pub enum UserEvent { 89 | WroteFile(Duration), 90 | None, 91 | } 92 | 93 | impl PartialEq for UserEvent { 94 | fn eq(&self, other: &Self) -> bool { 95 | std::mem::discriminant(self) == std::mem::discriminant(other) 96 | } 97 | } 98 | 99 | pub struct Model 100 | where 101 | T: TerminalAdapter, 102 | { 103 | /// Application 104 | pub app: Application, 105 | /// Indicates that the application must quit 106 | pub quit: bool, 107 | /// Tells whether to redraw interface 108 | pub redraw: bool, 109 | /// Used to draw to terminal 110 | pub terminal: TerminalBridge, 111 | } 112 | 113 | impl Model 114 | where 115 | T: TerminalAdapter, 116 | { 117 | pub fn new(app: Application, adapter: T) -> Self { 118 | Self { 119 | app, 120 | quit: false, 121 | redraw: true, 122 | terminal: TerminalBridge::init(adapter).expect("Cannot initialize terminal"), 123 | } 124 | } 125 | 126 | pub fn view(&mut self) { 127 | assert!( 128 | self.terminal 129 | .draw(|f| { 130 | let chunks = Layout::default() 131 | .direction(Direction::Vertical) 132 | .margin(1) 133 | .constraints( 134 | [ 135 | Constraint::Length(3), // Label 136 | ] 137 | .as_ref(), 138 | ) 139 | .split(f.area()); 140 | self.app.view(&Id::Label, f, chunks[0]); 141 | }) 142 | .is_ok() 143 | ); 144 | } 145 | } 146 | 147 | // Let's implement Update for model 148 | 149 | impl Update for Model 150 | where 151 | T: TerminalAdapter, 152 | { 153 | fn update(&mut self, msg: Option) -> Option { 154 | if let Some(msg) = msg { 155 | // Set redraw 156 | self.redraw = true; 157 | // Match message 158 | match msg { 159 | Msg::AppClose => { 160 | self.quit = true; // Terminate 161 | None 162 | } 163 | Msg::None => None, 164 | } 165 | } else { 166 | None 167 | } 168 | } 169 | } 170 | 171 | struct AsyncPort { 172 | tempfile: Arc, 173 | } 174 | 175 | impl AsyncPort { 176 | pub fn new() -> Self { 177 | let tempfile = Arc::new(NamedTempFile::new().unwrap()); 178 | Self { tempfile } 179 | } 180 | 181 | pub async fn write_file(&self) -> Duration { 182 | let t_start = std::time::Instant::now(); 183 | // Write to file 184 | let mut file = tokio::fs::File::create(self.tempfile.path()).await.unwrap(); 185 | file.write_all(b"Hello, world!").await.unwrap(); 186 | 187 | t_start.elapsed() 188 | } 189 | } 190 | 191 | #[tuirealm::async_trait] 192 | impl PollAsync for AsyncPort { 193 | async fn poll(&mut self) -> ListenerResult>> { 194 | let result = self.write_file().await; 195 | 196 | Ok(Some(Event::User(UserEvent::WroteFile(result)))) 197 | } 198 | } 199 | 200 | /// Simple label component; just renders a text 201 | /// NOTE: since I need just one label, I'm not going to use different object; I will directly implement Component for Label. 202 | /// This is not ideal actually and in a real app you should differentiate Mock Components from Application Components. 203 | #[derive(Default)] 204 | pub struct Label { 205 | props: Props, 206 | } 207 | 208 | impl MockComponent for Label { 209 | fn view(&mut self, frame: &mut Frame, area: Rect) { 210 | // Check if visible 211 | if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) { 212 | // Get properties 213 | let text = self 214 | .props 215 | .get_or(Attribute::Text, AttrValue::String(String::default())) 216 | .unwrap_string(); 217 | let alignment = self 218 | .props 219 | .get_or(Attribute::TextAlign, AttrValue::Alignment(Alignment::Left)) 220 | .unwrap_alignment(); 221 | let foreground = self 222 | .props 223 | .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset)) 224 | .unwrap_color(); 225 | let background = self 226 | .props 227 | .get_or(Attribute::Background, AttrValue::Color(Color::Reset)) 228 | .unwrap_color(); 229 | let modifiers = self 230 | .props 231 | .get_or( 232 | Attribute::TextProps, 233 | AttrValue::TextModifiers(TextModifiers::empty()), 234 | ) 235 | .unwrap_text_modifiers(); 236 | frame.render_widget( 237 | Paragraph::new(text) 238 | .style( 239 | Style::default() 240 | .fg(foreground) 241 | .bg(background) 242 | .add_modifier(modifiers), 243 | ) 244 | .alignment(alignment), 245 | area, 246 | ); 247 | } 248 | } 249 | 250 | fn query(&self, attr: Attribute) -> Option { 251 | self.props.get(attr) 252 | } 253 | 254 | fn attr(&mut self, attr: Attribute, value: AttrValue) { 255 | self.props.set(attr, value); 256 | } 257 | 258 | fn state(&self) -> State { 259 | State::None 260 | } 261 | 262 | fn perform(&mut self, _: Cmd) -> CmdResult { 263 | CmdResult::None 264 | } 265 | } 266 | 267 | impl Component for Label { 268 | fn on(&mut self, ev: Event) -> Option { 269 | // Does nothing 270 | match ev { 271 | Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => Some(Msg::AppClose), 272 | Event::User(UserEvent::WroteFile(duration)) => { 273 | // set text 274 | self.attr( 275 | Attribute::Text, 276 | AttrValue::String(format!("file wrote in {} nanos", duration.as_nanos())), 277 | ); 278 | 279 | Some(Msg::None) 280 | } 281 | _ => None, 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /examples/demo/app/mod.rs: -------------------------------------------------------------------------------- 1 | //! ## Terminal 2 | //! 3 | //! terminal helper 4 | 5 | pub use super::*; 6 | 7 | pub mod model; 8 | -------------------------------------------------------------------------------- /examples/demo/app/model.rs: -------------------------------------------------------------------------------- 1 | //! ## Model 2 | //! 3 | //! app model 4 | 5 | use std::time::{Duration, SystemTime}; 6 | 7 | use tuirealm::event::NoUserEvent; 8 | use tuirealm::props::{Alignment, Color, TextModifiers}; 9 | use tuirealm::ratatui::layout::{Constraint, Direction, Layout}; 10 | use tuirealm::terminal::{CrosstermTerminalAdapter, TerminalAdapter, TerminalBridge}; 11 | use tuirealm::{ 12 | Application, AttrValue, Attribute, EventListenerCfg, Sub, SubClause, SubEventClause, Update, 13 | }; 14 | 15 | use super::components::{Clock, DigitCounter, Label, LetterCounter}; 16 | use super::{Id, Msg}; 17 | 18 | pub struct Model 19 | where 20 | T: TerminalAdapter, 21 | { 22 | /// Application 23 | pub app: Application, 24 | /// Indicates that the application must quit 25 | pub quit: bool, 26 | /// Tells whether to redraw interface 27 | pub redraw: bool, 28 | /// Used to draw to terminal 29 | pub terminal: TerminalBridge, 30 | } 31 | 32 | impl Default for Model { 33 | fn default() -> Self { 34 | Self { 35 | app: Self::init_app(), 36 | quit: false, 37 | redraw: true, 38 | terminal: TerminalBridge::init_crossterm().expect("Cannot initialize terminal"), 39 | } 40 | } 41 | } 42 | 43 | impl Model 44 | where 45 | T: TerminalAdapter, 46 | { 47 | pub fn view(&mut self) { 48 | assert!( 49 | self.terminal 50 | .draw(|f| { 51 | let chunks = Layout::default() 52 | .direction(Direction::Vertical) 53 | .margin(1) 54 | .constraints( 55 | [ 56 | Constraint::Length(3), // Clock 57 | Constraint::Length(3), // Letter Counter 58 | Constraint::Length(3), // Digit Counter 59 | Constraint::Length(1), // Label 60 | ] 61 | .as_ref(), 62 | ) 63 | .split(f.area()); 64 | self.app.view(&Id::Clock, f, chunks[0]); 65 | self.app.view(&Id::LetterCounter, f, chunks[1]); 66 | self.app.view(&Id::DigitCounter, f, chunks[2]); 67 | self.app.view(&Id::Label, f, chunks[3]); 68 | }) 69 | .is_ok() 70 | ); 71 | } 72 | 73 | fn init_app() -> Application { 74 | // Setup application 75 | // NOTE: NoUserEvent is a shorthand to tell tui-realm we're not going to use any custom user event 76 | // NOTE: the event listener is configured to use the default crossterm input listener and to raise a Tick event each second 77 | // which we will use to update the clock 78 | 79 | let mut app: Application = Application::init( 80 | EventListenerCfg::default() 81 | .crossterm_input_listener(Duration::from_millis(20), 3) 82 | .poll_timeout(Duration::from_millis(10)) 83 | .tick_interval(Duration::from_secs(1)), 84 | ); 85 | // Mount components 86 | assert!( 87 | app.mount( 88 | Id::Label, 89 | Box::new( 90 | Label::default() 91 | .text("Waiting for a Msg...") 92 | .alignment(Alignment::Left) 93 | .background(Color::Reset) 94 | .foreground(Color::LightYellow) 95 | .modifiers(TextModifiers::BOLD), 96 | ), 97 | Vec::default(), 98 | ) 99 | .is_ok() 100 | ); 101 | // Mount clock, subscribe to tick 102 | assert!( 103 | app.mount( 104 | Id::Clock, 105 | Box::new( 106 | Clock::new(SystemTime::now()) 107 | .alignment(Alignment::Center) 108 | .background(Color::Reset) 109 | .foreground(Color::Cyan) 110 | .modifiers(TextModifiers::BOLD) 111 | ), 112 | vec![Sub::new(SubEventClause::Tick, SubClause::Always)] 113 | ) 114 | .is_ok() 115 | ); 116 | // Mount counters 117 | assert!( 118 | app.mount( 119 | Id::LetterCounter, 120 | Box::new(LetterCounter::new(0)), 121 | Vec::new() 122 | ) 123 | .is_ok() 124 | ); 125 | assert!( 126 | app.mount( 127 | Id::DigitCounter, 128 | Box::new(DigitCounter::new(5)), 129 | Vec::default() 130 | ) 131 | .is_ok() 132 | ); 133 | // Active letter counter 134 | assert!(app.active(&Id::LetterCounter).is_ok()); 135 | app 136 | } 137 | } 138 | 139 | // Let's implement Update for model 140 | 141 | impl Update for Model 142 | where 143 | T: TerminalAdapter, 144 | { 145 | fn update(&mut self, msg: Option) -> Option { 146 | if let Some(msg) = msg { 147 | // Set redraw 148 | self.redraw = true; 149 | // Match message 150 | match msg { 151 | Msg::AppClose => { 152 | self.quit = true; // Terminate 153 | None 154 | } 155 | Msg::Clock => None, 156 | Msg::DigitCounterBlur => { 157 | // Give focus to letter counter 158 | assert!(self.app.active(&Id::LetterCounter).is_ok()); 159 | None 160 | } 161 | Msg::DigitCounterChanged(v) => { 162 | // Update label 163 | assert!( 164 | self.app 165 | .attr( 166 | &Id::Label, 167 | Attribute::Text, 168 | AttrValue::String(format!("DigitCounter has now value: {v}")) 169 | ) 170 | .is_ok() 171 | ); 172 | None 173 | } 174 | Msg::LetterCounterBlur => { 175 | // Give focus to digit counter 176 | assert!(self.app.active(&Id::DigitCounter).is_ok()); 177 | None 178 | } 179 | Msg::LetterCounterChanged(v) => { 180 | // Update label 181 | assert!( 182 | self.app 183 | .attr( 184 | &Id::Label, 185 | Attribute::Text, 186 | AttrValue::String(format!("LetterCounter has now value: {v}")) 187 | ) 188 | .is_ok() 189 | ); 190 | None 191 | } 192 | } 193 | } else { 194 | None 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /examples/demo/components/clock.rs: -------------------------------------------------------------------------------- 1 | //! ## Label 2 | //! 3 | //! label component 4 | 5 | use std::ops::Add; 6 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; 7 | 8 | use tuirealm::command::{Cmd, CmdResult}; 9 | use tuirealm::props::{Alignment, Color, TextModifiers}; 10 | use tuirealm::ratatui::layout::Rect; 11 | use tuirealm::{ 12 | AttrValue, Attribute, Component, Event, Frame, MockComponent, NoUserEvent, State, StateValue, 13 | }; 14 | 15 | use super::{Label, Msg}; 16 | 17 | /// Simple clock component which displays current time 18 | pub struct Clock { 19 | component: Label, 20 | states: OwnStates, 21 | } 22 | 23 | impl Clock { 24 | pub fn new(initial_time: SystemTime) -> Self { 25 | Self { 26 | component: Label::default(), 27 | states: OwnStates::new(initial_time), 28 | } 29 | } 30 | 31 | pub fn alignment(mut self, a: Alignment) -> Self { 32 | self.component 33 | .attr(Attribute::TextAlign, AttrValue::Alignment(a)); 34 | self 35 | } 36 | 37 | pub fn foreground(mut self, c: Color) -> Self { 38 | self.component 39 | .attr(Attribute::Foreground, AttrValue::Color(c)); 40 | self 41 | } 42 | 43 | pub fn background(mut self, c: Color) -> Self { 44 | self.component 45 | .attr(Attribute::Background, AttrValue::Color(c)); 46 | self 47 | } 48 | 49 | pub fn modifiers(mut self, m: TextModifiers) -> Self { 50 | self.attr(Attribute::TextProps, AttrValue::TextModifiers(m)); 51 | self 52 | } 53 | 54 | fn time_to_str(&self) -> String { 55 | let since_the_epoch = self.get_epoch_time(); 56 | let hours = (since_the_epoch / 3600) % 24; 57 | let minutes = (since_the_epoch / 60) % 60; 58 | let seconds = since_the_epoch % 60; 59 | format!("{hours:02}:{minutes:02}:{seconds:02}") 60 | } 61 | 62 | fn get_epoch_time(&self) -> u64 { 63 | self.states 64 | .time 65 | .duration_since(UNIX_EPOCH) 66 | .expect("Time went backwards") 67 | .as_secs() 68 | } 69 | } 70 | 71 | impl MockComponent for Clock { 72 | fn view(&mut self, frame: &mut Frame, area: Rect) { 73 | // Render 74 | self.component.view(frame, area); 75 | } 76 | 77 | fn query(&self, attr: Attribute) -> Option { 78 | self.component.query(attr) 79 | } 80 | 81 | fn attr(&mut self, attr: Attribute, value: AttrValue) { 82 | self.component.attr(attr, value); 83 | } 84 | 85 | fn state(&self) -> State { 86 | // Return current time 87 | State::One(StateValue::U64(self.get_epoch_time())) 88 | } 89 | 90 | fn perform(&mut self, cmd: Cmd) -> CmdResult { 91 | self.component.perform(cmd) 92 | } 93 | } 94 | 95 | impl Component for Clock { 96 | fn on(&mut self, ev: Event) -> Option { 97 | if let Event::Tick = ev { 98 | self.states.tick(); 99 | // Set text 100 | self.attr(Attribute::Text, AttrValue::String(self.time_to_str())); 101 | Some(Msg::Clock) 102 | } else { 103 | None 104 | } 105 | } 106 | } 107 | 108 | struct OwnStates { 109 | time: SystemTime, 110 | } 111 | 112 | impl OwnStates { 113 | pub fn new(time: SystemTime) -> Self { 114 | Self { time } 115 | } 116 | 117 | pub fn tick(&mut self) { 118 | self.time = self.time.add(Duration::from_secs(1)); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /examples/demo/components/counter.rs: -------------------------------------------------------------------------------- 1 | //! ## Label 2 | //! 3 | //! label component 4 | 5 | use tuirealm::command::{Cmd, CmdResult}; 6 | use tuirealm::event::{Key, KeyEvent, KeyModifiers}; 7 | use tuirealm::props::{Alignment, Borders, Color, Style, TextModifiers}; 8 | use tuirealm::ratatui::layout::Rect; 9 | use tuirealm::ratatui::widgets::{BorderType, Paragraph}; 10 | use tuirealm::{ 11 | AttrValue, Attribute, Component, Event, Frame, MockComponent, NoUserEvent, Props, State, 12 | StateValue, 13 | }; 14 | 15 | use super::{Msg, get_block}; 16 | 17 | /// Counter which increments its value on Submit 18 | #[derive(Default)] 19 | struct Counter { 20 | props: Props, 21 | states: OwnStates, 22 | } 23 | 24 | impl Counter { 25 | pub fn label(mut self, label: S) -> Self 26 | where 27 | S: AsRef, 28 | { 29 | self.attr( 30 | Attribute::Title, 31 | AttrValue::Title((label.as_ref().to_string(), Alignment::Center)), 32 | ); 33 | self 34 | } 35 | 36 | pub fn value(mut self, n: isize) -> Self { 37 | self.attr(Attribute::Value, AttrValue::Number(n)); 38 | self 39 | } 40 | 41 | pub fn alignment(mut self, a: Alignment) -> Self { 42 | self.attr(Attribute::TextAlign, AttrValue::Alignment(a)); 43 | self 44 | } 45 | 46 | pub fn foreground(mut self, c: Color) -> Self { 47 | self.attr(Attribute::Foreground, AttrValue::Color(c)); 48 | self 49 | } 50 | 51 | pub fn background(mut self, c: Color) -> Self { 52 | self.attr(Attribute::Background, AttrValue::Color(c)); 53 | self 54 | } 55 | 56 | pub fn modifiers(mut self, m: TextModifiers) -> Self { 57 | self.attr(Attribute::TextProps, AttrValue::TextModifiers(m)); 58 | self 59 | } 60 | 61 | pub fn borders(mut self, b: Borders) -> Self { 62 | self.attr(Attribute::Borders, AttrValue::Borders(b)); 63 | self 64 | } 65 | } 66 | 67 | impl MockComponent for Counter { 68 | fn view(&mut self, frame: &mut Frame, area: Rect) { 69 | // Check if visible 70 | if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) { 71 | // Get properties 72 | let text = self.states.counter.to_string(); 73 | let alignment = self 74 | .props 75 | .get_or(Attribute::TextAlign, AttrValue::Alignment(Alignment::Left)) 76 | .unwrap_alignment(); 77 | let foreground = self 78 | .props 79 | .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset)) 80 | .unwrap_color(); 81 | let background = self 82 | .props 83 | .get_or(Attribute::Background, AttrValue::Color(Color::Reset)) 84 | .unwrap_color(); 85 | let modifiers = self 86 | .props 87 | .get_or( 88 | Attribute::TextProps, 89 | AttrValue::TextModifiers(TextModifiers::empty()), 90 | ) 91 | .unwrap_text_modifiers(); 92 | let title = self 93 | .props 94 | .get_or( 95 | Attribute::Title, 96 | AttrValue::Title((String::default(), Alignment::Center)), 97 | ) 98 | .unwrap_title(); 99 | let borders = self 100 | .props 101 | .get_or(Attribute::Borders, AttrValue::Borders(Borders::default())) 102 | .unwrap_borders(); 103 | let focus = self 104 | .props 105 | .get_or(Attribute::Focus, AttrValue::Flag(false)) 106 | .unwrap_flag(); 107 | frame.render_widget( 108 | Paragraph::new(text) 109 | .block(get_block(borders, title, focus)) 110 | .style( 111 | Style::default() 112 | .fg(foreground) 113 | .bg(background) 114 | .add_modifier(modifiers), 115 | ) 116 | .alignment(alignment), 117 | area, 118 | ); 119 | } 120 | } 121 | 122 | fn query(&self, attr: Attribute) -> Option { 123 | self.props.get(attr) 124 | } 125 | 126 | fn attr(&mut self, attr: Attribute, value: AttrValue) { 127 | self.props.set(attr, value); 128 | } 129 | 130 | fn state(&self) -> State { 131 | State::One(StateValue::Isize(self.states.counter)) 132 | } 133 | 134 | fn perform(&mut self, cmd: Cmd) -> CmdResult { 135 | match cmd { 136 | Cmd::Submit => { 137 | self.states.incr(); 138 | CmdResult::Changed(self.state()) 139 | } 140 | _ => CmdResult::None, 141 | } 142 | } 143 | } 144 | 145 | #[derive(Default)] 146 | struct OwnStates { 147 | counter: isize, 148 | } 149 | 150 | impl OwnStates { 151 | fn incr(&mut self) { 152 | self.counter += 1; 153 | } 154 | } 155 | 156 | // -- Counter components 157 | 158 | #[derive(MockComponent)] 159 | pub struct LetterCounter { 160 | component: Counter, 161 | } 162 | 163 | impl LetterCounter { 164 | pub fn new(initial_value: isize) -> Self { 165 | Self { 166 | component: Counter::default() 167 | .alignment(Alignment::Center) 168 | .background(Color::Reset) 169 | .borders( 170 | Borders::default() 171 | .color(Color::LightGreen) 172 | .modifiers(BorderType::Rounded), 173 | ) 174 | .foreground(Color::LightGreen) 175 | .modifiers(TextModifiers::BOLD) 176 | .value(initial_value) 177 | .label("Letter counter"), 178 | } 179 | } 180 | } 181 | 182 | impl Component for LetterCounter { 183 | fn on(&mut self, ev: Event) -> Option { 184 | // Get command 185 | let cmd = match ev { 186 | Event::Keyboard(KeyEvent { 187 | code: Key::Char(ch), 188 | modifiers: KeyModifiers::NONE, 189 | }) if ch.is_alphabetic() => Cmd::Submit, 190 | Event::Keyboard(KeyEvent { 191 | code: Key::Tab, 192 | modifiers: KeyModifiers::NONE, 193 | }) => return Some(Msg::LetterCounterBlur), // Return focus lost 194 | Event::Keyboard(KeyEvent { 195 | code: Key::Esc, 196 | modifiers: KeyModifiers::NONE, 197 | }) => return Some(Msg::AppClose), 198 | _ => Cmd::None, 199 | }; 200 | // perform 201 | match self.perform(cmd) { 202 | CmdResult::Changed(State::One(StateValue::Isize(c))) => { 203 | Some(Msg::LetterCounterChanged(c)) 204 | } 205 | _ => None, 206 | } 207 | } 208 | } 209 | 210 | #[derive(MockComponent)] 211 | pub struct DigitCounter { 212 | component: Counter, 213 | } 214 | 215 | impl DigitCounter { 216 | pub fn new(initial_value: isize) -> Self { 217 | Self { 218 | component: Counter::default() 219 | .alignment(Alignment::Center) 220 | .background(Color::Reset) 221 | .borders( 222 | Borders::default() 223 | .color(Color::Yellow) 224 | .modifiers(BorderType::Rounded), 225 | ) 226 | .foreground(Color::Yellow) 227 | .modifiers(TextModifiers::BOLD) 228 | .value(initial_value) 229 | .label("Digit counter"), 230 | } 231 | } 232 | } 233 | 234 | impl Component for DigitCounter { 235 | fn on(&mut self, ev: Event) -> Option { 236 | // Get command 237 | let cmd = match ev { 238 | Event::Keyboard(KeyEvent { 239 | code: Key::Char(ch), 240 | modifiers: KeyModifiers::NONE, 241 | }) if ch.is_ascii_digit() => Cmd::Submit, 242 | Event::Keyboard(KeyEvent { 243 | code: Key::Tab, 244 | modifiers: KeyModifiers::NONE, 245 | }) => return Some(Msg::DigitCounterBlur), // Return focus lost 246 | Event::Keyboard(KeyEvent { 247 | code: Key::Esc, 248 | modifiers: KeyModifiers::NONE, 249 | }) => return Some(Msg::AppClose), 250 | _ => Cmd::None, 251 | }; 252 | // perform 253 | match self.perform(cmd) { 254 | CmdResult::Changed(State::One(StateValue::Isize(c))) => { 255 | Some(Msg::DigitCounterChanged(c)) 256 | } 257 | _ => None, 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /examples/demo/components/label.rs: -------------------------------------------------------------------------------- 1 | //! ## Label 2 | //! 3 | //! label component 4 | 5 | use tuirealm::command::{Cmd, CmdResult}; 6 | use tuirealm::props::{Alignment, Color, Style, TextModifiers}; 7 | use tuirealm::ratatui::layout::Rect; 8 | use tuirealm::ratatui::widgets::Paragraph; 9 | use tuirealm::{ 10 | AttrValue, Attribute, Component, Event, Frame, MockComponent, NoUserEvent, Props, State, 11 | }; 12 | 13 | use super::Msg; 14 | 15 | /// Simple label component; just renders a text 16 | /// NOTE: since I need just one label, I'm not going to use different object; I will directly implement Component for Label. 17 | /// This is not ideal actually and in a real app you should differentiate Mock Components from Application Components. 18 | #[derive(Default)] 19 | pub struct Label { 20 | props: Props, 21 | } 22 | 23 | impl Label { 24 | pub fn text(mut self, s: S) -> Self 25 | where 26 | S: AsRef, 27 | { 28 | self.attr(Attribute::Text, AttrValue::String(s.as_ref().to_string())); 29 | self 30 | } 31 | 32 | pub fn alignment(mut self, a: Alignment) -> Self { 33 | self.attr(Attribute::TextAlign, AttrValue::Alignment(a)); 34 | self 35 | } 36 | 37 | pub fn foreground(mut self, c: Color) -> Self { 38 | self.attr(Attribute::Foreground, AttrValue::Color(c)); 39 | self 40 | } 41 | 42 | pub fn background(mut self, c: Color) -> Self { 43 | self.attr(Attribute::Background, AttrValue::Color(c)); 44 | self 45 | } 46 | 47 | pub fn modifiers(mut self, m: TextModifiers) -> Self { 48 | self.attr(Attribute::TextProps, AttrValue::TextModifiers(m)); 49 | self 50 | } 51 | } 52 | 53 | impl MockComponent for Label { 54 | fn view(&mut self, frame: &mut Frame, area: Rect) { 55 | // Check if visible 56 | if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) { 57 | // Get properties 58 | let text = self 59 | .props 60 | .get_or(Attribute::Text, AttrValue::String(String::default())) 61 | .unwrap_string(); 62 | let alignment = self 63 | .props 64 | .get_or(Attribute::TextAlign, AttrValue::Alignment(Alignment::Left)) 65 | .unwrap_alignment(); 66 | let foreground = self 67 | .props 68 | .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset)) 69 | .unwrap_color(); 70 | let background = self 71 | .props 72 | .get_or(Attribute::Background, AttrValue::Color(Color::Reset)) 73 | .unwrap_color(); 74 | let modifiers = self 75 | .props 76 | .get_or( 77 | Attribute::TextProps, 78 | AttrValue::TextModifiers(TextModifiers::empty()), 79 | ) 80 | .unwrap_text_modifiers(); 81 | frame.render_widget( 82 | Paragraph::new(text) 83 | .style( 84 | Style::default() 85 | .fg(foreground) 86 | .bg(background) 87 | .add_modifier(modifiers), 88 | ) 89 | .alignment(alignment), 90 | area, 91 | ); 92 | } 93 | } 94 | 95 | fn query(&self, attr: Attribute) -> Option { 96 | self.props.get(attr) 97 | } 98 | 99 | fn attr(&mut self, attr: Attribute, value: AttrValue) { 100 | self.props.set(attr, value); 101 | } 102 | 103 | fn state(&self) -> State { 104 | State::None 105 | } 106 | 107 | fn perform(&mut self, _: Cmd) -> CmdResult { 108 | CmdResult::None 109 | } 110 | } 111 | 112 | impl Component for Label { 113 | fn on(&mut self, _: Event) -> Option { 114 | // Does nothing 115 | None 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /examples/demo/components/mod.rs: -------------------------------------------------------------------------------- 1 | //! ## Components 2 | //! 3 | //! demo example components 4 | 5 | use tuirealm::props::{Alignment, Borders, Color, Style}; 6 | use tuirealm::ratatui::widgets::Block; 7 | 8 | use super::Msg; 9 | 10 | // -- modules 11 | mod clock; 12 | mod counter; 13 | mod label; 14 | 15 | // -- export 16 | pub use clock::Clock; 17 | pub use counter::{DigitCounter, LetterCounter}; 18 | pub use label::Label; 19 | 20 | /// ### `get_block` 21 | /// 22 | /// Get block 23 | pub(crate) fn get_block<'a>(props: Borders, title: (String, Alignment), focus: bool) -> Block<'a> { 24 | Block::default() 25 | .borders(props.sides) 26 | .border_style(if focus { 27 | props.style() 28 | } else { 29 | Style::default().fg(Color::Reset).bg(Color::Reset) 30 | }) 31 | .border_type(props.modifiers) 32 | .title(title.0) 33 | .title_alignment(title.1) 34 | } 35 | -------------------------------------------------------------------------------- /examples/demo/demo.rs: -------------------------------------------------------------------------------- 1 | //! ## Demo 2 | //! 3 | //! `Demo` shows how to use tui-realm in a real case 4 | 5 | extern crate tuirealm; 6 | 7 | use tuirealm::application::PollStrategy; 8 | use tuirealm::{AttrValue, Attribute, Update}; 9 | // -- internal 10 | mod app; 11 | mod components; 12 | use app::model::Model; 13 | 14 | // Let's define the messages handled by our app. NOTE: it must derive `PartialEq` 15 | #[derive(Debug, PartialEq)] 16 | pub enum Msg { 17 | AppClose, 18 | Clock, 19 | DigitCounterChanged(isize), 20 | DigitCounterBlur, 21 | LetterCounterChanged(isize), 22 | LetterCounterBlur, 23 | } 24 | 25 | // Let's define the component ids for our application 26 | #[derive(Debug, Eq, PartialEq, Clone, Hash)] 27 | pub enum Id { 28 | Clock, 29 | DigitCounter, 30 | LetterCounter, 31 | Label, 32 | } 33 | 34 | fn main() { 35 | // Setup model 36 | let mut model = Model::default(); 37 | // Enter alternate screen 38 | let _ = model.terminal.enter_alternate_screen(); 39 | let _ = model.terminal.enable_raw_mode(); 40 | // Main loop 41 | // NOTE: loop until quit; quit is set in update if AppClose is received from counter 42 | while !model.quit { 43 | // Tick 44 | match model.app.tick(PollStrategy::Once) { 45 | Err(err) => { 46 | assert!( 47 | model 48 | .app 49 | .attr( 50 | &Id::Label, 51 | Attribute::Text, 52 | AttrValue::String(format!("Application error: {err}")), 53 | ) 54 | .is_ok() 55 | ); 56 | } 57 | Ok(messages) if !messages.is_empty() => { 58 | // NOTE: redraw if at least one msg has been processed 59 | model.redraw = true; 60 | for msg in messages { 61 | let mut msg = Some(msg); 62 | while msg.is_some() { 63 | msg = model.update(msg); 64 | } 65 | } 66 | } 67 | _ => {} 68 | } 69 | // Redraw 70 | if model.redraw { 71 | model.view(); 72 | model.redraw = false; 73 | } 74 | } 75 | // Terminate terminal 76 | let _ = model.terminal.leave_alternate_screen(); 77 | let _ = model.terminal.disable_raw_mode(); 78 | let _ = model.terminal.clear_screen(); 79 | } 80 | -------------------------------------------------------------------------------- /examples/user_events/components/label.rs: -------------------------------------------------------------------------------- 1 | //! ## Label 2 | //! 3 | //! label component 4 | 5 | use std::time::UNIX_EPOCH; 6 | 7 | use tuirealm::command::{Cmd, CmdResult}; 8 | use tuirealm::event::{Key, KeyEvent}; 9 | use tuirealm::props::{Alignment, Color, Style, TextModifiers}; 10 | use tuirealm::ratatui::layout::Rect; 11 | use tuirealm::ratatui::widgets::Paragraph; 12 | use tuirealm::{AttrValue, Attribute, Component, Event, Frame, MockComponent, Props, State}; 13 | 14 | use super::{Msg, UserEvent}; 15 | 16 | /// Simple label component; just renders a text 17 | /// NOTE: since I need just one label, I'm not going to use different object; I will directly implement Component for Label. 18 | /// This is not ideal actually and in a real app you should differentiate Mock Components from Application Components. 19 | #[derive(Default)] 20 | pub struct Label { 21 | props: Props, 22 | } 23 | 24 | impl MockComponent for Label { 25 | fn view(&mut self, frame: &mut Frame, area: Rect) { 26 | // Check if visible 27 | if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) { 28 | // Get properties 29 | let text = self 30 | .props 31 | .get_or(Attribute::Text, AttrValue::String(String::default())) 32 | .unwrap_string(); 33 | let alignment = self 34 | .props 35 | .get_or(Attribute::TextAlign, AttrValue::Alignment(Alignment::Left)) 36 | .unwrap_alignment(); 37 | let foreground = self 38 | .props 39 | .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset)) 40 | .unwrap_color(); 41 | let background = self 42 | .props 43 | .get_or(Attribute::Background, AttrValue::Color(Color::Reset)) 44 | .unwrap_color(); 45 | let modifiers = self 46 | .props 47 | .get_or( 48 | Attribute::TextProps, 49 | AttrValue::TextModifiers(TextModifiers::empty()), 50 | ) 51 | .unwrap_text_modifiers(); 52 | frame.render_widget( 53 | Paragraph::new(text) 54 | .style( 55 | Style::default() 56 | .fg(foreground) 57 | .bg(background) 58 | .add_modifier(modifiers), 59 | ) 60 | .alignment(alignment), 61 | area, 62 | ); 63 | } 64 | } 65 | 66 | fn query(&self, attr: Attribute) -> Option { 67 | self.props.get(attr) 68 | } 69 | 70 | fn attr(&mut self, attr: Attribute, value: AttrValue) { 71 | self.props.set(attr, value); 72 | } 73 | 74 | fn state(&self) -> State { 75 | State::None 76 | } 77 | 78 | fn perform(&mut self, _: Cmd) -> CmdResult { 79 | CmdResult::None 80 | } 81 | } 82 | 83 | impl Component for Label { 84 | fn on(&mut self, ev: Event) -> Option { 85 | // Does nothing 86 | match ev { 87 | Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => Some(Msg::AppClose), 88 | Event::User(UserEvent::GotData(time)) => { 89 | // set text 90 | self.attr( 91 | Attribute::Text, 92 | AttrValue::String( 93 | time.duration_since(UNIX_EPOCH) 94 | .unwrap() 95 | .as_secs() 96 | .to_string(), 97 | ), 98 | ); 99 | 100 | Some(Msg::None) 101 | } 102 | _ => None, 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /examples/user_events/components/mod.rs: -------------------------------------------------------------------------------- 1 | //! ## Components 2 | //! 3 | //! demo example components 4 | 5 | use super::{Msg, UserEvent}; 6 | 7 | mod label; 8 | 9 | // -- export 10 | pub use label::Label; 11 | -------------------------------------------------------------------------------- /examples/user_events/model.rs: -------------------------------------------------------------------------------- 1 | //! ## Model 2 | //! 3 | //! app model 4 | 5 | use tuirealm::ratatui::layout::{Constraint, Direction, Layout}; 6 | use tuirealm::terminal::{TerminalAdapter, TerminalBridge}; 7 | use tuirealm::{Application, Update}; 8 | 9 | use super::{Id, Msg, UserEvent}; 10 | 11 | pub struct Model 12 | where 13 | T: TerminalAdapter, 14 | { 15 | /// Application 16 | pub app: Application, 17 | /// Indicates that the application must quit 18 | pub quit: bool, 19 | /// Tells whether to redraw interface 20 | pub redraw: bool, 21 | /// Used to draw to terminal 22 | pub terminal: TerminalBridge, 23 | } 24 | 25 | impl Model 26 | where 27 | T: TerminalAdapter, 28 | { 29 | pub fn new(app: Application, adapter: T) -> Self { 30 | Self { 31 | app, 32 | quit: false, 33 | redraw: true, 34 | terminal: TerminalBridge::init(adapter).expect("Cannot initialize terminal"), 35 | } 36 | } 37 | 38 | pub fn view(&mut self) { 39 | assert!( 40 | self.terminal 41 | .draw(|f| { 42 | let chunks = Layout::default() 43 | .direction(Direction::Vertical) 44 | .margin(1) 45 | .constraints( 46 | [ 47 | Constraint::Length(3), // Label 48 | Constraint::Length(3), // Other 49 | ] 50 | .as_ref(), 51 | ) 52 | .split(f.area()); 53 | self.app.view(&Id::Label, f, chunks[0]); 54 | self.app.view(&Id::Other, f, chunks[1]); 55 | }) 56 | .is_ok() 57 | ); 58 | } 59 | } 60 | 61 | // Let's implement Update for model 62 | 63 | impl Update for Model 64 | where 65 | T: TerminalAdapter, 66 | { 67 | fn update(&mut self, msg: Option) -> Option { 68 | if let Some(msg) = msg { 69 | // Set redraw 70 | self.redraw = true; 71 | // Match message 72 | match msg { 73 | Msg::AppClose => { 74 | self.quit = true; // Terminate 75 | None 76 | } 77 | Msg::None => None, 78 | } 79 | } else { 80 | None 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /examples/user_events/user_events.rs: -------------------------------------------------------------------------------- 1 | mod components; 2 | mod model; 3 | 4 | use std::time::{Duration, SystemTime}; 5 | 6 | use components::Label; 7 | use tuirealm::listener::{ListenerResult, Poll}; 8 | use tuirealm::terminal::CrosstermTerminalAdapter; 9 | use tuirealm::{ 10 | Application, Event, EventListenerCfg, PollStrategy, Sub, SubClause, SubEventClause, Update, 11 | }; 12 | 13 | use crate::model::Model; 14 | 15 | #[derive(Debug, PartialEq)] 16 | pub enum Msg { 17 | AppClose, 18 | None, 19 | } 20 | 21 | // Let's define the component ids for our application 22 | #[derive(Debug, Eq, PartialEq, Clone, Hash)] 23 | pub enum Id { 24 | Label, 25 | Other, 26 | } 27 | 28 | #[derive(Debug, Eq, Clone, Copy, PartialOrd, Ord)] 29 | pub enum UserEvent { 30 | GotData(SystemTime), 31 | None, 32 | } 33 | 34 | impl PartialEq for UserEvent { 35 | fn eq(&self, other: &Self) -> bool { 36 | matches!( 37 | (self, other), 38 | (UserEvent::GotData(_), UserEvent::GotData(_)) 39 | ) 40 | } 41 | } 42 | 43 | #[derive(Default)] 44 | struct UserDataPort; 45 | 46 | impl Poll for UserDataPort { 47 | fn poll(&mut self) -> ListenerResult>> { 48 | ListenerResult::Ok(Some(Event::User(UserEvent::GotData(SystemTime::now())))) 49 | } 50 | } 51 | 52 | fn main() { 53 | let event_listener = EventListenerCfg::default() 54 | .crossterm_input_listener(Duration::from_millis(10), 3) 55 | .add_port(Box::new(UserDataPort), Duration::from_millis(1000), 1); 56 | 57 | let mut app: Application = Application::init(event_listener); 58 | 59 | let _clause = tuirealm::subclause_and!(Id::Label, Id::Other); 60 | let _clause = tuirealm::subclause_or!(Id::Label, Id::Other); 61 | let _clause = tuirealm::subclause_and_not!(Id::Label, Id::Other, Id::Label); 62 | 63 | // subscribe component to clause 64 | app.mount( 65 | Id::Label, 66 | Box::new(Label::default()), 67 | vec![Sub::new( 68 | SubEventClause::User(UserEvent::GotData(SystemTime::UNIX_EPOCH)), 69 | SubClause::Always, 70 | )], 71 | ) 72 | .expect("failed to mount"); 73 | app.mount( 74 | Id::Other, 75 | Box::new(Label::default()), 76 | vec![Sub::new( 77 | SubEventClause::User(UserEvent::GotData(SystemTime::UNIX_EPOCH)), 78 | SubClause::Always, 79 | )], 80 | ) 81 | .expect("failed to mount"); 82 | 83 | app.active(&Id::Label).expect("failed to active"); 84 | 85 | let mut model = Model::new( 86 | app, 87 | CrosstermTerminalAdapter::new().expect("failed to create terminal"), 88 | ); 89 | // Main loop 90 | // NOTE: loop until quit; quit is set in update if AppClose is received from counter 91 | while !model.quit { 92 | // Tick 93 | match model.app.tick(PollStrategy::Once) { 94 | Err(err) => { 95 | panic!("application error {err}"); 96 | } 97 | Ok(messages) if !messages.is_empty() => { 98 | // NOTE: redraw if at least one msg has been processed 99 | model.redraw = true; 100 | for msg in messages { 101 | let mut msg = Some(msg); 102 | while msg.is_some() { 103 | msg = model.update(msg); 104 | } 105 | } 106 | } 107 | _ => {} 108 | } 109 | // Redraw 110 | if model.redraw { 111 | model.view(); 112 | model.redraw = false; 113 | } 114 | } 115 | 116 | model 117 | .terminal 118 | .restore() 119 | .expect("failed to restore terminal"); 120 | } 121 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | group_imports = "StdExternalCrate" 2 | imports_granularity = "Module" 3 | -------------------------------------------------------------------------------- /src/core/command.rs: -------------------------------------------------------------------------------- 1 | //! ## Command 2 | //! 3 | //! This module exposes the Command type, which must be used when sending command to the `MockComponent` from the 4 | //! `Component` after an `Event`. 5 | 6 | use super::State; 7 | 8 | // -- Command 9 | 10 | /// A command defines the "abstract" operation to perform in front of an Event. 11 | /// The command must be passed in the `on` method of the `Component` 12 | /// when calling `perform` method of the `MockComponent`. 13 | /// There is not a default conversion from `Event -> Cmd`, but it must be implmented by the user in the 14 | /// `Component` in a match case. 15 | #[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] 16 | pub enum Cmd { 17 | /// Describes a "user" typed a character 18 | Type(char), 19 | /// Describes a "cursor" movement, or a movement of another kind 20 | Move(Direction), 21 | /// An expansion of `Move` which defines the scroll. The step should be defined in props, if any. 22 | Scroll(Direction), 23 | /// Describes a movement with a position 24 | GoTo(Position), 25 | /// User submit field 26 | Submit, 27 | /// User "deleted" something 28 | Delete, 29 | /// User "cancelled" something; used to distinguish between Del and Canc 30 | Cancel, 31 | /// User toggled something 32 | Toggle, 33 | /// User changed something 34 | Change, 35 | /// A user defined amount of time has passed and the component should be updated 36 | Tick, 37 | /// A user defined command type. You won't find these kind of Command in the stdlib, but you can use them in your own components. 38 | Custom(&'static str), 39 | /// `None` won't do anything 40 | None, 41 | } 42 | 43 | /// Defines the 4 directions in front of a cursor movement. 44 | /// This may be used after a `Arrow::Up` event or for example if you want something more geeky 45 | /// when using `WASD` 46 | #[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] 47 | pub enum Direction { 48 | Down, 49 | Left, 50 | Right, 51 | Up, 52 | } 53 | 54 | /// Describes position on movement 55 | #[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] 56 | pub enum Position { 57 | Begin, 58 | End, 59 | At(usize), 60 | } 61 | 62 | // -- Command result 63 | 64 | /// A command result describes the output of a [`Cmd`] performed on a Component. 65 | /// It reports a "logical" change on the `MockComponent`. 66 | /// The `Component` then, must return a certain user defined `Msg` based on the value of the [`CmdResult`]. 67 | #[derive(Debug, PartialEq, Clone)] 68 | #[allow(clippy::large_enum_variant)] 69 | pub enum CmdResult { 70 | /// The component has changed state. The new state is reported. 71 | /// Box is used to reduce size 72 | Changed(State), 73 | /// Value submit result 74 | Submit(State), 75 | /// The command could not be applied. Useful to report errors 76 | Invalid(Cmd), 77 | /// Custom cmd result 78 | Custom(&'static str, State), 79 | /// An array of Command result 80 | Batch(Vec), 81 | /// No result to report 82 | None, 83 | } 84 | -------------------------------------------------------------------------------- /src/core/component.rs: -------------------------------------------------------------------------------- 1 | //! # Component 2 | //! 3 | //! This module exposes the component traits 4 | 5 | use ratatui::Frame; 6 | 7 | use crate::command::{Cmd, CmdResult}; 8 | use crate::ratatui::layout::Rect; 9 | use crate::{AttrValue, Attribute, Event, State}; 10 | 11 | /// A Mock Component represents a component which defines all the properties and states it can handle and represent 12 | /// and the way it should be rendered. It must also define how to behave in case of a [`Cmd`] (command). 13 | /// Despite that, it won't define how to behave after an [`Event`] and it won't send any `Msg`. 14 | /// The MockComponent is intended to be used as a reusable component to implement your application component. 15 | /// 16 | /// ### In practice 17 | /// 18 | /// A real life example would be an Input field. 19 | /// The mock component is represented by the `Input`, which will define the properties (e.g. max input length, input type, ...) 20 | /// and by its behaviour (e.g. when the user types 'a', 'a' char is added to input state). 21 | /// 22 | /// In your application though, you may use a `IpAddressInput` which is the [`Component`] using the `Input` mock component. 23 | /// If you want more example, just dive into the `examples/` folder in the project root. 24 | pub trait MockComponent { 25 | /// Based on the current properties and states, renders the component in the provided area frame. 26 | /// Render can also mutate the component state if this is required 27 | fn view(&mut self, frame: &mut Frame, area: Rect); 28 | 29 | /// Query attribute of component properties. 30 | fn query(&self, attr: Attribute) -> Option; 31 | 32 | /// Set attribute to properties. 33 | /// `query` describes the name, while `attr` the value it'll take 34 | fn attr(&mut self, attr: Attribute, value: AttrValue); 35 | 36 | /// Get current state from component 37 | fn state(&self) -> State; 38 | 39 | /// Perform a command on the component. 40 | /// The command will may change the component state. 41 | /// The method returns the result of the command applied (what changed if any) 42 | fn perform(&mut self, cmd: Cmd) -> CmdResult; 43 | } 44 | 45 | /// The component describes the application level component, which is a wrapper around the [`MockComponent`], 46 | /// which, in addition to all the methods exposed by the mock, it will handle the event coming from the `View`. 47 | /// The Event are passed to the `on` method, which will eventually return a `Msg`, 48 | /// which is defined in your application as an enum. (Don't forget to derive [`PartialEq`] for your enum). 49 | /// In your application you should have a Component for each element on your UI, but the logic to implement 50 | /// is very tiny, since the most of the work should already be done into the [`MockComponent`] 51 | /// and many of them are available in the standard library at . 52 | /// 53 | /// Don't forget you can find an example in the `examples/` directory and you can discover many more information 54 | /// about components in the repository documentation. 55 | pub trait Component: MockComponent 56 | where 57 | Msg: PartialEq, 58 | UserEvent: Eq + PartialEq + Clone + PartialOrd, 59 | { 60 | /// Handle input event and update internal states. 61 | /// Returns a Msg to the view. 62 | /// If [`None`] is returned it means there's no message to return for the provided event. 63 | fn on(&mut self, ev: Event) -> Option; 64 | } 65 | -------------------------------------------------------------------------------- /src/core/event.rs: -------------------------------------------------------------------------------- 1 | //! ## events 2 | //! 3 | //! `events` exposes the event raised by a user interaction or by the runtime 4 | 5 | use bitflags::bitflags; 6 | #[cfg(feature = "serialize")] 7 | use serde::{Deserialize, Serialize}; 8 | 9 | // -- event 10 | 11 | /// An event raised by a user interaction 12 | #[derive(Debug, Eq, PartialEq, Clone, PartialOrd)] 13 | pub enum Event 14 | where 15 | UserEvent: Eq + PartialEq + Clone + PartialOrd, 16 | { 17 | /// A keyboard event 18 | Keyboard(KeyEvent), 19 | /// A Mouse event 20 | Mouse(MouseEvent), 21 | /// This event is raised after the terminal window is resized 22 | WindowResize(u16, u16), 23 | /// Window focus gained 24 | FocusGained, 25 | /// Window focus lost 26 | FocusLost, 27 | /// Clipboard content pasted 28 | Paste(String), 29 | /// A ui tick event (should be configurable) 30 | Tick, 31 | /// Unhandled event; Empty event 32 | None, 33 | /// User event; won't be used by standard library or by default input event listener; 34 | /// but can be used in user defined ports 35 | User(UserEvent), 36 | } 37 | 38 | impl Event 39 | where 40 | U: Eq + PartialEq + Clone + PartialOrd, 41 | { 42 | pub(crate) fn as_keyboard(&self) -> Option<&KeyEvent> { 43 | if let Event::Keyboard(k) = self { 44 | Some(k) 45 | } else { 46 | None 47 | } 48 | } 49 | 50 | pub(crate) fn as_mouse(&self) -> Option<&MouseEvent> { 51 | if let Event::Mouse(m) = self { 52 | Some(m) 53 | } else { 54 | None 55 | } 56 | } 57 | 58 | pub(crate) fn as_window_resize(&self) -> bool { 59 | matches!(self, Self::WindowResize(_, _)) 60 | } 61 | 62 | pub(crate) fn as_tick(&self) -> bool { 63 | matches!(self, Self::Tick) 64 | } 65 | 66 | pub(crate) fn as_user(&self) -> Option<&U> { 67 | if let Event::User(u) = self { 68 | Some(u) 69 | } else { 70 | None 71 | } 72 | } 73 | } 74 | 75 | /// When using event you can use this as type parameter if you don't want to use user events 76 | #[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd)] 77 | pub enum NoUserEvent {} 78 | 79 | // -- keyboard 80 | 81 | /// A keyboard event 82 | #[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] 83 | #[cfg_attr( 84 | feature = "serialize", 85 | derive(Deserialize, Serialize), 86 | serde(tag = "type") 87 | )] 88 | pub struct KeyEvent { 89 | pub code: Key, 90 | pub modifiers: KeyModifiers, 91 | } 92 | 93 | /// A keyboard event 94 | #[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] 95 | #[cfg_attr( 96 | feature = "serialize", 97 | derive(Deserialize, Serialize), 98 | serde(tag = "type", content = "args") 99 | )] 100 | pub enum Key { 101 | /// Backspace key. 102 | Backspace, 103 | /// Enter key. 104 | Enter, 105 | /// Left arrow key. 106 | Left, 107 | /// Right arrow key. 108 | Right, 109 | /// Up arrow key. 110 | Up, 111 | /// Down arrow key. 112 | Down, 113 | /// Home key. 114 | Home, 115 | /// End key. 116 | End, 117 | /// Page up key. 118 | PageUp, 119 | /// Page dow key. 120 | PageDown, 121 | /// Tab key. 122 | Tab, 123 | /// Shift + Tab key. (sugar) 124 | BackTab, 125 | /// Delete key. 126 | Delete, 127 | /// Insert key. 128 | Insert, 129 | /// Function key followed by index (F1 => `Key::Function(1)`) 130 | Function(u8), 131 | /// A character. 132 | /// 133 | /// `KeyCode::Char('c')` represents `c` character, etc. 134 | Char(char), 135 | /// Null. 136 | Null, 137 | /// Caps lock pressed 138 | CapsLock, 139 | /// Scroll lock pressed 140 | ScrollLock, 141 | /// Num lock pressed 142 | NumLock, 143 | /// Print screen key 144 | PrintScreen, 145 | /// Pause key 146 | Pause, 147 | /// Menu key 148 | Menu, 149 | /// keypad begin 150 | KeypadBegin, 151 | /// Media key 152 | Media(MediaKeyCode), 153 | /// Escape key. 154 | Esc, 155 | /// Shift left 156 | ShiftLeft, 157 | /// Alt left; warning: it is supported only on termion 158 | AltLeft, 159 | /// warning: it is supported only on termion 160 | CtrlLeft, 161 | /// warning: it is supported only on termion 162 | ShiftRight, 163 | /// warning: it is supported only on termion 164 | AltRight, 165 | /// warning: it is supported only on termion 166 | CtrlRight, 167 | /// warning: it is supported only on termion 168 | ShiftUp, 169 | /// warning: it is supported only on termion 170 | AltUp, 171 | /// warning: it is supported only on termion 172 | CtrlUp, 173 | /// warning: it is supported only on termion 174 | ShiftDown, 175 | /// warning: it is supported only on termion 176 | AltDown, 177 | /// warning: it is supported only on termion 178 | CtrlDown, 179 | /// warning: it is supported only on termion 180 | CtrlHome, 181 | /// warning: it is supported only on termion 182 | CtrlEnd, 183 | } 184 | 185 | /// Defines special key states, such as shift, control, alt... 186 | #[derive(Clone, Copy, Hash, Eq, PartialEq, Debug, PartialOrd, Ord)] 187 | #[cfg_attr(feature = "serialize", derive(Deserialize, Serialize))] 188 | pub struct KeyModifiers(u8); 189 | 190 | bitflags! { 191 | impl KeyModifiers: u8 { 192 | const NONE = 0b0000_0000; 193 | const SHIFT = 0b0000_0001; 194 | const CONTROL = 0b0000_0010; 195 | const ALT = 0b0000_0100; 196 | } 197 | } 198 | 199 | impl KeyEvent { 200 | pub fn new(code: Key, modifiers: KeyModifiers) -> Self { 201 | Self { code, modifiers } 202 | } 203 | } 204 | 205 | impl From for KeyEvent { 206 | fn from(k: Key) -> Self { 207 | Self::new(k, KeyModifiers::NONE) 208 | } 209 | } 210 | 211 | #[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] 212 | #[cfg_attr( 213 | feature = "serialize", 214 | derive(Deserialize, Serialize), 215 | serde(tag = "type", content = "args") 216 | )] 217 | /// Describe a keycode for a media key 218 | pub enum MediaKeyCode { 219 | /// Play media key. 220 | Play, 221 | /// Pause media key. 222 | Pause, 223 | /// Play/Pause media key. 224 | PlayPause, 225 | /// Reverse media key. 226 | Reverse, 227 | /// Stop media key. 228 | Stop, 229 | /// Fast-forward media key. 230 | FastForward, 231 | /// Rewind media key. 232 | Rewind, 233 | /// Next-track media key. 234 | TrackNext, 235 | /// Previous-track media key. 236 | TrackPrevious, 237 | /// Record media key. 238 | Record, 239 | /// Lower-volume media key. 240 | LowerVolume, 241 | /// Raise-volume media key. 242 | RaiseVolume, 243 | /// Mute media key. 244 | MuteVolume, 245 | } 246 | 247 | /// A keyboard event 248 | #[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] 249 | #[cfg_attr( 250 | feature = "serialize", 251 | derive(Deserialize, Serialize), 252 | serde(tag = "type") 253 | )] 254 | pub struct MouseEvent { 255 | /// The kind of mouse event that was caused 256 | pub kind: MouseEventKind, 257 | /// The key modifiers active when the event occurred 258 | pub modifiers: KeyModifiers, 259 | /// The column that the event occurred on 260 | pub column: u16, 261 | /// The row that the event occurred on 262 | pub row: u16, 263 | } 264 | 265 | /// A Mouse event 266 | #[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] 267 | #[cfg_attr( 268 | feature = "serialize", 269 | derive(Deserialize, Serialize), 270 | serde(tag = "type", content = "args") 271 | )] 272 | pub enum MouseEventKind { 273 | /// Pressed mouse button. Contains the button that was pressed 274 | Down(MouseButton), 275 | /// Released mouse button. Contains the button that was released 276 | Up(MouseButton), 277 | /// Moved the mouse cursor while pressing the contained mouse button 278 | Drag(MouseButton), 279 | /// Moved / Hover changed without pressing any buttons 280 | Moved, 281 | /// Scrolled mouse wheel downwards 282 | ScrollDown, 283 | /// Scrolled mouse wheel upwards 284 | ScrollUp, 285 | /// Scrolled mouse wheel left 286 | ScrollLeft, 287 | /// Scrolled mouse wheel right 288 | ScrollRight, 289 | } 290 | 291 | /// A keyboard event 292 | #[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] 293 | #[cfg_attr( 294 | feature = "serialize", 295 | derive(Deserialize, Serialize), 296 | serde(tag = "type", content = "args") 297 | )] 298 | pub enum MouseButton { 299 | /// Left mouse button. 300 | Left, 301 | /// Right mouse button. 302 | Right, 303 | /// Middle mouse button. 304 | Middle, 305 | } 306 | 307 | #[cfg(test)] 308 | mod test { 309 | 310 | use pretty_assertions::assert_eq; 311 | 312 | use super::*; 313 | use crate::mock::MockEvent; 314 | 315 | #[test] 316 | fn new_key_event() { 317 | let k = KeyEvent::new(Key::Down, KeyModifiers::CONTROL); 318 | assert_eq!(k.code, Key::Down); 319 | assert_eq!(k.modifiers, KeyModifiers::CONTROL); 320 | } 321 | 322 | #[test] 323 | fn key_event_from_key() { 324 | let k = KeyEvent::from(Key::Up); 325 | assert_eq!(k.code, Key::Up); 326 | assert_eq!(k.modifiers, KeyModifiers::NONE); 327 | } 328 | 329 | #[test] 330 | fn check_events() { 331 | let e: Event = Event::Keyboard(KeyEvent::new(Key::Down, KeyModifiers::CONTROL)); 332 | assert!(e.as_keyboard().is_some()); 333 | assert_eq!(e.as_window_resize(), false); 334 | assert_eq!(e.as_tick(), false); 335 | assert_eq!(e.as_mouse().is_some(), false); 336 | assert!(e.as_user().is_none()); 337 | let e: Event = Event::WindowResize(0, 24); 338 | assert!(e.as_window_resize()); 339 | assert!(e.as_keyboard().is_none()); 340 | let e: Event = Event::Tick; 341 | assert!(e.as_tick()); 342 | let e: Event = Event::User(MockEvent::Bar); 343 | assert_eq!(e.as_user().unwrap(), &MockEvent::Bar); 344 | 345 | let e: Event = Event::Mouse(MouseEvent { 346 | kind: MouseEventKind::Moved, 347 | modifiers: KeyModifiers::NONE, 348 | column: 0, 349 | row: 0, 350 | }); 351 | assert!(e.as_mouse().is_some()); 352 | assert_eq!(e.as_keyboard().is_some(), false); 353 | assert_eq!(e.as_tick(), false); 354 | assert_eq!(e.as_window_resize(), false); 355 | } 356 | 357 | // -- serde 358 | #[cfg(feature = "serialize")] 359 | use std::fs::File; 360 | #[cfg(feature = "serialize")] 361 | use std::io::{Read, Write}; 362 | 363 | #[cfg(feature = "serialize")] 364 | use serde::de::DeserializeOwned; 365 | #[cfg(feature = "serialize")] 366 | use serde::{Deserialize, Serialize}; 367 | #[cfg(feature = "serialize")] 368 | use tempfile::NamedTempFile; 369 | 370 | #[cfg(feature = "serialize")] 371 | fn deserialize(mut readable: R) -> S 372 | where 373 | R: Read, 374 | S: DeserializeOwned + Sized + std::fmt::Debug, 375 | { 376 | // Read file content 377 | let mut data: String = String::new(); 378 | if let Err(err) = readable.read_to_string(&mut data) { 379 | panic!("Error: {}", err); 380 | } 381 | // Deserialize 382 | match toml::de::from_str(data.as_str()) { 383 | Ok(deserialized) => deserialized, 384 | Err(err) => panic!("Error: {}", err), 385 | } 386 | } 387 | 388 | #[cfg(feature = "serialize")] 389 | fn serialize(serializable: &S, mut writable: W) 390 | where 391 | S: Serialize + Sized, 392 | W: Write, 393 | { 394 | // Serialize content 395 | let data: String = match toml::ser::to_string(serializable) { 396 | Ok(dt) => dt, 397 | Err(err) => { 398 | panic!("Error: {}", err); 399 | } 400 | }; 401 | // Write file 402 | if let Err(err) = writable.write_all(data.as_bytes()) { 403 | panic!("Error: {}", err) 404 | } 405 | } 406 | 407 | #[cfg(feature = "serialize")] 408 | #[derive(Debug, PartialEq, Deserialize, Serialize)] 409 | struct KeyBindings { 410 | pub quit: KeyEvent, 411 | pub open: KeyEvent, 412 | } 413 | 414 | #[cfg(feature = "serialize")] 415 | impl KeyBindings { 416 | pub fn new(quit: KeyEvent, open: KeyEvent) -> Self { 417 | Self { quit, open } 418 | } 419 | } 420 | 421 | #[test] 422 | #[cfg(feature = "serialize")] 423 | fn should_serialize_key_bindings() { 424 | let temp = NamedTempFile::new().expect("Failed to open tempfile"); 425 | let keys = KeyBindings::new( 426 | KeyEvent::from(Key::Esc), 427 | KeyEvent::new(Key::Char('o'), KeyModifiers::CONTROL), 428 | ); 429 | let mut config = File::create(temp.path()).expect("Failed to open file for write"); 430 | serialize(&keys, &mut config); 431 | let mut readable = File::open(temp.path()).expect("Failed to open file for read"); 432 | let r_keys: KeyBindings = deserialize(&mut readable); 433 | assert_eq!(keys, r_keys); 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /src/core/injector.rs: -------------------------------------------------------------------------------- 1 | //! ## Injector 2 | //! 3 | //! properties injector 4 | 5 | use std::hash::Hash; 6 | 7 | use super::props::{AttrValue, Attribute}; 8 | 9 | /// An injector is a trait object which can provide properties to inject to a certain component. 10 | /// The injector is called each time a component is mounted, providing the id of the mounted 11 | /// component and may return a list of ([`Attribute`], [`AttrValue`]) to inject. 12 | pub trait Injector 13 | where 14 | ComponentId: Eq + PartialEq + Clone + Hash, 15 | { 16 | fn inject(&self, id: &ComponentId) -> Vec<(Attribute, AttrValue)>; 17 | } 18 | 19 | #[cfg(test)] 20 | mod test { 21 | 22 | use pretty_assertions::assert_eq; 23 | 24 | use super::*; 25 | use crate::mock::{MockComponentId, MockInjector}; 26 | 27 | #[test] 28 | fn should_create_a_trait_object_injector() { 29 | let injector = MockInjector; 30 | assert_eq!( 31 | injector.inject(&MockComponentId::InputBar), 32 | vec![( 33 | Attribute::Text, 34 | AttrValue::String(String::from("hello, world!")), 35 | )] 36 | ); 37 | assert_eq!(injector.inject(&MockComponentId::InputFoo), vec![]); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/core/mod.rs: -------------------------------------------------------------------------------- 1 | //! ## Core 2 | //! 3 | //! Core implements the core functionalities and types for tui-realm 4 | 5 | pub mod application; 6 | pub mod command; 7 | mod component; 8 | pub mod event; 9 | pub mod injector; 10 | pub mod props; 11 | mod state; 12 | pub mod subscription; 13 | mod view; 14 | 15 | // -- export 16 | pub use component::{Component, MockComponent}; 17 | pub use state::{State, StateValue}; 18 | // -- internal 19 | pub(crate) use subscription::Subscription; 20 | pub(crate) use view::WrappedComponent; 21 | pub use view::{View, ViewError}; 22 | 23 | // -- Update 24 | 25 | /// The update trait defines the prototype of the function to be used to handle the events coming from the View. 26 | pub trait Update 27 | where 28 | Msg: PartialEq, 29 | { 30 | /// update the current state handling a message from the view. 31 | /// This function may return a Message, 32 | /// so this function has to be intended to be call recursively if necessary 33 | fn update(&mut self, msg: Option) -> Option; 34 | } 35 | -------------------------------------------------------------------------------- /src/core/props/borders.rs: -------------------------------------------------------------------------------- 1 | //! ## Borders 2 | //! 3 | //! `Borders` is the module which defines the border properties 4 | 5 | use super::{Color, Style}; 6 | // Exports 7 | pub use crate::ratatui::widgets::{BorderType, Borders as BorderSides}; 8 | 9 | // -- Border 10 | 11 | /// Defines the properties of the borders 12 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 13 | pub struct Borders { 14 | pub sides: BorderSides, 15 | pub modifiers: BorderType, 16 | pub color: Color, 17 | } 18 | 19 | impl Default for Borders { 20 | fn default() -> Self { 21 | Borders { 22 | sides: BorderSides::ALL, 23 | modifiers: BorderType::Plain, 24 | color: Color::Reset, 25 | } 26 | } 27 | } 28 | 29 | impl Borders { 30 | /// Set border sides 31 | pub fn sides(mut self, borders: BorderSides) -> Self { 32 | self.sides = borders; 33 | self 34 | } 35 | 36 | pub fn modifiers(mut self, modifiers: BorderType) -> Self { 37 | self.modifiers = modifiers; 38 | self 39 | } 40 | 41 | pub fn color(mut self, color: Color) -> Self { 42 | self.color = color; 43 | self 44 | } 45 | 46 | /// Get Border style 47 | pub fn style(&self) -> Style { 48 | Style::default().fg(self.color) 49 | } 50 | } 51 | 52 | #[cfg(test)] 53 | mod test { 54 | 55 | use pretty_assertions::assert_eq; 56 | 57 | use super::*; 58 | 59 | #[test] 60 | fn borders() { 61 | // Default 62 | let props: Borders = Borders::default(); 63 | assert_eq!(props.sides, BorderSides::ALL); 64 | assert_eq!(props.modifiers, BorderType::Plain); 65 | assert_eq!(props.color, Color::Reset); 66 | // Build 67 | let props = Borders::default() 68 | .sides(BorderSides::TOP) 69 | .modifiers(BorderType::Double) 70 | .color(Color::Yellow); 71 | assert_eq!(props.sides, BorderSides::TOP); 72 | assert_eq!(props.modifiers, BorderType::Double); 73 | assert_eq!(props.color, Color::Yellow); 74 | // Get style 75 | let style: Style = props.style(); 76 | assert_eq!(*style.fg.as_ref().unwrap(), Color::Yellow); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/core/props/dataset.rs: -------------------------------------------------------------------------------- 1 | //! ## Dataset 2 | //! 3 | //! `Dataset` is a wrapper for tui dataset 4 | 5 | use super::Style; 6 | use crate::ratatui::symbols::Marker; 7 | use crate::ratatui::widgets::{Dataset as TuiDataset, GraphType}; 8 | 9 | /// Dataset describes a set of data for a chart 10 | #[derive(Clone, Debug)] 11 | pub struct Dataset { 12 | pub name: String, 13 | pub marker: Marker, 14 | pub graph_type: GraphType, 15 | pub style: Style, 16 | data: Vec<(f64, f64)>, 17 | } 18 | 19 | impl Default for Dataset { 20 | fn default() -> Self { 21 | Self { 22 | name: String::default(), 23 | marker: Marker::Dot, 24 | graph_type: GraphType::Scatter, 25 | style: Style::default(), 26 | data: Vec::default(), 27 | } 28 | } 29 | } 30 | 31 | impl Dataset { 32 | /// Set name for dataset 33 | pub fn name>(mut self, s: S) -> Self { 34 | self.name = s.into(); 35 | self 36 | } 37 | 38 | /// Set marker type for dataset 39 | pub fn marker(mut self, m: Marker) -> Self { 40 | self.marker = m; 41 | self 42 | } 43 | 44 | /// Set graphtype for dataset 45 | pub fn graph_type(mut self, g: GraphType) -> Self { 46 | self.graph_type = g; 47 | self 48 | } 49 | 50 | /// Set style for dataset 51 | pub fn style(mut self, s: Style) -> Self { 52 | self.style = s; 53 | self 54 | } 55 | 56 | /// Set data for dataset; must be a vec of (f64, f64) 57 | pub fn data(mut self, data: Vec<(f64, f64)>) -> Self { 58 | self.data = data; 59 | self 60 | } 61 | 62 | /// Push a record to the back of dataset 63 | pub fn push(&mut self, point: (f64, f64)) { 64 | self.data.push(point); 65 | } 66 | 67 | /// Pop last element of dataset 68 | pub fn pop(&mut self) { 69 | self.data.pop(); 70 | } 71 | 72 | /// Pop last element of dataset 73 | pub fn pop_front(&mut self) { 74 | if !self.data.is_empty() { 75 | self.data.remove(0); 76 | } 77 | } 78 | 79 | /// Get a reference to data 80 | pub fn get_data(&self) -> &[(f64, f64)] { 81 | &self.data 82 | } 83 | } 84 | 85 | impl PartialEq for Dataset { 86 | fn eq(&self, other: &Self) -> bool { 87 | self.name == other.name && self.data == other.data 88 | } 89 | } 90 | 91 | impl<'a> From<&'a Dataset> for TuiDataset<'a> { 92 | fn from(data: &'a Dataset) -> TuiDataset<'a> { 93 | TuiDataset::default() 94 | .name(data.name.clone()) 95 | .marker(data.marker) 96 | .graph_type(data.graph_type) 97 | .style(data.style) 98 | .data(data.get_data()) 99 | } 100 | } 101 | 102 | #[cfg(test)] 103 | mod test { 104 | 105 | use pretty_assertions::assert_eq; 106 | 107 | use super::*; 108 | use crate::ratatui::style::Color; 109 | 110 | #[test] 111 | fn dataset() { 112 | let mut dataset: Dataset = Dataset::default() 113 | .name("Avg temperatures") 114 | .graph_type(GraphType::Scatter) 115 | .marker(Marker::Braille) 116 | .style(Style::default().fg(Color::Cyan)) 117 | .data(vec![ 118 | (0.0, -1.0), 119 | (1.0, 1.0), 120 | (2.0, 3.0), 121 | (3.0, 7.0), 122 | (4.0, 11.0), 123 | (5.0, 15.0), 124 | (6.0, 17.0), 125 | (7.0, 17.0), 126 | (8.0, 13.0), 127 | (9.0, 9.0), 128 | (10.0, 4.0), 129 | (11.0, 0.0), 130 | ]); 131 | assert_eq!(dataset.name.as_str(), "Avg temperatures"); 132 | assert_eq!(dataset.style.fg.unwrap_or(Color::Reset), Color::Cyan); 133 | assert_eq!(dataset.get_data().len(), 12); 134 | // mut 135 | dataset.push((12.0, 1.0)); 136 | assert_eq!(dataset.get_data().len(), 13); 137 | dataset.pop(); 138 | assert_eq!(dataset.get_data().len(), 12); 139 | dataset.pop_front(); 140 | assert_eq!(dataset.get_data().len(), 11); 141 | // From 142 | let _: TuiDataset = TuiDataset::from(&dataset); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/core/props/direction.rs: -------------------------------------------------------------------------------- 1 | //! ## Direction 2 | //! 3 | //! Describes a generic direction 4 | 5 | /// Defines the 4 directions 6 | #[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] 7 | pub enum Direction { 8 | Down, 9 | Left, 10 | Right, 11 | Up, 12 | } 13 | -------------------------------------------------------------------------------- /src/core/props/layout.rs: -------------------------------------------------------------------------------- 1 | //! ## Layout 2 | //! 3 | //! This module exposes the layout type 4 | 5 | use crate::ratatui::layout::{Constraint, Direction, Layout as TuiLayout, Margin, Rect}; 6 | 7 | /// Defines how a layout has to be rendered 8 | #[derive(Debug, PartialEq, Clone, Eq)] 9 | pub struct Layout { 10 | constraints: Vec, 11 | direction: Direction, 12 | margin: Margin, 13 | } 14 | 15 | impl Default for Layout { 16 | fn default() -> Self { 17 | Self { 18 | constraints: Vec::new(), 19 | direction: Direction::Vertical, 20 | margin: Margin { 21 | horizontal: 0, 22 | vertical: 0, 23 | }, 24 | } 25 | } 26 | } 27 | 28 | impl Layout { 29 | // -- constructors 30 | 31 | pub fn constraints(mut self, constraints: &[Constraint]) -> Self { 32 | self.constraints = constraints.to_vec(); 33 | self 34 | } 35 | 36 | pub fn margin(mut self, margin: u16) -> Self { 37 | self.margin = Margin { 38 | horizontal: margin, 39 | vertical: margin, 40 | }; 41 | self 42 | } 43 | 44 | pub fn horizontal_margin(mut self, margin: u16) -> Self { 45 | self.margin.horizontal = margin; 46 | self 47 | } 48 | 49 | pub fn vertical_margin(mut self, margin: u16) -> Self { 50 | self.margin.vertical = margin; 51 | self 52 | } 53 | 54 | pub fn direction(mut self, direction: Direction) -> Self { 55 | self.direction = direction; 56 | self 57 | } 58 | 59 | // -- chunks 60 | 61 | /// Split an `Area` into chunks using the current layout configuration 62 | pub fn chunks(&self, area: Rect) -> Vec { 63 | TuiLayout::default() 64 | .direction(self.direction) 65 | .horizontal_margin(self.margin.horizontal) 66 | .vertical_margin(self.margin.vertical) 67 | .constraints::<&[Constraint]>(self.constraints.as_ref()) 68 | .split(area) 69 | .to_vec() 70 | } 71 | } 72 | 73 | #[cfg(test)] 74 | mod test { 75 | 76 | use super::*; 77 | 78 | #[test] 79 | fn should_build_a_layout() { 80 | let area = Rect::default(); 81 | let layout = Layout::default() 82 | .margin(10) 83 | .horizontal_margin(15) 84 | .vertical_margin(12) 85 | .direction(Direction::Vertical) 86 | .constraints(&[ 87 | Constraint::Length(3), 88 | Constraint::Length(3), 89 | Constraint::Length(1), 90 | ]); 91 | assert_eq!(layout.chunks(area).len(), 3); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/core/props/shape.rs: -------------------------------------------------------------------------------- 1 | //! ## Shape 2 | //! 3 | //! This module exposes the shape attribute type 4 | 5 | use super::Color; 6 | use crate::ratatui::widgets::canvas::{Line, Map, Rectangle}; 7 | 8 | /// Describes the shape to draw on the canvas 9 | #[derive(Clone, Debug)] 10 | pub enum Shape { 11 | Label((f64, f64, String, Color)), 12 | Layer, 13 | Line(Line), 14 | Map(Map), 15 | Points((Vec<(f64, f64)>, Color)), 16 | Rectangle(Rectangle), 17 | } 18 | 19 | impl PartialEq for Shape { 20 | fn eq(&self, other: &Self) -> bool { 21 | match (self, other) { 22 | (Shape::Label(a), Shape::Label(b)) => a == b, 23 | (Shape::Layer, Shape::Layer) => true, 24 | (Shape::Line(a), Shape::Line(b)) => { 25 | a.x1 == b.x1 && a.x2 == b.x2 && a.y1 == b.y1 && a.y2 == b.y2 && a.color == b.color 26 | } 27 | (Shape::Map(a), Shape::Map(b)) => a.color == b.color, 28 | (Shape::Points(a), Shape::Points(b)) => a == b, 29 | (Shape::Rectangle(a), Shape::Rectangle(b)) => { 30 | a.x == b.x 31 | && a.y == b.y 32 | && a.width == b.width 33 | && a.height == b.height 34 | && a.color == b.color 35 | } 36 | (_, _) => false, 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/core/props/texts.rs: -------------------------------------------------------------------------------- 1 | //! ## Texts 2 | //! 3 | //! `Texts` is the module which defines the texts properties for components. 4 | //! It also provides some helpers and builders to facilitate the use of builders. 5 | 6 | use crate::ratatui::style::{Color, Modifier}; 7 | 8 | // -- Text parts 9 | 10 | /// ### TextSpan 11 | /// 12 | /// TextSpan is a "cell" of text with its attributes 13 | #[derive(Clone, Debug, PartialEq, Eq)] 14 | pub struct TextSpan { 15 | pub content: String, 16 | pub fg: Color, 17 | pub bg: Color, 18 | pub modifiers: Modifier, 19 | } 20 | 21 | impl TextSpan { 22 | /// Instantiate a new `TextSpan` 23 | pub fn new>(text: S) -> Self { 24 | Self { 25 | content: text.into(), 26 | fg: Color::Reset, 27 | bg: Color::Reset, 28 | modifiers: Modifier::empty(), 29 | } 30 | } 31 | 32 | pub fn fg(mut self, fg: Color) -> Self { 33 | self.fg = fg; 34 | self 35 | } 36 | 37 | pub fn bg(mut self, bg: Color) -> Self { 38 | self.bg = bg; 39 | self 40 | } 41 | 42 | /// Set bold property for text 43 | pub fn bold(mut self) -> Self { 44 | self.modifiers |= Modifier::BOLD; 45 | self 46 | } 47 | 48 | /// Set italic property for text 49 | pub fn italic(mut self) -> Self { 50 | self.modifiers |= Modifier::ITALIC; 51 | self 52 | } 53 | 54 | /// Set underlined property for text 55 | pub fn underlined(mut self) -> Self { 56 | self.modifiers |= Modifier::UNDERLINED; 57 | self 58 | } 59 | 60 | /// Set slow_blink property for text 61 | pub fn slow_blink(mut self) -> Self { 62 | self.modifiers |= Modifier::SLOW_BLINK; 63 | self 64 | } 65 | 66 | /// Set rapid_blink property for text 67 | pub fn rapid_blink(mut self) -> Self { 68 | self.modifiers |= Modifier::RAPID_BLINK; 69 | self 70 | } 71 | 72 | /// Set reversed property for text 73 | pub fn reversed(mut self) -> Self { 74 | self.modifiers |= Modifier::REVERSED; 75 | self 76 | } 77 | 78 | /// Set strikethrough property for text 79 | pub fn strikethrough(mut self) -> Self { 80 | self.modifiers |= Modifier::CROSSED_OUT; 81 | self 82 | } 83 | } 84 | 85 | impl Default for TextSpan { 86 | fn default() -> Self { 87 | Self::new(String::default()) 88 | } 89 | } 90 | 91 | impl From for TextSpan 92 | where 93 | S: Into, 94 | { 95 | fn from(txt: S) -> Self { 96 | TextSpan::new(txt) 97 | } 98 | } 99 | 100 | /// Table represents a list of rows with a list of columns of text spans 101 | pub type Table = Vec>; 102 | 103 | /// Table builder is a helper to make it easier to build text tables 104 | pub struct TableBuilder { 105 | table: Option, 106 | } 107 | 108 | impl TableBuilder { 109 | /// Add a column to the last row 110 | pub fn add_col(&mut self, span: TextSpan) -> &mut Self { 111 | if let Some(table) = self.table.as_mut() { 112 | if let Some(row) = table.last_mut() { 113 | row.push(span); 114 | } 115 | } 116 | self 117 | } 118 | 119 | /// Add a new row to the table 120 | pub fn add_row(&mut self) -> &mut Self { 121 | if let Some(table) = self.table.as_mut() { 122 | table.push(vec![]); 123 | } 124 | self 125 | } 126 | 127 | /// Take table out of builder 128 | /// Don't call this method twice for any reasons! 129 | pub fn build(&mut self) -> Table { 130 | self.table.take().unwrap() 131 | } 132 | } 133 | 134 | impl Default for TableBuilder { 135 | fn default() -> Self { 136 | TableBuilder { 137 | table: Some(vec![vec![]]), 138 | } 139 | } 140 | } 141 | 142 | #[cfg(test)] 143 | mod test { 144 | 145 | use pretty_assertions::assert_eq; 146 | 147 | use super::*; 148 | 149 | #[test] 150 | fn tables() { 151 | let table: Table = TableBuilder::default() 152 | .add_col(TextSpan::from("name")) 153 | .add_col(TextSpan::from("age")) 154 | .add_row() 155 | .add_col(TextSpan::from("christian")) 156 | .add_col(TextSpan::from("23")) 157 | .add_row() 158 | .add_col(TextSpan::from("omar")) 159 | .add_col(TextSpan::from("25")) 160 | .add_row() 161 | .add_row() 162 | .add_col(TextSpan::from("pippo")) 163 | .build(); 164 | // Verify table 165 | assert_eq!(table.len(), 5); // 5 spans 166 | assert_eq!(table.first().unwrap().len(), 2); // 2 cols 167 | assert_eq!(table.get(1).unwrap().len(), 2); // 2 cols 168 | assert_eq!( 169 | table.get(1).unwrap().first().unwrap().content.as_str(), 170 | "christian" 171 | ); // check content 172 | assert_eq!(table.get(2).unwrap().len(), 2); // 2 cols 173 | assert_eq!(table.get(3).unwrap().len(), 0); // 0 cols 174 | assert_eq!(table.get(4).unwrap().len(), 1); // 1 cols 175 | } 176 | 177 | #[test] 178 | fn text_span() { 179 | // default 180 | let span: TextSpan = TextSpan::default(); 181 | assert_eq!(span.content.as_str(), ""); 182 | assert_eq!(span.modifiers, Modifier::empty()); 183 | assert_eq!(span.fg, Color::Reset); 184 | assert_eq!(span.bg, Color::Reset); 185 | // from str 186 | let span: TextSpan = TextSpan::from("Hello!"); 187 | assert_eq!(span.content.as_str(), "Hello!"); 188 | assert_eq!(span.modifiers, Modifier::empty()); 189 | assert_eq!(span.fg, Color::Reset); 190 | assert_eq!(span.bg, Color::Reset); 191 | // From String 192 | let span: TextSpan = TextSpan::from(String::from("omar")); 193 | assert_eq!(span.content.as_str(), "omar"); 194 | assert_eq!(span.fg, Color::Reset); 195 | assert_eq!(span.bg, Color::Reset); 196 | // With attributes 197 | let span: TextSpan = TextSpan::new("Error") 198 | .bg(Color::Red) 199 | .fg(Color::Black) 200 | .bold() 201 | .italic() 202 | .underlined() 203 | .rapid_blink() 204 | .rapid_blink() 205 | .slow_blink() 206 | .strikethrough() 207 | .reversed(); 208 | assert_eq!(span.content.as_str(), "Error"); 209 | assert_eq!(span.fg, Color::Black); 210 | assert_eq!(span.bg, Color::Red); 211 | assert!(span.modifiers.intersects(Modifier::BOLD)); 212 | assert!(span.modifiers.intersects(Modifier::ITALIC)); 213 | assert!(span.modifiers.intersects(Modifier::UNDERLINED)); 214 | assert!(span.modifiers.intersects(Modifier::SLOW_BLINK)); 215 | assert!(span.modifiers.intersects(Modifier::RAPID_BLINK)); 216 | assert!(span.modifiers.intersects(Modifier::REVERSED)); 217 | assert!(span.modifiers.intersects(Modifier::CROSSED_OUT)); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/core/state.rs: -------------------------------------------------------------------------------- 1 | //! ## State 2 | //! 3 | //! This module exposes the state type and values 4 | 5 | use std::collections::{HashMap, LinkedList}; 6 | 7 | use crate::props::Color; 8 | use crate::utils::{Email, PhoneNumber}; 9 | 10 | /// State describes a component state 11 | #[derive(Debug, PartialEq, Clone)] 12 | pub enum State { 13 | One(StateValue), 14 | Tup2((StateValue, StateValue)), 15 | Tup3((StateValue, StateValue, StateValue)), 16 | Tup4((StateValue, StateValue, StateValue, StateValue)), 17 | Vec(Vec), 18 | Map(HashMap), 19 | Linked(LinkedList), 20 | None, 21 | } 22 | 23 | /// StateValue describes the value contained in a State 24 | #[derive(Debug, PartialEq, Clone)] 25 | pub enum StateValue { 26 | None, 27 | Bool(bool), 28 | U8(u8), 29 | U16(u16), 30 | U32(u32), 31 | U64(u64), 32 | U128(u128), 33 | Usize(usize), 34 | I8(i8), 35 | I16(i16), 36 | I32(i32), 37 | I64(i64), 38 | I128(i128), 39 | Isize(isize), 40 | F64(f64), 41 | String(String), 42 | // -- input types 43 | Color(Color), 44 | Email(Email), 45 | PhoneNumber(PhoneNumber), 46 | } 47 | 48 | impl State { 49 | pub fn unwrap_one(self) -> StateValue { 50 | match self { 51 | Self::One(val) => val, 52 | state => panic!("Could not unwrap {state:?} as `One`"), 53 | } 54 | } 55 | 56 | pub fn unwrap_tup2(self) -> (StateValue, StateValue) { 57 | match self { 58 | Self::Tup2(val) => val, 59 | state => panic!("Could not unwrap {state:?} as `Tup2`"), 60 | } 61 | } 62 | 63 | pub fn unwrap_tup3(self) -> (StateValue, StateValue, StateValue) { 64 | match self { 65 | Self::Tup3(val) => val, 66 | state => panic!("Could not unwrap {state:?} as `Tup3`"), 67 | } 68 | } 69 | 70 | pub fn unwrap_tup4(self) -> (StateValue, StateValue, StateValue, StateValue) { 71 | match self { 72 | Self::Tup4(val) => val, 73 | state => panic!("Could not unwrap {state:?} as `Tup4`"), 74 | } 75 | } 76 | 77 | pub fn unwrap_vec(self) -> Vec { 78 | match self { 79 | Self::Vec(val) => val, 80 | state => panic!("Could not unwrap {state:?} as `Vec`"), 81 | } 82 | } 83 | 84 | pub fn unwrap_map(self) -> HashMap { 85 | match self { 86 | Self::Map(val) => val, 87 | state => panic!("Could not unwrap {state:?} as `Map`"), 88 | } 89 | } 90 | 91 | pub fn unwrap_linked(self) -> LinkedList { 92 | match self { 93 | Self::Linked(val) => val, 94 | state => panic!("Could not unwrap {state:?} as `Linked`"), 95 | } 96 | } 97 | 98 | /// Returns whether `State` is `State::None` 99 | pub fn is_none(&self) -> bool { 100 | matches!(self, Self::None) 101 | } 102 | } 103 | 104 | impl StateValue { 105 | /// Returns whether `StateValue` is `StateValue::None` 106 | pub fn is_none(&self) -> bool { 107 | matches!(self, Self::None) 108 | } 109 | 110 | pub fn unwrap_bool(self) -> bool { 111 | match self { 112 | Self::Bool(val) => val, 113 | value => panic!("Could not unwrap {value:?} as `Bool`"), 114 | } 115 | } 116 | 117 | pub fn unwrap_u8(self) -> u8 { 118 | match self { 119 | Self::U8(val) => val, 120 | value => panic!("Could not unwrap {value:?} as `U8`"), 121 | } 122 | } 123 | 124 | pub fn unwrap_u16(self) -> u16 { 125 | match self { 126 | Self::U16(val) => val, 127 | value => panic!("Could not unwrap {value:?} as `U16`"), 128 | } 129 | } 130 | 131 | pub fn unwrap_u32(self) -> u32 { 132 | match self { 133 | Self::U32(val) => val, 134 | value => panic!("Could not unwrap {value:?} as `U32`"), 135 | } 136 | } 137 | 138 | pub fn unwrap_u64(self) -> u64 { 139 | match self { 140 | Self::U64(val) => val, 141 | value => panic!("Could not unwrap {value:?} as `U64`"), 142 | } 143 | } 144 | 145 | pub fn unwrap_u128(self) -> u128 { 146 | match self { 147 | Self::U128(val) => val, 148 | value => panic!("Could not unwrap {value:?} as `U128`"), 149 | } 150 | } 151 | 152 | pub fn unwrap_usize(self) -> usize { 153 | match self { 154 | Self::Usize(val) => val, 155 | value => panic!("Could not unwrap {value:?} as `Usize`"), 156 | } 157 | } 158 | 159 | pub fn unwrap_i8(self) -> i8 { 160 | match self { 161 | Self::I8(val) => val, 162 | value => panic!("Could not unwrap {value:?} as `I8`"), 163 | } 164 | } 165 | 166 | pub fn unwrap_i16(self) -> i16 { 167 | match self { 168 | Self::I16(val) => val, 169 | value => panic!("Could not unwrap {value:?} as `I16`"), 170 | } 171 | } 172 | 173 | pub fn unwrap_i32(self) -> i32 { 174 | match self { 175 | Self::I32(val) => val, 176 | value => panic!("Could not unwrap {value:?} as `I32`"), 177 | } 178 | } 179 | 180 | pub fn unwrap_i64(self) -> i64 { 181 | match self { 182 | Self::I64(val) => val, 183 | value => panic!("Could not unwrap {value:?} as `I64`"), 184 | } 185 | } 186 | 187 | pub fn unwrap_i128(self) -> i128 { 188 | match self { 189 | Self::I128(val) => val, 190 | value => panic!("Could not unwrap {value:?} as `I128`"), 191 | } 192 | } 193 | 194 | pub fn unwrap_isize(self) -> isize { 195 | match self { 196 | Self::Isize(val) => val, 197 | value => panic!("Could not unwrap {value:?} as `Isize`"), 198 | } 199 | } 200 | 201 | pub fn unwrap_f64(self) -> f64 { 202 | match self { 203 | Self::F64(val) => val, 204 | value => panic!("Could not unwrap {value:?} as `F64`"), 205 | } 206 | } 207 | 208 | pub fn unwrap_string(self) -> String { 209 | match self { 210 | Self::String(val) => val, 211 | value => panic!("Could not unwrap {value:?} as `String`"), 212 | } 213 | } 214 | 215 | pub fn unwrap_color(self) -> Color { 216 | match self { 217 | Self::Color(val) => val, 218 | value => panic!("Could not unwrap {value:?} as `Color`"), 219 | } 220 | } 221 | 222 | pub fn unwrap_email(self) -> Email { 223 | match self { 224 | Self::Email(val) => val, 225 | value => panic!("Could not unwrap {value:?} as `Email`"), 226 | } 227 | } 228 | 229 | pub fn unwrap_phone_number(self) -> PhoneNumber { 230 | match self { 231 | Self::PhoneNumber(val) => val, 232 | value => panic!("Could not unwrap {value:?} as `PhoneNumber`"), 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_cfg))] 2 | 3 | //! # tui-realm 4 | //! 5 | //! tui-realm is a **framework** for **[ratatui](https://github.com/ratatui-org/ratatui)** 6 | //! to simplify the implementation of terminal user interfaces adding the possibility to work 7 | //! with re-usable components with properties and states, as you'd do in React. But that's not all: 8 | //! the components communicate with the ui engine via a system based on **Messages** and **Events**, 9 | //! providing you with the possibility to implement `update` routines as happens in Elm. 10 | //! 11 | //! In addition, the components are organized inside the **View**, which manages mounting/umounting, 12 | //! focus and event forwarding for you. 13 | //! 14 | //! tui-realm also comes with a standard library of components, which can be added to your dependencies, 15 | //! that you may find very useful. 16 | //! 17 | //! ## Get started 🏁 18 | //! 19 | //! > ⚠️ Warning: currently tui-realm supports these backends: crossterm, termion 20 | //! 21 | //! ### Add tui-realm to your Cargo.toml 🦀 22 | //! 23 | //! If you want the default features, just add tuirealm 1.x version: 24 | //! 25 | //! ```toml 26 | //! tuirealm = "3" 27 | //! ``` 28 | //! 29 | //! otherwise you can specify the features you want to add: 30 | //! 31 | //! ```toml 32 | //! tuirealm = { version = "3", default-features = false, features = [ "derive", "serialize", "termion" ] } 33 | //! ``` 34 | //! 35 | //! Supported features are: 36 | //! 37 | //! - `derive` (*default*): add the `#[derive(MockComponent)]` proc macro to automatically implement `MockComponent` for `Component`. [Read more](https://github.com/veeso/tuirealm_derive). 38 | //! - `async-ports`: add support for async ports 39 | //! - `serialize`: add the serialize/deserialize trait implementation for `KeyEvent` and `Key`. 40 | //! - `crossterm`: use the [crossterm](https://github.com/crossterm-rs/crossterm) terminal backend 41 | //! - `termion`: use the [termion](https://github.com/redox-os/termion) terminal backend 42 | //! 43 | //! ### Create a tui-realm application 🪂 44 | //! 45 | //! You can read the guide to get started with tui-realm on [Github](https://github.com/veeso/tui-realm/blob/main/docs/en/get-started.md) 46 | //! 47 | //! ### Run examples 🔍 48 | //! 49 | //! Still confused about how tui-realm works? Don't worry, try with the examples: 50 | //! 51 | //! - [demo](https://github.com/veeso/tui-realm/blob/main/examples/demo.rs): a simple application which shows how tui-realm works 52 | //! 53 | //! ```sh 54 | //! cargo run --example demo 55 | //! ``` 56 | //! 57 | 58 | #![doc(html_playground_url = "https://play.rust-lang.org")] 59 | #![doc( 60 | html_favicon_url = "https://raw.githubusercontent.com/veeso/tui-realm/main/docs/images/cargo/tui-realm-128.png" 61 | )] 62 | #![doc( 63 | html_logo_url = "https://raw.githubusercontent.com/veeso/tui-realm/main/docs/images/cargo/tui-realm-512.png" 64 | )] 65 | 66 | #[macro_use] 67 | extern crate lazy_regex; 68 | extern crate self as tuirealm; 69 | #[cfg(feature = "derive")] 70 | #[allow(unused_imports)] 71 | #[macro_use] 72 | extern crate tuirealm_derive; 73 | 74 | mod core; 75 | pub mod listener; 76 | pub mod macros; 77 | #[cfg(test)] 78 | pub mod mock; 79 | pub mod ratatui; 80 | pub mod terminal; 81 | pub mod utils; 82 | // export async trait for async-ports 83 | #[cfg(feature = "async-ports")] 84 | #[cfg_attr(docsrs, doc(cfg(feature = "async-ports")))] 85 | pub use async_trait::async_trait; 86 | pub use listener::{EventListenerCfg, ListenerError}; 87 | // -- derive 88 | #[cfg(feature = "derive")] 89 | #[doc(hidden)] 90 | pub use tuirealm_derive::*; 91 | 92 | pub use self::core::application::{self, Application, ApplicationError, PollStrategy}; 93 | pub use self::core::event::{self, Event, NoUserEvent}; 94 | pub use self::core::injector::Injector; 95 | pub use self::core::props::{self, AttrValue, Attribute, Props}; 96 | pub use self::core::subscription::{EventClause as SubEventClause, Sub, SubClause}; 97 | pub use self::core::{Component, MockComponent, State, StateValue, Update, ViewError, command}; 98 | pub use self::ratatui::Frame; 99 | -------------------------------------------------------------------------------- /src/listener/async_ticker.rs: -------------------------------------------------------------------------------- 1 | use super::{ListenerResult, PollAsync}; 2 | use crate::Event; 3 | 4 | /// [`PollAsync`] implementation to have a Async-Port for emitting [`Event::Tick`]. 5 | /// 6 | /// This will emit a [`Event::Tick`] on every [`poll`](Self::poll) call, relying on the [`tick_interval`](super::EventListener) to handle intervals. 7 | #[derive(Debug, Clone, Copy)] 8 | pub struct AsyncTicker(); 9 | 10 | impl AsyncTicker { 11 | pub fn new() -> Self { 12 | Self() 13 | } 14 | } 15 | 16 | #[async_trait::async_trait] 17 | impl PollAsync for AsyncTicker 18 | where 19 | U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, 20 | { 21 | async fn poll(&mut self) -> ListenerResult>> { 22 | Ok(Some(Event::Tick)) 23 | } 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use super::AsyncTicker; 29 | use crate::listener::PollAsync; 30 | use crate::{Event, NoUserEvent}; 31 | 32 | #[tokio::test] 33 | async fn should_emit_tick_on_every_poll() { 34 | let mut ticker = AsyncTicker::new(); 35 | assert_eq!(ticker.poll().await, Ok(Some(Event::::Tick))); 36 | assert_eq!(ticker.poll().await, Ok(Some(Event::::Tick))); 37 | assert_eq!(ticker.poll().await, Ok(Some(Event::::Tick))); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/listener/builder.rs: -------------------------------------------------------------------------------- 1 | //! ## Builder 2 | //! 3 | //! This module exposes the EventListenerCfg which is used to build the event listener 4 | 5 | #[cfg(feature = "async-ports")] 6 | use tokio::runtime::Handle; 7 | 8 | #[cfg(feature = "async-ports")] 9 | use super::AsyncPort; 10 | use super::{Duration, EventListener, ListenerError, Poll, SyncPort}; 11 | 12 | /// The event listener configurator is used to setup an event listener. 13 | /// Once you're done with configuration just call `EventListenerCfg::start` and the event listener will start and the listener 14 | /// will be returned. 15 | pub struct EventListenerCfg 16 | where 17 | U: Eq + PartialEq + Clone + PartialOrd + Send, 18 | { 19 | sync_ports: Vec>, 20 | #[cfg(feature = "async-ports")] 21 | async_ports: Vec>, 22 | #[cfg(feature = "async-ports")] 23 | handle: Option, 24 | tick_interval: Option, 25 | #[cfg(feature = "async-ports")] 26 | async_tick: bool, 27 | poll_timeout: Duration, 28 | } 29 | 30 | impl Default for EventListenerCfg 31 | where 32 | U: Eq + PartialEq + Clone + PartialOrd + Send, 33 | { 34 | fn default() -> Self { 35 | Self { 36 | sync_ports: Vec::default(), 37 | #[cfg(feature = "async-ports")] 38 | async_ports: Vec::default(), 39 | #[cfg(feature = "async-ports")] 40 | handle: None, 41 | poll_timeout: Duration::from_millis(10), 42 | tick_interval: None, 43 | #[cfg(feature = "async-ports")] 44 | async_tick: false, 45 | } 46 | } 47 | } 48 | 49 | impl EventListenerCfg 50 | where 51 | U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, 52 | { 53 | /// Create the event listener with the parameters provided and start the workers. 54 | /// 55 | /// # Errors 56 | /// 57 | /// - if there are async ports defined, but no handle is set. 58 | #[allow(unused_mut)] // mutability is necessary when "async-ports" is active 59 | pub(crate) fn start(mut self) -> Result, ListenerError> { 60 | #[cfg(feature = "async-ports")] 61 | let start_async = 62 | !self.async_ports.is_empty() || (self.async_tick && self.tick_interval.is_some()); 63 | #[cfg(not(feature = "async-ports"))] 64 | let store_tx = false; 65 | #[cfg(feature = "async-ports")] 66 | let store_tx = start_async; 67 | #[cfg(not(feature = "async-ports"))] 68 | let sync_tick_interval = self.tick_interval; 69 | #[cfg(feature = "async-ports")] 70 | let sync_tick_interval = self.tick_interval.take_if(|_| !self.async_tick); 71 | let mut res = EventListener::new(self.poll_timeout); 72 | 73 | // dont start a sync worker if there are no sync tasks 74 | if !self.sync_ports.is_empty() || sync_tick_interval.is_some() { 75 | res = res.start(self.sync_ports, sync_tick_interval, store_tx); 76 | } 77 | 78 | // dont start a taskpool without any actual tasks 79 | #[cfg(feature = "async-ports")] 80 | if start_async { 81 | let Some(handle) = self.handle else { 82 | return Err(ListenerError::NoHandle); 83 | }; 84 | res = res.start_async( 85 | self.async_ports, 86 | handle, 87 | self.tick_interval.take_if(|_| self.async_tick), 88 | ); 89 | } 90 | 91 | Ok(res) 92 | } 93 | 94 | /// Set poll timeout. 95 | /// Poll timeout is the maximum time to wait when fetching the thread receiver. 96 | /// 97 | /// > Panics if timeout is 0 98 | pub fn poll_timeout(mut self, timeout: Duration) -> Self { 99 | if timeout == Duration::ZERO { 100 | panic!( 101 | "poll timeout cannot be 0 (see )" 102 | ) 103 | } 104 | self.poll_timeout = timeout; 105 | self 106 | } 107 | 108 | /// Defines the tick interval for the event listener. 109 | /// If an interval is defined, this will also enable the `Tick` event. 110 | pub fn tick_interval(mut self, interval: Duration) -> Self { 111 | self.tick_interval = Some(interval); 112 | self 113 | } 114 | 115 | /// Add a new [`SyncPort`] (Poll, Interval) to the the event listener. 116 | /// 117 | /// The interval is the amount of time between each [`Poll::poll`] call. 118 | /// The max_poll is the maximum amount of times the port should be polled in a single poll. 119 | pub fn add_port(self, poll: Box>, interval: Duration, max_poll: usize) -> Self { 120 | self.port(SyncPort::new(poll, interval, max_poll)) 121 | } 122 | 123 | /// Add a new [`SyncPort`] to the the event listener 124 | /// 125 | /// The [`SyncPort`] needs to be manually constructed, unlike [`Self::add_port`] 126 | pub fn port(mut self, port: SyncPort) -> Self { 127 | self.sync_ports.push(port); 128 | self 129 | } 130 | 131 | #[cfg(feature = "crossterm")] 132 | /// Add to the event listener the default crossterm input listener [`crate::terminal::CrosstermInputListener`] 133 | /// 134 | /// The interval is the amount of time between each [`Poll::poll`] call. 135 | /// The max_poll is the maximum amount of times the port should be polled in a `interval`. 136 | pub fn crossterm_input_listener(self, interval: Duration, max_poll: usize) -> Self { 137 | self.add_port( 138 | Box::new(crate::terminal::CrosstermInputListener::::new(interval)), 139 | interval, 140 | max_poll, 141 | ) 142 | } 143 | 144 | #[cfg(all(feature = "crossterm", feature = "async-ports"))] 145 | /// Add to the async event listener the default crossterm input listener [`crate::terminal::CrosstermAsyncStream`] 146 | /// 147 | /// The `interval` is the amount of time between each [`PollAsync::poll`](super::PollAsync) call. 148 | /// The `max_poll` is the maximum amount of times the port should be polled in a single `interval`. 149 | /// 150 | /// It is recommended to set `interval` to `0` to have immediate events. 151 | pub fn async_crossterm_input_listener(self, interval: Duration, max_poll: usize) -> Self { 152 | self.add_async_port( 153 | Box::new(crate::terminal::CrosstermAsyncStream::new()), 154 | interval, 155 | max_poll, 156 | ) 157 | } 158 | 159 | #[cfg(feature = "termion")] 160 | /// Add to the event listener the default termion input listener [`crate::terminal::TermionInputListener`] 161 | /// 162 | /// The interval is the amount of time between each [`Poll::poll`] call. 163 | /// The max_poll is the maximum amount of times the port should be polled in a `interval`. 164 | pub fn termion_input_listener(self, interval: Duration, max_poll: usize) -> Self { 165 | self.add_port( 166 | Box::new(crate::terminal::TermionInputListener::::new(interval)), 167 | interval, 168 | max_poll, 169 | ) 170 | } 171 | } 172 | 173 | /// Implementations for feature `async-ports` 174 | impl EventListenerCfg 175 | where 176 | U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, 177 | { 178 | /// Add a new [`AsyncPort`] (Poll, Interval) to the the event listener. 179 | /// 180 | /// The interval is the amount of time between each [`super::PollAsync::poll`] call. 181 | /// The max_poll is the maximum amount of times the port should be polled in a single poll. 182 | #[cfg(feature = "async-ports")] 183 | #[cfg_attr(docsrs, doc(cfg(feature = "async-ports")))] 184 | pub fn add_async_port( 185 | self, 186 | poll: Box>, 187 | interval: Duration, 188 | max_poll: usize, 189 | ) -> Self { 190 | self.async_port(AsyncPort::new(poll, interval, max_poll)) 191 | } 192 | 193 | /// Add a new [`AsyncPort`] to the the event listener. 194 | /// 195 | /// The [`AsyncPort`] needs to be manually constructed, unlike [`Self::add_async_port`]. 196 | #[cfg(feature = "async-ports")] 197 | #[cfg_attr(docsrs, doc(cfg(feature = "async-ports")))] 198 | pub fn async_port(mut self, port: AsyncPort) -> Self { 199 | self.async_ports.push(port); 200 | self 201 | } 202 | 203 | /// Set the async runtime handle to use to spawn the async ports on. 204 | /// 205 | /// If this is not set, a Error is returned on [`start`](Self::start). 206 | #[cfg(feature = "async-ports")] 207 | #[cfg_attr(docsrs, doc(cfg(feature = "async-ports")))] 208 | pub fn with_handle(mut self, handle: tokio::runtime::Handle) -> Self { 209 | self.handle = Some(handle); 210 | self 211 | } 212 | 213 | /// Change the way [`Event::Tick`](crate::Event::Tick) is emitted from being on a [`SyncPort`] to be a [`AsyncPort`]. 214 | #[cfg(feature = "async-ports")] 215 | #[cfg_attr(docsrs, doc(cfg(feature = "async-ports")))] 216 | pub fn async_tick(mut self, value: bool) -> Self { 217 | self.async_tick = value; 218 | self 219 | } 220 | } 221 | 222 | #[cfg(test)] 223 | mod test { 224 | 225 | use pretty_assertions::assert_eq; 226 | 227 | use super::*; 228 | use crate::mock::{MockEvent, MockPoll}; 229 | 230 | #[test] 231 | #[cfg(feature = "crossterm")] 232 | fn should_configure_and_start_event_listener_crossterm() { 233 | let builder = EventListenerCfg::::default(); 234 | assert!(builder.sync_ports.is_empty()); 235 | assert!(builder.tick_interval.is_none()); 236 | assert_eq!(builder.poll_timeout, Duration::from_millis(10)); 237 | let builder = builder.tick_interval(Duration::from_secs(10)); 238 | assert_eq!(builder.tick_interval.unwrap(), Duration::from_secs(10)); 239 | let builder = builder.poll_timeout(Duration::from_millis(50)); 240 | assert_eq!(builder.poll_timeout, Duration::from_millis(50)); 241 | let builder = builder 242 | .crossterm_input_listener(Duration::from_millis(200), 1) 243 | .add_port(Box::new(MockPoll::default()), Duration::from_secs(300), 1); 244 | assert_eq!(builder.sync_ports.len(), 2); 245 | let mut listener = builder.start().unwrap(); 246 | assert!(listener.stop().is_ok()); 247 | } 248 | 249 | #[test] 250 | #[cfg(feature = "termion")] 251 | fn should_configure_and_start_event_listener_termion() { 252 | let builder = EventListenerCfg::::default(); 253 | assert!(builder.sync_ports.is_empty()); 254 | assert!(builder.tick_interval.is_none()); 255 | assert_eq!(builder.poll_timeout, Duration::from_millis(10)); 256 | let builder = builder.tick_interval(Duration::from_secs(10)); 257 | assert_eq!(builder.tick_interval.unwrap(), Duration::from_secs(10)); 258 | let builder = builder.poll_timeout(Duration::from_millis(50)); 259 | assert_eq!(builder.poll_timeout, Duration::from_millis(50)); 260 | let builder = builder 261 | .termion_input_listener(Duration::from_millis(200), 1) 262 | .add_port(Box::new(MockPoll::default()), Duration::from_secs(300), 1); 263 | assert_eq!(builder.sync_ports.len(), 2); 264 | let mut listener = builder.start().unwrap(); 265 | assert!(listener.stop().is_ok()); 266 | } 267 | 268 | #[test] 269 | #[should_panic] 270 | fn event_listener_cfg_should_panic_with_poll_timeout_zero() { 271 | let _ = EventListenerCfg::::default() 272 | .poll_timeout(Duration::from_secs(0)) 273 | .start(); 274 | } 275 | 276 | #[test] 277 | fn should_add_port_via_port_1() { 278 | let builder = EventListenerCfg::::default(); 279 | assert!(builder.sync_ports.is_empty()); 280 | let builder = builder.port(SyncPort::new( 281 | Box::new(MockPoll::default()), 282 | Duration::from_millis(1), 283 | 1, 284 | )); 285 | assert_eq!(builder.sync_ports.len(), 1); 286 | } 287 | 288 | #[test] 289 | #[cfg(feature = "async-ports")] 290 | fn should_error_without_handle() { 291 | use crate::mock::MockPollAsync; 292 | 293 | let port = AsyncPort::::new( 294 | Box::new(MockPollAsync::default()), 295 | Duration::from_secs(5), 296 | 1, 297 | ); 298 | 299 | let builder = EventListenerCfg::::default(); 300 | assert!(builder.async_ports.is_empty()); 301 | let builder = builder.async_port(port); 302 | 303 | assert_eq!(builder.start().unwrap_err(), ListenerError::NoHandle); 304 | } 305 | 306 | #[tokio::test] 307 | #[cfg(feature = "async-ports")] 308 | async fn should_spawn_async_ticker() { 309 | use tokio::time::sleep; 310 | 311 | use crate::Event; 312 | 313 | let builder = EventListenerCfg::::default() 314 | .with_handle(Handle::current()) 315 | .async_tick(true) 316 | .tick_interval(Duration::from_millis(10)); 317 | assert!(builder.async_ports.is_empty()); 318 | assert_eq!(Handle::current().metrics().num_alive_tasks(), 0); 319 | 320 | let mut listener = builder.start().unwrap(); 321 | assert_eq!(Handle::current().metrics().num_alive_tasks(), 1); 322 | assert!(listener.thread.is_none()); // there are no sync ports or tasks, so no sync worker 323 | assert!(listener.taskpool.is_some()); 324 | sleep(Duration::from_millis(25)).await; // wait for at least 1 event 325 | 326 | listener.stop().unwrap(); 327 | assert_eq!(listener.poll(), Ok(Some(Event::Tick))); 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /src/listener/port.rs: -------------------------------------------------------------------------------- 1 | //! ## Port 2 | //! 3 | //! This module exposes the poll wrapper to include in the worker 4 | 5 | #[cfg(feature = "async-ports")] 6 | mod async_p; 7 | mod sync; 8 | 9 | #[cfg(feature = "async-ports")] 10 | #[cfg_attr(docsrs, doc(cfg(feature = "async-ports")))] 11 | pub use self::async_p::AsyncPort; 12 | pub use self::sync::SyncPort; 13 | 14 | #[cfg(test)] 15 | mod test { 16 | use std::time::{Duration, Instant}; 17 | 18 | use pretty_assertions::assert_eq; 19 | 20 | use super::*; 21 | use crate::mock::{MockEvent, MockPoll}; 22 | 23 | #[test] 24 | fn test_single_listener() { 25 | let mut listener = 26 | SyncPort::::new(Box::new(MockPoll::default()), Duration::from_secs(5), 1); 27 | assert!(listener.next_poll() <= Instant::now()); 28 | assert_eq!(listener.should_poll(), true); 29 | assert!(listener.poll().ok().unwrap().is_some()); 30 | listener.calc_next_poll(); 31 | assert_eq!(listener.should_poll(), false); 32 | assert_eq!(*listener.interval(), Duration::from_secs(5)); 33 | } 34 | 35 | #[tokio::test] 36 | #[cfg(feature = "async-ports")] 37 | async fn test_single_async_listener() { 38 | use crate::mock::MockPollAsync; 39 | 40 | let mut listener = AsyncPort::::new( 41 | Box::new(MockPollAsync::default()), 42 | Duration::from_secs(5), 43 | 1, 44 | ); 45 | assert!(listener.next_poll() <= Instant::now()); 46 | assert_eq!(listener.should_poll(), true); 47 | assert!(listener.poll().await.ok().unwrap().is_some()); 48 | listener.calc_next_poll(); 49 | assert_eq!(listener.should_poll(), false); 50 | assert_eq!(*listener.interval(), Duration::from_secs(5)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/listener/port/async_p.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Add as _; 2 | use std::time::{Duration, Instant}; 3 | 4 | use crate::Event; 5 | use crate::listener::{ListenerResult, PollAsync}; 6 | 7 | /// An async port is a wrapper around the [`PollAsync`] trait object, which also defines an interval, which defines 8 | /// the amount of time between each [`PollAsync::poll`] call. 9 | /// Its purpose is to listen for incoming events of a user-defined type 10 | /// 11 | /// [`AsyncPort`] has the possibility to run 12 | pub struct AsyncPort 13 | where 14 | U: Eq + PartialEq + Clone + PartialOrd + Send, 15 | { 16 | poll: Box>, 17 | interval: Duration, 18 | next_poll: Instant, 19 | max_poll: usize, 20 | } 21 | 22 | impl AsyncPort 23 | where 24 | U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, 25 | { 26 | /// Define a new [`AsyncPort`] 27 | /// 28 | /// # Parameters 29 | /// 30 | /// * `poll` - The poll trait object 31 | /// * `interval` - The interval between each poll 32 | /// * `max_poll` - The maximum amount of times the port should be polled in a single poll 33 | /// * `runtime` - The tokio runtime to use for async polling 34 | pub fn new(poll: Box>, interval: Duration, max_poll: usize) -> Self { 35 | Self { 36 | poll, 37 | interval, 38 | next_poll: Instant::now(), 39 | max_poll, 40 | } 41 | } 42 | 43 | /// Get how often a port should get polled in a single poll 44 | pub fn max_poll(&self) -> usize { 45 | self.max_poll 46 | } 47 | 48 | /// Returns the interval for the current [`AsyncPort`] 49 | pub fn interval(&self) -> &Duration { 50 | &self.interval 51 | } 52 | 53 | /// Returns the time of the next poll for this listener 54 | pub fn next_poll(&self) -> Instant { 55 | self.next_poll 56 | } 57 | 58 | /// Returns whether next poll is now or in the past 59 | pub fn should_poll(&self) -> bool { 60 | self.next_poll <= Instant::now() 61 | } 62 | 63 | /// Calls [`PollAsync::poll`] on the inner [`PollAsync`] trait object. 64 | pub async fn poll(&mut self) -> ListenerResult>> { 65 | self.poll.poll().await 66 | } 67 | 68 | /// Calculate the next poll (t_now + interval) 69 | pub fn calc_next_poll(&mut self) { 70 | self.next_poll = Instant::now().add(self.interval); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/listener/port/sync.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Add as _; 2 | use std::time::{Duration, Instant}; 3 | 4 | use crate::Event; 5 | use crate::listener::{ListenerResult, Poll}; 6 | 7 | /// A port is a wrapper around the poll trait object, which also defines an interval, which defines 8 | /// the amount of time between each [`Poll::poll`] call. 9 | /// Its purpose is to listen for incoming events of a user-defined type 10 | pub struct SyncPort 11 | where 12 | U: Eq + PartialEq + Clone + PartialOrd + Send, 13 | { 14 | poll: Box>, 15 | interval: Duration, 16 | next_poll: Instant, 17 | max_poll: usize, 18 | } 19 | 20 | impl SyncPort 21 | where 22 | U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, 23 | { 24 | /// Define a new [`SyncPort`] 25 | /// 26 | /// # Parameters 27 | /// 28 | /// * `poll` - The poll trait object 29 | /// * `interval` - The interval between each poll 30 | /// * `max_poll` - The maximum amount of times the port should be polled in a single poll 31 | pub fn new(poll: Box>, interval: Duration, max_poll: usize) -> Self { 32 | Self { 33 | poll, 34 | interval, 35 | next_poll: Instant::now(), 36 | max_poll, 37 | } 38 | } 39 | 40 | /// Get how often a port should get polled in a single poll 41 | pub fn max_poll(&self) -> usize { 42 | self.max_poll 43 | } 44 | 45 | /// Returns the interval for the current [`SyncPort`] 46 | pub fn interval(&self) -> &Duration { 47 | &self.interval 48 | } 49 | 50 | /// Returns the time of the next poll for this listener 51 | pub fn next_poll(&self) -> Instant { 52 | self.next_poll 53 | } 54 | 55 | /// Returns whether next poll is now or in the past 56 | pub fn should_poll(&self) -> bool { 57 | self.next_poll <= Instant::now() 58 | } 59 | 60 | /// Calls [`Poll::poll`] on the inner [`Poll`] trait object. 61 | pub fn poll(&mut self) -> ListenerResult>> { 62 | self.poll.poll() 63 | } 64 | 65 | /// Calculate the next poll (t_now + interval) 66 | pub fn calc_next_poll(&mut self) { 67 | self.next_poll = Instant::now().add(self.interval); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/listener/task_pool.rs: -------------------------------------------------------------------------------- 1 | //! A Taskpool for spawning, tracking and cancelling async ports. 2 | 3 | use tokio::runtime::Handle; 4 | use tokio::select; 5 | use tokio_util::sync::CancellationToken; 6 | use tokio_util::task::TaskTracker; 7 | 8 | #[derive(Debug)] 9 | pub struct TaskPool { 10 | tracker: TaskTracker, 11 | handle: Handle, 12 | // the tracker itself does not cancel tasks on close 13 | cancel_token: CancellationToken, 14 | } 15 | 16 | impl TaskPool { 17 | pub fn new(handle: Handle) -> Self { 18 | Self { 19 | tracker: TaskTracker::new(), 20 | handle, 21 | cancel_token: CancellationToken::new(), 22 | } 23 | } 24 | 25 | /// Spawn a new future on the [`TaskPool`]s which is tracked and can be cancelled. 26 | pub fn spawn(&self, fut: F) 27 | where 28 | F: Future + Send + 'static, 29 | { 30 | let token = self.cancel_token.clone(); 31 | self.handle.spawn(self.tracker.track_future(async move { 32 | select! { 33 | () = fut => {}, 34 | () = token.cancelled() => {} 35 | } 36 | })); 37 | } 38 | 39 | /// Close the tracker and allow [`wait_done`](Self::wait_done) to exit. 40 | /// 41 | /// Does not prevent adding new tasks. 42 | /// Does not cancel any tasks. 43 | pub fn close(&self) { 44 | self.tracker.close(); 45 | } 46 | 47 | /// Cancel all tracked tasks. 48 | pub fn cancel_all(&self) { 49 | self.cancel_token.cancel(); 50 | } 51 | 52 | /// Wait until all tasks have finished. 53 | /// 54 | /// NOTE: this will wait infinitely until the task tracker is closed! 55 | #[allow(dead_code)] 56 | pub async fn wait_done(&self) { 57 | self.tracker.wait().await; 58 | } 59 | 60 | /// Close the Tracker, cancel all tasks in the tracker and wait for all tasks to settle. 61 | #[allow(dead_code)] 62 | pub async fn cancel_and_wait(&self) { 63 | self.close(); 64 | self.cancel_all(); 65 | self.wait_done().await; 66 | } 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use std::sync::Arc; 72 | use std::sync::atomic::{AtomicUsize, Ordering}; 73 | use std::time::Duration; 74 | 75 | use tokio::runtime::Handle; 76 | use tokio::time::sleep; 77 | 78 | use crate::listener::task_pool::TaskPool; 79 | 80 | #[tokio::test] 81 | async fn should_spawn_and_close() { 82 | let taskpool = TaskPool::new(Handle::current()); 83 | assert!(taskpool.tracker.is_empty()); 84 | assert!(!taskpool.tracker.is_closed()); 85 | 86 | let active = Arc::new(AtomicUsize::new(0)); 87 | 88 | let active_t = active.clone(); 89 | taskpool.spawn(async move { 90 | sleep(Duration::from_millis(2)).await; 91 | active_t.fetch_add(1, Ordering::Relaxed); 92 | }); 93 | 94 | taskpool.close(); 95 | taskpool.wait_done().await; 96 | 97 | assert_eq!(active.load(Ordering::Relaxed), 1); 98 | assert!(taskpool.tracker.is_empty()); 99 | assert!(taskpool.tracker.is_closed()); 100 | } 101 | 102 | #[tokio::test] 103 | async fn should_cancel() { 104 | let taskpool = TaskPool::new(Handle::current()); 105 | assert!(taskpool.tracker.is_empty()); 106 | assert!(!taskpool.tracker.is_closed()); 107 | // note: it seemingly does not count the main async function of the runtime 108 | assert_eq!(Handle::current().metrics().num_alive_tasks(), 0); 109 | 110 | let active = Arc::new(AtomicUsize::new(0)); 111 | 112 | let active_t = active.clone(); 113 | taskpool.spawn(async move { 114 | active_t.fetch_add(1, Ordering::Relaxed); 115 | sleep(Duration::MAX).await; 116 | }); 117 | 118 | // just to be sure that the other tasks gets executed too 119 | sleep(Duration::from_millis(10)).await; 120 | 121 | assert_eq!(Handle::current().metrics().num_alive_tasks(), 1); 122 | 123 | taskpool.cancel_and_wait().await; 124 | 125 | assert_eq!(active.load(Ordering::Relaxed), 1); 126 | assert!(taskpool.tracker.is_empty()); 127 | assert!(taskpool.tracker.is_closed()); 128 | assert_eq!(Handle::current().metrics().num_alive_tasks(), 0); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/listener/worker.rs: -------------------------------------------------------------------------------- 1 | //! ## Worker 2 | //! 3 | //! This module implements the worker thread for the event listener 4 | 5 | use std::ops::{Add, Sub}; 6 | use std::sync::atomic::AtomicBool; 7 | use std::sync::{Arc, mpsc}; 8 | use std::thread; 9 | use std::time::{Duration, Instant}; 10 | 11 | use super::{ListenerMsg, SyncPort}; 12 | 13 | // -- worker 14 | 15 | /// worker for event listener 16 | pub(super) struct EventListenerWorker 17 | where 18 | U: Eq + PartialEq + Clone + PartialOrd + Send, 19 | { 20 | ports: Vec>, 21 | sender: mpsc::Sender>, 22 | paused: Arc, 23 | running: Arc, 24 | next_tick: Instant, 25 | tick_interval: Option, 26 | } 27 | 28 | impl EventListenerWorker 29 | where 30 | U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, 31 | { 32 | /// Create a new Worker. 33 | /// 34 | /// If `tick_interval` is [`None`], no [`Event::Tick`](crate::Event::Tick) will be sent and a fallback interval time will be used. 35 | /// If `tick_interval` is [`Some`], [`Event::Tick`](crate::Event::Tick) will be sent and be used as the interval time. 36 | pub(super) fn new( 37 | ports: Vec>, 38 | sender: mpsc::Sender>, 39 | paused: Arc, 40 | running: Arc, 41 | tick_interval: Option, 42 | ) -> Self { 43 | Self { 44 | ports, 45 | sender, 46 | paused, 47 | running, 48 | next_tick: Instant::now(), 49 | tick_interval, 50 | } 51 | } 52 | 53 | /// Calculate next tick time. 54 | /// If tick is None, panics. 55 | fn calc_next_tick(&mut self) { 56 | self.next_tick = Instant::now().add(self.tick_interval.unwrap()); 57 | } 58 | 59 | /// Calc the distance in time between now and the first upcoming event 60 | fn next_event(&self) -> Duration { 61 | let now = Instant::now(); 62 | // TODO: this time should likely be lowered 63 | let fallback_time = now.add(Duration::from_secs(60)); 64 | // Get first upcoming event from ports 65 | let min_listener_event = self 66 | .ports 67 | .iter() 68 | .map(|x| x.next_poll()) 69 | .min() 70 | .unwrap_or(fallback_time); 71 | let next_tick = if self.tick_interval.is_none() { 72 | fallback_time 73 | } else { 74 | self.next_tick 75 | }; 76 | let min_time = std::cmp::min(min_listener_event, next_tick); 77 | // If min time is > now, returns diff, otherwise return 0 78 | if min_time > now { 79 | min_time.sub(now) 80 | } else { 81 | Duration::ZERO 82 | } 83 | } 84 | 85 | /// Returns whether should keep running 86 | fn running(&self) -> bool { 87 | self.running.load(std::sync::atomic::Ordering::Relaxed) 88 | } 89 | 90 | /// Returns whether worker is paused 91 | fn paused(&self) -> bool { 92 | self.paused.load(std::sync::atomic::Ordering::Relaxed) 93 | } 94 | 95 | /// Returns whether it's time to tick. 96 | /// If tick_interval is `None` it will never return `true` 97 | fn should_tick(&self) -> bool { 98 | match self.tick_interval { 99 | None => false, 100 | Some(_) => self.next_tick <= Instant::now(), 101 | } 102 | } 103 | 104 | /// Send tick to listener and calc next tick 105 | fn send_tick(&mut self) -> Result<(), mpsc::SendError>> { 106 | // Send tick 107 | self.sender.send(ListenerMsg::Tick)?; 108 | // Calc next tick 109 | self.calc_next_tick(); 110 | Ok(()) 111 | } 112 | 113 | /// Poll and send poll to listener. Calc next poll. 114 | /// Returns only the messages, while the None returned by poll are discarded 115 | fn poll(&mut self) -> Result<(), mpsc::SendError>> { 116 | let port_iter = self.ports.iter_mut().filter(|port| port.should_poll()); 117 | 118 | for port in port_iter { 119 | let mut times_remaining = port.max_poll(); 120 | // poll a port until it has nothing anymore 121 | loop { 122 | let msg = match port.poll() { 123 | Ok(Some(ev)) => ListenerMsg::User(ev), 124 | Ok(None) => break, 125 | Err(err) => ListenerMsg::Error(err), 126 | }; 127 | 128 | self.sender.send(msg)?; 129 | 130 | // do this at the end to at least call it once 131 | times_remaining = times_remaining.saturating_sub(1); 132 | 133 | if times_remaining == 0 { 134 | break; 135 | } 136 | } 137 | // Update next poll 138 | port.calc_next_poll(); 139 | } 140 | 141 | Ok(()) 142 | } 143 | 144 | /// thread run method 145 | pub(super) fn run(&mut self) { 146 | loop { 147 | // Check if running or send_error has occurred 148 | if !self.running() { 149 | break; 150 | } 151 | // If paused, wait and resume cycle 152 | if self.paused() { 153 | thread::sleep(Duration::from_millis(25)); 154 | continue; 155 | } 156 | // Iter ports and Send messages 157 | if self.poll().is_err() { 158 | break; 159 | } 160 | // Tick 161 | if self.should_tick() && self.send_tick().is_err() { 162 | break; 163 | } 164 | // Sleep till next event 165 | thread::sleep(self.next_event()); 166 | } 167 | } 168 | } 169 | 170 | #[cfg(test)] 171 | mod test { 172 | 173 | use pretty_assertions::assert_eq; 174 | 175 | use super::super::ListenerResult; 176 | use super::*; 177 | use crate::Event; 178 | use crate::core::event::{Key, KeyEvent}; 179 | use crate::listener::SyncPort; 180 | use crate::mock::{MockEvent, MockPoll}; 181 | 182 | #[test] 183 | fn worker_should_poll_multiple_times() { 184 | let (tx, rx) = mpsc::channel(); 185 | let paused = Arc::new(AtomicBool::new(false)); 186 | let paused_t = Arc::clone(&paused); 187 | let running = Arc::new(AtomicBool::new(true)); 188 | let running_t = Arc::clone(&running); 189 | 190 | let mock_port = SyncPort::new(Box::new(MockPoll::default()), Duration::from_secs(5), 10); 191 | 192 | let mut worker = 193 | EventListenerWorker::::new(vec![mock_port], tx, paused_t, running_t, None); 194 | assert!(worker.poll().is_ok()); 195 | assert!(worker.next_event() <= Duration::from_secs(5)); 196 | let mut recieved = Vec::new(); 197 | 198 | while let Ok(msg) = rx.try_recv() { 199 | recieved.push(msg); 200 | } 201 | 202 | assert_eq!(recieved.len(), 10); 203 | } 204 | 205 | #[test] 206 | fn worker_should_send_poll() { 207 | let (tx, rx) = mpsc::channel(); 208 | let paused = Arc::new(AtomicBool::new(false)); 209 | let paused_t = Arc::clone(&paused); 210 | let running = Arc::new(AtomicBool::new(true)); 211 | let running_t = Arc::clone(&running); 212 | let mut worker = EventListenerWorker::::new( 213 | vec![SyncPort::new( 214 | Box::new(MockPoll::default()), 215 | Duration::from_secs(5), 216 | 1, 217 | )], 218 | tx, 219 | paused_t, 220 | running_t, 221 | None, 222 | ); 223 | assert!(worker.poll().is_ok()); 224 | assert!(worker.next_event() <= Duration::from_secs(5)); 225 | // Receive 226 | assert_eq!( 227 | ListenerResult::from(rx.recv().ok().unwrap()).ok().unwrap(), 228 | Some(Event::Keyboard(KeyEvent::from(Key::Enter))) 229 | ); 230 | } 231 | 232 | #[test] 233 | fn worker_should_send_tick() { 234 | let (tx, rx) = mpsc::channel(); 235 | let paused = Arc::new(AtomicBool::new(false)); 236 | let paused_t = Arc::clone(&paused); 237 | let running = Arc::new(AtomicBool::new(true)); 238 | let running_t = Arc::clone(&running); 239 | let mut worker = EventListenerWorker::::new( 240 | vec![SyncPort::new( 241 | Box::new(MockPoll::default()), 242 | Duration::from_secs(5), 243 | 1, 244 | )], 245 | tx, 246 | paused_t, 247 | running_t, 248 | Some(Duration::from_secs(1)), 249 | ); 250 | assert!(worker.send_tick().is_ok()); 251 | assert!(worker.next_tick > Instant::now()); 252 | // Receive 253 | assert_eq!( 254 | ListenerResult::from(rx.recv().ok().unwrap()).ok().unwrap(), 255 | Some(Event::Tick) 256 | ); 257 | } 258 | 259 | #[test] 260 | fn worker_should_calc_times_correctly_with_tick() { 261 | let (tx, rx) = mpsc::channel(); 262 | let paused = Arc::new(AtomicBool::new(false)); 263 | let paused_t = Arc::clone(&paused); 264 | let running = Arc::new(AtomicBool::new(true)); 265 | let running_t = Arc::clone(&running); 266 | let mut worker = EventListenerWorker::::new( 267 | vec![SyncPort::new( 268 | Box::new(MockPoll::default()), 269 | Duration::from_secs(5), 270 | 1, 271 | )], 272 | tx, 273 | paused_t, 274 | running_t, 275 | Some(Duration::from_secs(1)), 276 | ); 277 | assert_eq!(worker.running(), true); 278 | // Should set next events to now 279 | assert!(worker.next_event() <= Duration::from_secs(1)); 280 | assert!(worker.next_tick <= Instant::now()); 281 | assert!(worker.should_tick()); 282 | // Calc next 283 | let expected_next_tick = Instant::now().add(Duration::from_secs(1)); 284 | worker.calc_next_tick(); 285 | assert!(worker.next_tick >= expected_next_tick); 286 | // Next event should be in 1 second (tick) 287 | assert!(worker.next_event() <= Duration::from_secs(1)); 288 | // Now should no more tick and poll 289 | assert_eq!(worker.should_tick(), false); 290 | // Stop 291 | running.store(false, std::sync::atomic::Ordering::Relaxed); 292 | assert_eq!(worker.running(), false); 293 | drop(rx); 294 | } 295 | 296 | #[test] 297 | fn worker_should_calc_times_correctly_without_tick() { 298 | let (tx, rx) = mpsc::channel(); 299 | let paused = Arc::new(AtomicBool::new(false)); 300 | let paused_t = Arc::clone(&paused); 301 | let running = Arc::new(AtomicBool::new(true)); 302 | let running_t = Arc::clone(&running); 303 | let worker = EventListenerWorker::::new( 304 | vec![SyncPort::new( 305 | Box::new(MockPoll::default()), 306 | Duration::from_secs(3), 307 | 1, 308 | )], 309 | tx, 310 | paused_t, 311 | running_t, 312 | None, 313 | ); 314 | assert_eq!(worker.running(), true); 315 | assert_eq!(worker.paused(), false); 316 | // Should set next events to now 317 | assert!(worker.next_event() <= Duration::from_secs(3)); 318 | assert!(worker.next_tick <= Instant::now()); 319 | assert_eq!(worker.should_tick(), false); 320 | // Next event should be in 3 second (poll) 321 | assert!(worker.next_event() <= Duration::from_secs(3)); 322 | // Stop 323 | running.store(false, std::sync::atomic::Ordering::Relaxed); 324 | 325 | assert_eq!(worker.running(), false); 326 | drop(rx); 327 | } 328 | 329 | #[test] 330 | #[should_panic] 331 | fn worker_should_panic_when_trying_next_tick_without_it() { 332 | let (tx, _) = mpsc::channel(); 333 | let paused = Arc::new(AtomicBool::new(false)); 334 | let paused_t = Arc::clone(&paused); 335 | let running = Arc::new(AtomicBool::new(true)); 336 | let running_t = Arc::clone(&running); 337 | let mut worker = 338 | EventListenerWorker::::new(vec![], tx, paused_t, running_t, None); 339 | worker.calc_next_tick(); 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | /// A macro to generate a chain of [`crate::SubClause::AndMany`] from a list of 2 | /// Ids with the case [`crate::SubClause::IsMounted`] for every id. 3 | /// 4 | /// ### Example 5 | /// 6 | /// ```rust 7 | /// use tuirealm::{SubClause, subclause_and}; 8 | /// 9 | /// #[derive(Debug, Eq, PartialEq, Clone, Hash)] 10 | /// pub enum Id { 11 | /// InputBar, 12 | /// InputFoo, 13 | /// InputOmar, 14 | /// } 15 | /// 16 | /// let sub_clause = subclause_and!( 17 | /// Id::InputBar, 18 | /// Id::InputFoo, 19 | /// Id::InputOmar 20 | /// ); 21 | /// 22 | /// assert_eq!( 23 | /// sub_clause, 24 | /// SubClause::AndMany(vec![ 25 | /// SubClause::IsMounted(Id::InputBar), 26 | /// SubClause::IsMounted(Id::InputFoo), 27 | /// SubClause::IsMounted(Id::InputOmar), 28 | /// ]) 29 | /// ); 30 | /// ``` 31 | #[macro_export] 32 | macro_rules! subclause_and { 33 | ($id:expr) => { 34 | SubClause::IsMounted($id) 35 | }; 36 | ($($rest:expr),+ $(,)?) => { 37 | SubClause::AndMany(vec![ 38 | $(SubClause::IsMounted($rest)),* 39 | ]) 40 | }; 41 | } 42 | 43 | /// A macro to generate a chain of [`crate::SubClause::And`] from a list of 44 | /// Ids with the case [`crate::SubClause::Not`] containing [`crate::SubClause::IsMounted`] for every id. 45 | /// 46 | /// Why is this useful? 47 | /// Well, it happens quite often at least in my application to require a subclause for a "Global Listener" item 48 | /// to have no "Popup" mounted in the application. 49 | /// 50 | /// ### Example 51 | /// 52 | /// ```rust 53 | /// use tuirealm::{SubClause, subclause_and_not}; 54 | /// 55 | /// #[derive(Debug, Eq, PartialEq, Clone, Hash)] 56 | /// pub enum Id { 57 | /// InputBar, 58 | /// InputFoo, 59 | /// InputOmar, 60 | /// } 61 | /// 62 | /// let sub_clause = subclause_and_not!( 63 | /// Id::InputBar, 64 | /// Id::InputFoo, 65 | /// Id::InputOmar 66 | /// ); 67 | /// 68 | /// assert_eq!( 69 | /// sub_clause, 70 | /// SubClause::not(SubClause::AndMany(vec![ 71 | /// SubClause::IsMounted(Id::InputBar), 72 | /// SubClause::IsMounted(Id::InputFoo), 73 | /// SubClause::IsMounted(Id::InputOmar), 74 | /// ])) 75 | /// ); 76 | /// ``` 77 | #[macro_export] 78 | macro_rules! subclause_and_not { 79 | ($id:expr) => { 80 | SubClause::not(SubClause::IsMounted($id)) 81 | }; 82 | ($($rest:expr),+ $(,)?) => { 83 | SubClause::not( 84 | SubClause::AndMany(vec![ 85 | $(SubClause::IsMounted($rest)),* 86 | ]) 87 | ) 88 | }; 89 | } 90 | 91 | /// A macro to generate a chain of [`crate::SubClause::OrMany`] from a list of 92 | /// Ids with the case [`crate::SubClause::IsMounted`] for every id. 93 | /// 94 | /// ### Example 95 | /// 96 | /// ```rust 97 | /// use tuirealm::{SubClause, subclause_or}; 98 | /// 99 | /// #[derive(Debug, Eq, PartialEq, Clone, Hash)] 100 | /// pub enum Id { 101 | /// InputBar, 102 | /// InputFoo, 103 | /// InputOmar, 104 | /// } 105 | /// 106 | /// let sub_clause = subclause_or!( 107 | /// Id::InputBar, 108 | /// Id::InputFoo, 109 | /// Id::InputOmar 110 | /// ); 111 | /// 112 | /// assert_eq!( 113 | /// sub_clause, 114 | /// SubClause::OrMany(vec![ 115 | /// SubClause::IsMounted(Id::InputBar), 116 | /// SubClause::IsMounted(Id::InputFoo), 117 | /// SubClause::IsMounted(Id::InputOmar), 118 | /// ]) 119 | /// ); 120 | /// ``` 121 | #[macro_export] 122 | macro_rules! subclause_or { 123 | ($id:expr) => { 124 | SubClause::IsMounted($id) 125 | }; 126 | ($($rest:expr),+ $(,)?) => { 127 | SubClause::OrMany(vec![ 128 | $(SubClause::IsMounted($rest)),* 129 | ]) 130 | }; 131 | } 132 | 133 | #[cfg(test)] 134 | mod tests { 135 | use crate::SubClause; 136 | use crate::mock::MockComponentId; 137 | 138 | #[test] 139 | fn subclause_and() { 140 | // single 141 | assert_eq!( 142 | subclause_and!(MockComponentId::InputBar), 143 | SubClause::IsMounted(MockComponentId::InputBar), 144 | ); 145 | 146 | // multiple with no ending comma 147 | assert_eq!( 148 | subclause_and!( 149 | MockComponentId::InputBar, 150 | MockComponentId::InputFoo, 151 | MockComponentId::InputOmar 152 | ), 153 | SubClause::AndMany(vec![ 154 | SubClause::IsMounted(MockComponentId::InputBar), 155 | SubClause::IsMounted(MockComponentId::InputFoo), 156 | SubClause::IsMounted(MockComponentId::InputOmar), 157 | ]) 158 | ); 159 | 160 | // multiple with ending comma 161 | assert_eq!( 162 | subclause_and!( 163 | MockComponentId::InputBar, 164 | MockComponentId::InputFoo, 165 | MockComponentId::InputOmar, 166 | ), 167 | SubClause::AndMany(vec![ 168 | SubClause::IsMounted(MockComponentId::InputBar), 169 | SubClause::IsMounted(MockComponentId::InputFoo), 170 | SubClause::IsMounted(MockComponentId::InputOmar), 171 | ]) 172 | ); 173 | } 174 | 175 | #[test] 176 | fn subclause_and_not() { 177 | // single 178 | assert_eq!( 179 | subclause_and_not!(MockComponentId::InputBar), 180 | SubClause::not(SubClause::IsMounted(MockComponentId::InputBar)), 181 | ); 182 | 183 | // multiple with no ending comma 184 | assert_eq!( 185 | subclause_and_not!( 186 | MockComponentId::InputBar, 187 | MockComponentId::InputFoo, 188 | MockComponentId::InputOmar 189 | ), 190 | SubClause::not(SubClause::AndMany(vec![ 191 | SubClause::IsMounted(MockComponentId::InputBar), 192 | SubClause::IsMounted(MockComponentId::InputFoo), 193 | SubClause::IsMounted(MockComponentId::InputOmar), 194 | ])) 195 | ); 196 | 197 | // multiple with ending comma 198 | assert_eq!( 199 | subclause_and_not!( 200 | MockComponentId::InputBar, 201 | MockComponentId::InputFoo, 202 | MockComponentId::InputOmar, 203 | ), 204 | SubClause::not(SubClause::AndMany(vec![ 205 | SubClause::IsMounted(MockComponentId::InputBar), 206 | SubClause::IsMounted(MockComponentId::InputFoo), 207 | SubClause::IsMounted(MockComponentId::InputOmar), 208 | ])) 209 | ); 210 | } 211 | 212 | #[test] 213 | fn subclause_or() { 214 | // single 215 | assert_eq!( 216 | subclause_or!(MockComponentId::InputBar), 217 | SubClause::IsMounted(MockComponentId::InputBar), 218 | ); 219 | 220 | // multiple with no ending comma 221 | assert_eq!( 222 | subclause_or!( 223 | MockComponentId::InputBar, 224 | MockComponentId::InputFoo, 225 | MockComponentId::InputOmar 226 | ), 227 | SubClause::OrMany(vec![ 228 | SubClause::IsMounted(MockComponentId::InputBar), 229 | SubClause::IsMounted(MockComponentId::InputFoo), 230 | SubClause::IsMounted(MockComponentId::InputOmar), 231 | ]) 232 | ); 233 | 234 | // multiple with ending comma 235 | assert_eq!( 236 | subclause_or!( 237 | MockComponentId::InputBar, 238 | MockComponentId::InputFoo, 239 | MockComponentId::InputOmar, 240 | ), 241 | SubClause::OrMany(vec![ 242 | SubClause::IsMounted(MockComponentId::InputBar), 243 | SubClause::IsMounted(MockComponentId::InputFoo), 244 | SubClause::IsMounted(MockComponentId::InputOmar), 245 | ]) 246 | ); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/mock/components.rs: -------------------------------------------------------------------------------- 1 | //! # Components 2 | //! 3 | //! mock components 4 | 5 | use ratatui::Frame; 6 | 7 | use super::{MockEvent, MockMsg}; 8 | use crate::command::{Cmd, CmdResult, Direction}; 9 | use crate::event::{Event, Key, KeyEvent, KeyModifiers}; 10 | use crate::{AttrValue, Attribute, Component, MockComponent, Props, State, StateValue}; 11 | 12 | /// Mocked component implementing `MockComponent` 13 | pub struct MockInput { 14 | props: Props, 15 | states: MockInputStates, 16 | } 17 | 18 | impl Default for MockInput { 19 | fn default() -> Self { 20 | Self { 21 | props: Props::default(), 22 | states: MockInputStates::default(), 23 | } 24 | } 25 | } 26 | 27 | impl MockComponent for MockInput { 28 | fn view(&mut self, _: &mut Frame, _: crate::ratatui::layout::Rect) {} 29 | 30 | fn query(&self, attr: Attribute) -> Option { 31 | self.props.get(attr) 32 | } 33 | 34 | fn attr(&mut self, query: Attribute, attr: AttrValue) { 35 | self.props.set(query, attr); 36 | } 37 | 38 | fn state(&self) -> State { 39 | State::One(StateValue::String(self.states.text.clone())) 40 | } 41 | 42 | fn perform(&mut self, cmd: Cmd) -> CmdResult { 43 | match cmd { 44 | Cmd::Move(Direction::Left) => { 45 | self.states.left(); 46 | CmdResult::Changed(State::One(StateValue::Usize(self.states.cursor))) 47 | } 48 | Cmd::Move(Direction::Right) => { 49 | self.states.right(); 50 | CmdResult::Changed(State::One(StateValue::Usize(self.states.cursor))) 51 | } 52 | Cmd::Type(ch) => { 53 | self.states.input(ch); 54 | CmdResult::Changed(self.state()) 55 | } 56 | _ => CmdResult::None, 57 | } 58 | } 59 | } 60 | 61 | // -- component states 62 | 63 | struct MockInputStates { 64 | text: String, 65 | cursor: usize, 66 | } 67 | 68 | impl MockInputStates { 69 | fn default() -> Self { 70 | Self { 71 | text: String::default(), 72 | cursor: 0, 73 | } 74 | } 75 | } 76 | 77 | impl MockInputStates { 78 | fn input(&mut self, c: char) { 79 | self.text.push(c); 80 | } 81 | 82 | fn left(&mut self) { 83 | if self.cursor > 0 { 84 | self.cursor -= 1; 85 | } 86 | } 87 | 88 | fn right(&mut self) { 89 | self.cursor += 1; 90 | } 91 | } 92 | 93 | // -- component impl 94 | 95 | #[derive(MockComponent, Default)] 96 | pub struct MockFooInput { 97 | component: MockInput, 98 | } 99 | 100 | impl Component for MockFooInput { 101 | fn on(&mut self, ev: Event) -> Option { 102 | let cmd = match ev { 103 | Event::Keyboard(KeyEvent { 104 | code: Key::Left, 105 | modifiers: _, 106 | }) => Cmd::Move(Direction::Left), 107 | Event::Keyboard(KeyEvent { 108 | code: Key::Right, 109 | modifiers: _, 110 | }) => Cmd::Move(Direction::Right), 111 | Event::Keyboard(KeyEvent { 112 | code: Key::Char(ch), 113 | modifiers: KeyModifiers::NONE, 114 | }) => Cmd::Type(ch), 115 | Event::Keyboard(KeyEvent { 116 | code: Key::Enter, 117 | modifiers: KeyModifiers::NONE, 118 | }) => return Some(MockMsg::FooSubmit(self.component.states.text.clone())), 119 | _ => Cmd::None, 120 | }; 121 | match self.component.perform(cmd) { 122 | CmdResult::Changed(State::One(StateValue::String(s))) => { 123 | Some(MockMsg::FooInputChanged(s)) 124 | } 125 | _ => None, 126 | } 127 | } 128 | } 129 | 130 | #[derive(MockComponent, Default)] 131 | pub struct MockBarInput { 132 | component: MockInput, 133 | } 134 | 135 | impl Component for MockBarInput { 136 | fn on(&mut self, ev: Event) -> Option { 137 | let cmd = match ev { 138 | Event::Keyboard(KeyEvent { 139 | code: Key::Left, 140 | modifiers: _, 141 | }) => Cmd::Move(Direction::Left), 142 | Event::Keyboard(KeyEvent { 143 | code: Key::Right, 144 | modifiers: _, 145 | }) => Cmd::Move(Direction::Right), 146 | Event::Keyboard(KeyEvent { 147 | code: Key::Char(ch), 148 | modifiers: KeyModifiers::NONE, 149 | }) => Cmd::Type(ch), 150 | Event::Keyboard(KeyEvent { 151 | code: Key::Enter, 152 | modifiers: KeyModifiers::NONE, 153 | }) => return Some(MockMsg::BarSubmit(self.component.states.text.clone())), 154 | Event::Tick => return Some(MockMsg::BarTick), 155 | _ => Cmd::None, 156 | }; 157 | match self.component.perform(cmd) { 158 | CmdResult::Changed(State::One(StateValue::String(s))) => { 159 | Some(MockMsg::BarInputChanged(s)) 160 | } 161 | _ => None, 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/mock/mod.rs: -------------------------------------------------------------------------------- 1 | //! # Mock 2 | //! 3 | //! This module contains data type for unit tests only 4 | 5 | use std::marker::PhantomData; 6 | 7 | use crate::event::{Event, Key, KeyEvent}; 8 | use crate::listener::{ListenerResult, Poll}; 9 | use crate::{AttrValue, Attribute, Injector}; 10 | 11 | // -- modules 12 | mod components; 13 | pub use components::{MockBarInput, MockFooInput, MockInput}; 14 | 15 | // -- event 16 | 17 | /// Mock UserEvent type 18 | #[derive(Debug, Eq, PartialEq, Clone, PartialOrd)] 19 | pub enum MockEvent { 20 | None, 21 | Foo, 22 | Bar, 23 | Hello(String), 24 | } 25 | 26 | /// Mock component id type 27 | #[derive(Debug, Eq, PartialEq, Clone, Hash)] 28 | pub enum MockComponentId { 29 | InputBar, 30 | InputFoo, 31 | InputOmar, 32 | Dyn(String), 33 | } 34 | 35 | // -- poll 36 | 37 | /// Mock poll implementation 38 | pub struct MockPoll { 39 | ghost: PhantomData, 40 | } 41 | 42 | impl Default for MockPoll { 43 | fn default() -> Self { 44 | Self { ghost: PhantomData } 45 | } 46 | } 47 | 48 | impl Poll for MockPoll { 49 | fn poll(&mut self) -> ListenerResult>> { 50 | Ok(Some(Event::Keyboard(KeyEvent::from(Key::Enter)))) 51 | } 52 | } 53 | 54 | #[cfg(feature = "async-ports")] 55 | #[derive(Default)] 56 | pub struct MockPollAsync(); 57 | 58 | #[cfg(feature = "async-ports")] 59 | #[async_trait::async_trait] 60 | impl crate::listener::PollAsync 61 | for MockPollAsync 62 | { 63 | async fn poll(&mut self) -> ListenerResult>> { 64 | let tempfile = tempfile::NamedTempFile::new().expect("tempfile"); 65 | let _file = tokio::fs::File::open(tempfile.path()).await.expect("file"); 66 | 67 | Ok(Some(Event::Keyboard(KeyEvent::from(Key::Enter)))) 68 | } 69 | } 70 | 71 | // -- msg 72 | 73 | /// Mocked Msg for components and view 74 | #[derive(Debug, PartialEq)] 75 | pub enum MockMsg { 76 | FooInputChanged(String), 77 | FooSubmit(String), 78 | BarInputChanged(String), 79 | BarSubmit(String), 80 | BarTick, 81 | } 82 | 83 | // -- injector 84 | 85 | #[derive(Default)] 86 | pub struct MockInjector; 87 | 88 | impl Injector for MockInjector { 89 | fn inject(&self, id: &MockComponentId) -> Vec<(Attribute, AttrValue)> { 90 | match id { 91 | &MockComponentId::InputBar => vec![( 92 | Attribute::Text, 93 | AttrValue::String(String::from("hello, world!")), 94 | )], 95 | _ => vec![], 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/ratatui.rs: -------------------------------------------------------------------------------- 1 | //! ## ratatui 2 | //! 3 | //! `ratatui` just exposes the ratatui modules, in order to include the entire library inside realm 4 | 5 | pub use ratatui::*; 6 | -------------------------------------------------------------------------------- /src/terminal.rs: -------------------------------------------------------------------------------- 1 | //! ## terminal 2 | //! 3 | //! Cross platform Terminal helper 4 | 5 | mod adapter; 6 | mod event_listener; 7 | 8 | use ratatui::{CompletedFrame, Frame}; 9 | use thiserror::Error; 10 | 11 | #[cfg(feature = "crossterm")] 12 | #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))] 13 | pub use self::adapter::CrosstermTerminalAdapter; 14 | pub use self::adapter::TerminalAdapter; 15 | #[cfg(feature = "termion")] 16 | #[cfg_attr(docsrs, doc(cfg(feature = "termion")))] 17 | pub use self::adapter::TermionTerminalAdapter; 18 | #[cfg(all(feature = "crossterm", feature = "async-ports"))] 19 | #[cfg_attr(docsrs, doc(cfg(all(feature = "crossterm", feature = "async-ports"))))] 20 | pub use self::event_listener::CrosstermAsyncStream; 21 | #[cfg(feature = "crossterm")] 22 | #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))] 23 | pub use self::event_listener::CrosstermInputListener; 24 | #[cfg(feature = "termion")] 25 | #[cfg_attr(docsrs, doc(cfg(feature = "termion")))] 26 | pub use self::event_listener::TermionInputListener; 27 | 28 | /// TerminalResult is a type alias for a Result that uses [`TerminalError`] as the error type. 29 | pub type TerminalResult = Result; 30 | 31 | #[derive(Debug, Error)] 32 | pub enum TerminalError { 33 | #[error("cannot draw frame")] 34 | CannotDrawFrame, 35 | #[error("cannot connect to stdout")] 36 | CannotConnectStdout, 37 | #[error("cannot enter alternate mode")] 38 | CannotEnterAlternateMode, 39 | #[error("cannot leave alternate mode")] 40 | CannotLeaveAlternateMode, 41 | #[error("cannot toggle raw mode")] 42 | CannotToggleRawMode, 43 | #[error("cannot clear screen")] 44 | CannotClear, 45 | #[error("backend doesn't support this command")] 46 | Unsupported, 47 | #[error("cannot activate / deactivate mouse capture")] 48 | CannotToggleMouseCapture, 49 | } 50 | 51 | /// An helper around [`crate::ratatui::Terminal`] to quickly setup and perform on terminal. 52 | /// You can opt whether to use or not this structure to interact with the terminal 53 | /// Anyway this structure is 100% cross-backend compatible and is really easy to use, so I suggest you to use it. 54 | /// If you need more advance terminal command, you can get a reference to it using the `raw()` and `raw_mut()` methods. 55 | /// 56 | /// To quickly setup a terminal with default settings, you can use the [`TerminalBridge::init()`] method. 57 | /// 58 | /// ```rust,ignore 59 | /// use tuirealm::terminal::TerminalBridge; 60 | /// 61 | /// #[cfg(feature = "crossterm")] 62 | /// let mut terminal = TerminalBridge::init_crossterm().unwrap(); 63 | /// #[cfg(feature = "termion")] 64 | /// let mut terminal = TerminalBridge::init_termion().unwrap(); 65 | /// ``` 66 | pub struct TerminalBridge 67 | where 68 | T: TerminalAdapter, 69 | { 70 | terminal: T, 71 | } 72 | 73 | impl TerminalBridge 74 | where 75 | T: TerminalAdapter, 76 | { 77 | /// Instantiates a new Terminal bridge from a [`TerminalAdapter`] 78 | pub fn new(terminal: T) -> Self { 79 | Self { terminal } 80 | } 81 | 82 | /// Initialize a terminal with reasonable defaults for most applications. 83 | /// 84 | /// This will create a new [`TerminalBridge`] and initialize it with the following defaults: 85 | /// 86 | /// - Raw mode is enabled 87 | /// - Alternate screen buffer enabled 88 | /// - A panic hook is installed that restores the terminal before panicking. Ensure that this method 89 | /// is called after any other panic hooks that may be installed to ensure that the terminal is 90 | /// restored before those hooks are called. 91 | /// 92 | /// For more control over the terminal initialization, use [`TerminalBridge::new`]. 93 | pub fn init(terminal: T) -> TerminalResult { 94 | let mut terminal = Self::new(terminal); 95 | terminal.enable_raw_mode()?; 96 | terminal.enter_alternate_screen()?; 97 | Self::set_panic_hook(); 98 | 99 | Ok(terminal) 100 | } 101 | 102 | /// Restore the terminal to its original state. 103 | /// 104 | /// This function will attempt to restore the terminal to its original state by leaving the alternate screen 105 | /// and disabling raw mode. If either of these operations fail, the error will be returned. 106 | pub fn restore(&mut self) -> TerminalResult<()> { 107 | self.leave_alternate_screen()?; 108 | self.disable_raw_mode() 109 | } 110 | 111 | /// Sets a panic hook that restores the terminal before panicking. 112 | /// 113 | /// Replaces the panic hook with a one that will restore the terminal state before calling the 114 | /// original panic hook. This ensures that the terminal is left in a good state when a panic occurs. 115 | pub fn set_panic_hook() { 116 | let hook = std::panic::take_hook(); 117 | std::panic::set_hook(Box::new(move |info| { 118 | #[cfg(feature = "crossterm")] 119 | { 120 | let _ = crossterm::terminal::disable_raw_mode(); 121 | let _ = crossterm::execute!( 122 | std::io::stdout(), 123 | crossterm::terminal::LeaveAlternateScreen 124 | ); 125 | } 126 | 127 | hook(info); 128 | })); 129 | } 130 | 131 | /// Enter in alternate screen using the terminal adapter 132 | pub fn enter_alternate_screen(&mut self) -> TerminalResult<()> { 133 | self.terminal.enter_alternate_screen() 134 | } 135 | 136 | /// Leave the alternate screen using the terminal adapter 137 | pub fn leave_alternate_screen(&mut self) -> TerminalResult<()> { 138 | self.terminal.leave_alternate_screen() 139 | } 140 | 141 | /// Clear the screen 142 | pub fn clear_screen(&mut self) -> TerminalResult<()> { 143 | self.terminal.clear_screen() 144 | } 145 | 146 | /// Enable terminal raw mode 147 | pub fn enable_raw_mode(&mut self) -> TerminalResult<()> { 148 | self.terminal.enable_raw_mode() 149 | } 150 | 151 | /// Disable terminal raw mode 152 | pub fn disable_raw_mode(&mut self) -> TerminalResult<()> { 153 | self.terminal.disable_raw_mode() 154 | } 155 | 156 | /// Enable mouse-event capture, if the backend supports it 157 | pub fn enable_mouse_capture(&mut self) -> TerminalResult<()> { 158 | self.terminal.enable_mouse_capture() 159 | } 160 | 161 | /// Disable mouse-event capture, if the backend supports it 162 | pub fn disable_mouse_capture(&mut self) -> TerminalResult<()> { 163 | self.terminal.disable_mouse_capture() 164 | } 165 | 166 | /// Draws a single frame to the terminal. 167 | /// 168 | /// Returns a [`CompletedFrame`] if successful, otherwise a [`TerminalError`]. 169 | /// 170 | /// This method will: 171 | /// 172 | /// - autoresize the terminal if necessary 173 | /// - call the render callback, passing it a [`Frame`] reference to render to 174 | /// - flush the current internal state by copying the current buffer to the backend 175 | /// - move the cursor to the last known position if it was set during the rendering closure 176 | /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal 177 | /// 178 | /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing 179 | /// purposes, but it is often not used in regular applicationss. 180 | /// 181 | /// The render callback should fully render the entire frame when called, including areas that 182 | /// are unchanged from the previous frame. This is because each frame is compared to the 183 | /// previous frame to determine what has changed, and only the changes are written to the 184 | /// terminal. If the render callback does not fully render the frame, the terminal will not be 185 | /// in a consistent state. 186 | pub fn draw(&mut self, render_callback: F) -> TerminalResult 187 | where 188 | F: FnOnce(&mut Frame<'_>), 189 | { 190 | self.terminal.draw(render_callback) 191 | } 192 | } 193 | 194 | #[cfg(feature = "crossterm")] 195 | impl TerminalBridge { 196 | /// Create a new instance of the [`TerminalBridge`] using [`crossterm`] as backend 197 | pub fn new_crossterm() -> TerminalResult { 198 | Ok(Self::new(adapter::CrosstermTerminalAdapter::new()?)) 199 | } 200 | 201 | /// Initialize a terminal with reasonable defaults for most applications using [`crossterm`] as backend. 202 | /// 203 | /// See [`TerminalBridge::init`] for more information. 204 | pub fn init_crossterm() -> TerminalResult { 205 | Self::init(adapter::CrosstermTerminalAdapter::new()?) 206 | } 207 | 208 | /// Returns a reference to the underlying [`crate::ratatui::Terminal`] 209 | pub fn raw( 210 | &self, 211 | ) -> &crate::ratatui::Terminal> { 212 | self.terminal.raw() 213 | } 214 | 215 | /// Returns a mutable reference the underlying [`crate::ratatui::Terminal`] 216 | pub fn raw_mut( 217 | &mut self, 218 | ) -> &mut crate::ratatui::Terminal> 219 | { 220 | self.terminal.raw_mut() 221 | } 222 | } 223 | 224 | #[cfg(feature = "termion")] 225 | impl TerminalBridge { 226 | /// Create a new instance of the [`TerminalBridge`] using [`termion`] as backend 227 | pub fn new_termion() -> Self { 228 | Self::new(adapter::TermionTerminalAdapter::new().unwrap()) 229 | } 230 | 231 | /// Initialize a terminal with reasonable defaults for most applications using [`termion`] as backend. 232 | /// 233 | /// See [`TerminalBridge::init`] for more information. 234 | pub fn init_termion() -> TerminalResult { 235 | Self::init(adapter::TermionTerminalAdapter::new().unwrap()) 236 | } 237 | 238 | /// Returns a reference to the underlying Terminal 239 | pub fn raw(&self) -> &adapter::TermionBackend { 240 | self.terminal.raw() 241 | } 242 | 243 | /// Returns a mutable reference to the underlying Terminal 244 | pub fn raw_mut(&mut self) -> &mut adapter::TermionBackend { 245 | self.terminal.raw_mut() 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/terminal/adapter.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "crossterm")] 2 | mod crossterm; 3 | #[cfg(feature = "termion")] 4 | mod termion; 5 | 6 | #[cfg(feature = "crossterm")] 7 | pub use crossterm::CrosstermTerminalAdapter; 8 | use ratatui::{CompletedFrame, Frame}; 9 | #[cfg(feature = "termion")] 10 | pub use termion::{TermionBackend, TermionTerminalAdapter}; 11 | 12 | use super::TerminalResult; 13 | 14 | /// TerminalAdapter is a trait that defines the methods that a terminal adapter should implement. 15 | /// 16 | /// This trait is used to abstract the terminal implementation from the rest of the application. 17 | /// This allows tui-realm to be used with different terminal libraries, such as crossterm, termion, etc. 18 | pub trait TerminalAdapter { 19 | /// Draws a single frame to the terminal. 20 | /// 21 | /// Returns a [`CompletedFrame`] if successful, otherwise a [`super::TerminalError`]. 22 | /// 23 | /// This method will: 24 | /// 25 | /// - autoresize the terminal if necessary 26 | /// - call the render callback, passing it a [`Frame`] reference to render to 27 | /// - flush the current internal state by copying the current buffer to the backend 28 | /// - move the cursor to the last known position if it was set during the rendering closure 29 | /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal 30 | /// 31 | /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing 32 | /// purposes, but it is often not used in regular applicationss. 33 | /// 34 | /// The render callback should fully render the entire frame when called, including areas that 35 | /// are unchanged from the previous frame. This is because each frame is compared to the 36 | /// previous frame to determine what has changed, and only the changes are written to the 37 | /// terminal. If the render callback does not fully render the frame, the terminal will not be 38 | /// in a consistent state. 39 | fn draw(&mut self, render_callback: F) -> TerminalResult 40 | where 41 | F: FnOnce(&mut Frame<'_>); 42 | 43 | /// Clear the screen 44 | fn clear_screen(&mut self) -> TerminalResult<()>; 45 | 46 | /// Enable terminal raw mode 47 | fn enable_raw_mode(&mut self) -> TerminalResult<()>; 48 | 49 | /// Disable terminal raw mode 50 | fn disable_raw_mode(&mut self) -> TerminalResult<()>; 51 | 52 | /// Enter in alternate screen using the terminal adapter 53 | fn enter_alternate_screen(&mut self) -> TerminalResult<()>; 54 | 55 | /// Leave the alternate screen using the terminal adapter 56 | fn leave_alternate_screen(&mut self) -> TerminalResult<()>; 57 | 58 | /// Enable mouse capture using the terminal adapter 59 | fn enable_mouse_capture(&mut self) -> TerminalResult<()>; 60 | 61 | /// Disable mouse capture using the terminal adapter 62 | fn disable_mouse_capture(&mut self) -> TerminalResult<()>; 63 | } 64 | -------------------------------------------------------------------------------- /src/terminal/adapter/crossterm.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 2 | use crossterm::execute; 3 | use crossterm::terminal::{ 4 | EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, 5 | }; 6 | use ratatui::Terminal; 7 | 8 | use super::{TerminalAdapter, TerminalResult}; 9 | use crate::ratatui::backend::CrosstermBackend; 10 | use crate::terminal::TerminalError; 11 | 12 | /// CrosstermTerminalAdapter is the adapter for the [`crossterm`] terminal 13 | /// 14 | /// It implements the [`TerminalAdapter`] trait 15 | pub struct CrosstermTerminalAdapter { 16 | terminal: Terminal>, 17 | } 18 | 19 | impl CrosstermTerminalAdapter { 20 | /// Create a new instance of the CrosstermTerminalAdapter 21 | pub fn new() -> TerminalResult { 22 | let backend = CrosstermBackend::new(std::io::stdout()); 23 | let terminal = Terminal::new(backend).map_err(|_| TerminalError::CannotConnectStdout)?; 24 | 25 | Ok(Self { terminal }) 26 | } 27 | 28 | pub fn raw(&self) -> &Terminal> { 29 | &self.terminal 30 | } 31 | 32 | pub fn raw_mut(&mut self) -> &mut Terminal> { 33 | &mut self.terminal 34 | } 35 | } 36 | 37 | impl TerminalAdapter for CrosstermTerminalAdapter { 38 | fn draw(&mut self, render_callback: F) -> TerminalResult 39 | where 40 | F: FnOnce(&mut ratatui::Frame<'_>), 41 | { 42 | self.raw_mut() 43 | .draw(render_callback) 44 | .map_err(|_| TerminalError::CannotDrawFrame) 45 | } 46 | 47 | fn clear_screen(&mut self) -> TerminalResult<()> { 48 | self.terminal 49 | .clear() 50 | .map_err(|_| TerminalError::CannotClear) 51 | } 52 | 53 | fn enable_raw_mode(&mut self) -> TerminalResult<()> { 54 | enable_raw_mode().map_err(|_| TerminalError::CannotToggleRawMode) 55 | } 56 | 57 | fn disable_raw_mode(&mut self) -> TerminalResult<()> { 58 | disable_raw_mode().map_err(|_| TerminalError::CannotToggleRawMode) 59 | } 60 | 61 | fn enter_alternate_screen(&mut self) -> TerminalResult<()> { 62 | execute!( 63 | self.terminal.backend_mut(), 64 | EnterAlternateScreen, 65 | EnableMouseCapture 66 | ) 67 | .map_err(|_| TerminalError::CannotEnterAlternateMode) 68 | } 69 | 70 | fn leave_alternate_screen(&mut self) -> TerminalResult<()> { 71 | execute!( 72 | self.terminal.backend_mut(), 73 | LeaveAlternateScreen, 74 | DisableMouseCapture 75 | ) 76 | .map_err(|_| TerminalError::CannotLeaveAlternateMode) 77 | } 78 | 79 | fn enable_mouse_capture(&mut self) -> TerminalResult<()> { 80 | execute!(self.raw_mut().backend_mut(), EnableMouseCapture) 81 | .map_err(|_| TerminalError::CannotToggleMouseCapture) 82 | } 83 | 84 | fn disable_mouse_capture(&mut self) -> TerminalResult<()> { 85 | execute!(self.raw_mut().backend_mut(), DisableMouseCapture) 86 | .map_err(|_| TerminalError::CannotToggleMouseCapture) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/terminal/adapter/termion.rs: -------------------------------------------------------------------------------- 1 | use std::io::Stdout; 2 | 3 | use ratatui::Terminal; 4 | use ratatui::prelude::TermionBackend as TermionLibBackend; 5 | use termion::input::MouseTerminal; 6 | use termion::raw::{IntoRawMode as _, RawTerminal}; 7 | use termion::screen::{AlternateScreen, IntoAlternateScreen as _}; 8 | 9 | use super::{TerminalAdapter, TerminalResult}; 10 | use crate::terminal::TerminalError; 11 | 12 | pub type TermionBackend = 13 | Terminal>>>>; 14 | 15 | /// TermionTerminalAdapter is the adapter for the [`termion`] terminal 16 | /// 17 | /// It implements the [`TerminalAdapter`] trait 18 | pub struct TermionTerminalAdapter { 19 | terminal: TermionBackend, 20 | } 21 | 22 | impl TermionTerminalAdapter { 23 | pub fn new() -> TerminalResult { 24 | let stdout = std::io::stdout() 25 | .into_raw_mode() 26 | .map_err(|_| TerminalError::CannotConnectStdout)? 27 | .into_alternate_screen() 28 | .map_err(|_| TerminalError::CannotConnectStdout)?; 29 | let stdout = MouseTerminal::from(stdout); 30 | 31 | let terminal = Terminal::new(TermionLibBackend::new(stdout)) 32 | .map_err(|_| TerminalError::CannotConnectStdout)?; 33 | 34 | Ok(Self { terminal }) 35 | } 36 | 37 | pub fn raw(&self) -> &TermionBackend { 38 | &self.terminal 39 | } 40 | 41 | pub fn raw_mut(&mut self) -> &mut TermionBackend { 42 | &mut self.terminal 43 | } 44 | } 45 | 46 | impl TerminalAdapter for TermionTerminalAdapter { 47 | fn draw(&mut self, render_callback: F) -> TerminalResult 48 | where 49 | F: FnOnce(&mut ratatui::Frame<'_>), 50 | { 51 | self.raw_mut() 52 | .draw(render_callback) 53 | .map_err(|_| TerminalError::CannotDrawFrame) 54 | } 55 | 56 | fn clear_screen(&mut self) -> TerminalResult<()> { 57 | self.terminal 58 | .clear() 59 | .map_err(|_| TerminalError::CannotClear) 60 | } 61 | 62 | fn disable_raw_mode(&mut self) -> TerminalResult<()> { 63 | Err(TerminalError::Unsupported) 64 | } 65 | 66 | fn enable_raw_mode(&mut self) -> TerminalResult<()> { 67 | Err(TerminalError::Unsupported) 68 | } 69 | 70 | fn enter_alternate_screen(&mut self) -> TerminalResult<()> { 71 | Err(TerminalError::Unsupported) 72 | } 73 | 74 | fn leave_alternate_screen(&mut self) -> TerminalResult<()> { 75 | Err(TerminalError::Unsupported) 76 | } 77 | 78 | fn disable_mouse_capture(&mut self) -> TerminalResult<()> { 79 | Err(TerminalError::Unsupported) 80 | } 81 | 82 | fn enable_mouse_capture(&mut self) -> TerminalResult<()> { 83 | Err(TerminalError::Unsupported) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/terminal/event_listener.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "crossterm")] 2 | mod crossterm; 3 | #[cfg(all(feature = "crossterm", feature = "async-ports"))] 4 | mod crossterm_async; 5 | #[cfg(feature = "termion")] 6 | mod termion; 7 | 8 | #[cfg(feature = "crossterm")] 9 | pub use crossterm::CrosstermInputListener; 10 | #[cfg(all(feature = "crossterm", feature = "async-ports"))] 11 | pub use crossterm_async::CrosstermAsyncStream; 12 | #[cfg(feature = "termion")] 13 | pub use termion::TermionInputListener; 14 | 15 | #[allow(unused_imports)] // used in the event listeners 16 | use crate::Event; 17 | -------------------------------------------------------------------------------- /src/terminal/event_listener/crossterm_async.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::EventStream; 2 | use futures_util::StreamExt; 3 | 4 | use crate::listener::{ListenerResult, PollAsync}; 5 | use crate::{Event, ListenerError}; 6 | 7 | /// The async input listener for crossterm. 8 | /// This can be manually added as a async port, or directly via [`EventListenerCfg::crossterm_input_listener()`](crate::EventListenerCfg::crossterm_input_listener) 9 | /// 10 | /// NOTE: This relies on [`From`] implementations in [`super::crossterm`]. 11 | #[doc(alias = "InputEventListener")] 12 | #[derive(Debug)] 13 | pub struct CrosstermAsyncStream { 14 | stream: EventStream, 15 | } 16 | 17 | impl CrosstermAsyncStream { 18 | pub fn new() -> Self { 19 | CrosstermAsyncStream { 20 | stream: EventStream::new(), 21 | } 22 | } 23 | } 24 | 25 | impl Default for CrosstermAsyncStream { 26 | fn default() -> Self { 27 | Self::new() 28 | } 29 | } 30 | 31 | #[async_trait::async_trait] 32 | impl PollAsync 33 | for CrosstermAsyncStream 34 | { 35 | async fn poll(&mut self) -> ListenerResult>> { 36 | let res = match self.stream.next().await { 37 | Some(Ok(event)) => event, 38 | Some(Err(_err)) => return Err(ListenerError::PollFailed), 39 | None => return Ok(None), 40 | }; 41 | 42 | Ok(Some(Event::from(res))) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/terminal/event_listener/termion.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | use std::time::Duration; 3 | 4 | use termion::event::{Event as TonEvent, Key as TonKey}; 5 | use termion::input::TermRead as _; 6 | 7 | use super::Event; 8 | use crate::ListenerError; 9 | use crate::event::{Key, KeyEvent, KeyModifiers}; 10 | use crate::listener::{ListenerResult, Poll}; 11 | 12 | /// The input listener for [`termion`]. 13 | #[doc(alias = "InputEventListener")] 14 | pub struct TermionInputListener 15 | where 16 | U: Eq + PartialEq + Clone + PartialOrd + Send, 17 | { 18 | ghost: PhantomData, 19 | } 20 | 21 | impl TermionInputListener 22 | where 23 | U: Eq + PartialEq + Clone + PartialOrd + Send, 24 | { 25 | pub fn new(_interval: Duration) -> Self { 26 | Self { ghost: PhantomData } 27 | } 28 | } 29 | 30 | impl Poll for TermionInputListener 31 | where 32 | U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, 33 | { 34 | fn poll(&mut self) -> ListenerResult>> { 35 | match std::io::stdin().events().next() { 36 | Some(Ok(ev)) => Ok(Some(Event::from(ev))), 37 | Some(Err(_)) => Err(ListenerError::PollFailed), 38 | None => Ok(None), 39 | } 40 | } 41 | } 42 | 43 | impl From for Event 44 | where 45 | U: Eq + PartialEq + Clone + PartialOrd + Send, 46 | { 47 | fn from(e: TonEvent) -> Self { 48 | match e { 49 | TonEvent::Key(key) => Self::Keyboard(key.into()), 50 | _ => Self::None, 51 | } 52 | } 53 | } 54 | 55 | impl From for KeyEvent { 56 | fn from(e: TonKey) -> Self { 57 | // Get modifiers 58 | let modifiers = match e { 59 | TonKey::Alt(c) if c.is_uppercase() => KeyModifiers::ALT | KeyModifiers::SHIFT, 60 | TonKey::Ctrl(c) if c.is_uppercase() => KeyModifiers::CONTROL | KeyModifiers::SHIFT, 61 | TonKey::Char(c) if c.is_uppercase() => KeyModifiers::SHIFT, 62 | TonKey::Alt(_) => KeyModifiers::ALT, 63 | TonKey::Ctrl(_) => KeyModifiers::CONTROL, 64 | _ => KeyModifiers::NONE, 65 | }; 66 | let code = match e { 67 | TonKey::Alt('\n') | TonKey::Char('\n') | TonKey::Ctrl('\n') => Key::Enter, 68 | TonKey::Alt('\t') | TonKey::Char('\t') | TonKey::Ctrl('\t') => Key::Tab, 69 | TonKey::Alt(c) | TonKey::Char(c) | TonKey::Ctrl(c) => Key::Char(c.to_ascii_lowercase()), 70 | TonKey::BackTab => Key::BackTab, 71 | TonKey::Backspace => Key::Backspace, 72 | TonKey::Delete => Key::Delete, 73 | TonKey::Down => Key::Down, 74 | TonKey::End => Key::End, 75 | TonKey::Left => Key::Left, 76 | TonKey::Right => Key::Right, 77 | TonKey::Up => Key::Up, 78 | TonKey::Home => Key::Home, 79 | TonKey::PageUp => Key::PageUp, 80 | TonKey::PageDown => Key::PageDown, 81 | TonKey::Insert => Key::Insert, 82 | TonKey::F(f) => Key::Function(f), 83 | TonKey::Null => Key::Null, 84 | TonKey::Esc => Key::Esc, 85 | TonKey::__IsNotComplete => Key::Null, 86 | TonKey::ShiftLeft => Key::ShiftLeft, 87 | TonKey::AltLeft => Key::AltLeft, 88 | TonKey::CtrlLeft => Key::CtrlLeft, 89 | TonKey::ShiftRight => Key::ShiftRight, 90 | TonKey::AltRight => Key::AltRight, 91 | TonKey::CtrlRight => Key::CtrlRight, 92 | TonKey::ShiftUp => Key::ShiftUp, 93 | TonKey::AltUp => Key::AltUp, 94 | TonKey::CtrlUp => Key::CtrlUp, 95 | TonKey::ShiftDown => Key::ShiftDown, 96 | TonKey::AltDown => Key::AltDown, 97 | TonKey::CtrlDown => Key::CtrlDown, 98 | TonKey::CtrlHome => Key::CtrlHome, 99 | TonKey::CtrlEnd => Key::CtrlEnd, 100 | }; 101 | Self { code, modifiers } 102 | } 103 | } 104 | 105 | #[cfg(test)] 106 | mod test { 107 | 108 | use pretty_assertions::assert_eq; 109 | use termion::event::MouseEvent; 110 | 111 | use super::*; 112 | use crate::mock::MockEvent; 113 | 114 | #[test] 115 | fn adapt_termion_key_event() { 116 | assert_eq!( 117 | KeyEvent::from(TonKey::BackTab), 118 | KeyEvent::from(Key::BackTab) 119 | ); 120 | assert_eq!( 121 | KeyEvent::from(TonKey::Backspace), 122 | KeyEvent::from(Key::Backspace) 123 | ); 124 | assert_eq!( 125 | KeyEvent::from(TonKey::Char('b')), 126 | KeyEvent::from(Key::Char('b')) 127 | ); 128 | assert_eq!( 129 | KeyEvent::from(TonKey::Ctrl('b')), 130 | KeyEvent { 131 | code: Key::Char('b'), 132 | modifiers: KeyModifiers::CONTROL, 133 | } 134 | ); 135 | assert_eq!( 136 | KeyEvent::from(TonKey::Alt('b')), 137 | KeyEvent { 138 | code: Key::Char('b'), 139 | modifiers: KeyModifiers::ALT 140 | } 141 | ); 142 | assert_eq!( 143 | KeyEvent::from(TonKey::Char('B')), 144 | KeyEvent { 145 | code: Key::Char('b'), 146 | modifiers: KeyModifiers::SHIFT, 147 | } 148 | ); 149 | assert_eq!(KeyEvent::from(TonKey::Delete), KeyEvent::from(Key::Delete)); 150 | assert_eq!(KeyEvent::from(TonKey::Down), KeyEvent::from(Key::Down)); 151 | assert_eq!(KeyEvent::from(TonKey::End), KeyEvent::from(Key::End)); 152 | assert_eq!( 153 | KeyEvent::from(TonKey::Char('\n')), 154 | KeyEvent::from(Key::Enter) 155 | ); 156 | assert_eq!(KeyEvent::from(TonKey::Esc), KeyEvent::from(Key::Esc)); 157 | assert_eq!( 158 | KeyEvent::from(TonKey::F(0)), 159 | KeyEvent::from(Key::Function(0)) 160 | ); 161 | assert_eq!(KeyEvent::from(TonKey::Home), KeyEvent::from(Key::Home)); 162 | assert_eq!(KeyEvent::from(TonKey::Insert), KeyEvent::from(Key::Insert)); 163 | assert_eq!(KeyEvent::from(TonKey::Left), KeyEvent::from(Key::Left)); 164 | assert_eq!(KeyEvent::from(TonKey::Null), KeyEvent::from(Key::Null)); 165 | assert_eq!( 166 | KeyEvent::from(TonKey::PageDown), 167 | KeyEvent::from(Key::PageDown) 168 | ); 169 | assert_eq!(KeyEvent::from(TonKey::PageUp), KeyEvent::from(Key::PageUp)); 170 | assert_eq!(KeyEvent::from(TonKey::Right), KeyEvent::from(Key::Right)); 171 | assert_eq!(KeyEvent::from(TonKey::Char('\t')), KeyEvent::from(Key::Tab)); 172 | assert_eq!(KeyEvent::from(TonKey::Up), KeyEvent::from(Key::Up)); 173 | assert_eq!( 174 | KeyEvent::from(TonKey::__IsNotComplete), 175 | KeyEvent::from(Key::Null) 176 | ); 177 | } 178 | 179 | #[test] 180 | fn adapt_termion_event() { 181 | type AppEvent = Event; 182 | assert_eq!( 183 | AppEvent::from(TonEvent::Key(TonKey::Backspace)), 184 | Event::Keyboard(KeyEvent::from(Key::Backspace)) 185 | ); 186 | assert_eq!( 187 | AppEvent::from(TonEvent::Mouse(MouseEvent::Hold(0, 0))), 188 | Event::None 189 | ); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | //! ## Utils 2 | //! 3 | //! This module exposes utilities 4 | 5 | pub mod parser; 6 | mod types; 7 | 8 | // export types 9 | pub use types::{Email, PhoneNumber}; 10 | -------------------------------------------------------------------------------- /src/utils/types.rs: -------------------------------------------------------------------------------- 1 | //! ## Types 2 | //! 3 | //! This module exposes types used by utilities 4 | 5 | /// Represents a phone number 6 | #[derive(Eq, PartialEq, Debug, Clone)] 7 | pub struct PhoneNumber { 8 | /// Prefix number (without `00` or `+`) 9 | pub prefix: Option, 10 | /// Phone number without prefix 11 | pub number: String, 12 | } 13 | 14 | impl PhoneNumber { 15 | pub fn new>(prefix: Option, number: S) -> Self { 16 | let number = number.as_ref().replace([' ', '-'], ""); 17 | Self { 18 | prefix: prefix.map(|x| x.as_ref().to_string()), 19 | number, 20 | } 21 | } 22 | 23 | /// Returns the full number with syntax `+{prefix}{number}` 24 | pub fn phone_number(&self) -> String { 25 | match &self.prefix { 26 | None => self.number.clone(), 27 | Some(prefix) => format!("+{prefix}{}", self.number), 28 | } 29 | } 30 | } 31 | 32 | /// Represents an email address 33 | #[derive(Eq, PartialEq, Debug, Clone)] 34 | pub struct Email { 35 | /// Address name (e.g. `foo.bar@preema.it` => `foo.bar`) 36 | pub name: String, 37 | /// Email agent name (e.g. `foo.bar@preema.it` => `preema.it`) 38 | pub agent: String, 39 | } 40 | 41 | impl Email { 42 | pub fn new>(name: S, agent: S) -> Self { 43 | Self { 44 | name: name.into(), 45 | agent: agent.into(), 46 | } 47 | } 48 | 49 | /// Returns the email address 50 | pub fn address(&self) -> String { 51 | format!("{}@{}", self.name, self.agent) 52 | } 53 | } 54 | 55 | #[cfg(test)] 56 | mod test { 57 | 58 | use pretty_assertions::assert_eq; 59 | 60 | use super::*; 61 | 62 | #[test] 63 | fn phone_number() { 64 | let phone = PhoneNumber::new(Some("39"), "345 777 6117"); 65 | assert_eq!(phone.prefix.as_deref().unwrap(), "39"); 66 | assert_eq!(phone.number.as_str(), "3457776117"); 67 | assert_eq!(phone.phone_number().as_str(), "+393457776117"); 68 | let phone = PhoneNumber::new(None, "345-777-6117"); 69 | assert!(phone.prefix.is_none()); 70 | assert_eq!(phone.number.as_str(), "3457776117"); 71 | assert_eq!(phone.phone_number().as_str(), "3457776117"); 72 | } 73 | 74 | #[test] 75 | fn email() { 76 | let email = Email::new("cvisintin", "youtube.com"); 77 | assert_eq!(email.name.as_str(), "cvisintin"); 78 | assert_eq!(email.agent.as_str(), "youtube.com"); 79 | assert_eq!(email.address().as_str(), "cvisintin@youtube.com"); 80 | } 81 | } 82 | --------------------------------------------------------------------------------