diff --git a/README.md b/README.md index 66e2ecb..cc728f4 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,6 @@ This repository contains templates for bootstrapping a Rust [`Ratatui`](https://github.com/ratatui-org/ratatui) & [`crossterm`](https://github.com/crossterm-rs/crossterm). -## Project structure - -```text -src/ -├── app.rs -> holds the state and application logic -├── event.rs -> handles the terminal events (key press, mouse click, resize, etc.) -├── handler.rs -> handles the key press events and updates the application -├── lib.rs -> module definitions -├── main.rs -> entry-point -├── tui.rs -> initializes/exits the terminal interface -└── ui.rs -> renders the widgets / UI -``` - ## Creating a project 1. Install [`cargo-generate`](https://github.com/cargo-generate/cargo-generate#installation) @@ -29,9 +16,26 @@ src/ 2. Create a new app based on this repository: ```shell - cargo generate ratatui-org/ratatui-template simple + cargo generate ratatui-org/ratatui-template ``` +3. Choose either the [Simple](#simple-template) or [Async](./async/README.md) template. + +## Simple template + +The simple template will create the following project structure: + +```text +src/ +├── app.rs -> holds the state and application logic +├── event.rs -> handles the terminal events (key press, mouse click, resize, etc.) +├── handler.rs -> handles the key press events and updates the application +├── lib.rs -> module definitions +├── main.rs -> entry-point +├── tui.rs -> initializes/exits the terminal interface +└── ui.rs -> renders the widgets / UI +``` + ## See also - [Rust Munich Meetup #8 - Designing TUI Applications in Rust](https://www.youtube.com/watch?v=ogdJnOLo238) diff --git a/async/.github/workflows/ci.yml b/async/.github/workflows/ci.yml new file mode 100644 index 0000000..7494981 --- /dev/null +++ b/async/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: Build Template +on: + push: + branches: + - main + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + env: + PROJECT_NAME: ratatui-github-example + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Run cargo generate + uses: cargo-generate/cargo-generate-action@v0.18.3 + with: + name: ${{ env.PROJECT_NAME }} + subfolder: template + template_values_file: .github/workflows/template.toml + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Cargo check + # we need to move the generated project to a temp folder, away from the template project + # otherwise `cargo` runs would fail + # see https://github.com/rust-lang/cargo/issues/9922 + run: | + mv $PROJECT_NAME ${{ runner.temp }}/ + cd ${{ runner.temp }}/$PROJECT_NAME + cargo check --tests diff --git a/async/.github/workflows/docs.yml b/async/.github/workflows/docs.yml new file mode 100644 index 0000000..0d58803 --- /dev/null +++ b/async/.github/workflows/docs.yml @@ -0,0 +1,50 @@ +name: docs + +on: + push: + branches: + - main + pull_request: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + steps: + - uses: actions/checkout@v2 + + - name: Setup mdBook + uses: peaceiris/actions-mdbook@v1 + with: + mdbook-version: "latest" + + - uses: baptiste0928/cargo-install@v2 + with: + crate: mdbook-admonish + version: "1.9.0" + + - uses: baptiste0928/cargo-install@v2 + with: + crate: mdbook-svgbob2 + version: "0.3.0" + + - uses: baptiste0928/cargo-install@v2 + with: + crate: mdbook-linkcheck + version: "0.7.7" + + - name: Build with mdbook + run: mdbook build book + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./book/book/html + force_orphan: true diff --git a/async/.github/workflows/template.toml b/async/.github/workflows/template.toml new file mode 100644 index 0000000..77297b8 --- /dev/null +++ b/async/.github/workflows/template.toml @@ -0,0 +1,7 @@ +[values] +gh-username = "kdheepak" +project-description = "Example of ratatui template" +msrv = "stable" +use_gitserver = false +crossterm_io = "stderr" +use_rustfmt = false diff --git a/async/.gitignore b/async/.gitignore new file mode 100644 index 0000000..ff021e8 --- /dev/null +++ b/async/.gitignore @@ -0,0 +1,18 @@ +# Created by https://www.toptal.com/developers/gitignore/api/rust +# Edit at https://www.toptal.com/developers/gitignore?templates=rust + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ +/target +.data + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# End of https://www.toptal.com/developers/gitignore/api/rust diff --git a/async/.justfile b/async/.justfile new file mode 100644 index 0000000..4a4a471 --- /dev/null +++ b/async/.justfile @@ -0,0 +1,44 @@ +default: + @just --list + +fmt: + cargo fmt + prettier --write . + just --fmt --unstable + +update: + cargo upgrade --incompatible + cargo update + +check: + pre-commit run --all-files + cargo check + cargo clippy + +build: + cargo build --all-targets + +test: + cargo test run --workspace --all-targets + +changelog: + git cliff -o CHANGELOG.md + prettier --write CHANGELOG.md + +binary-name := "ratatui-hello-world" + +generate-hello-world: + cargo generate --path . --name {{binary-name}} -d project-description="Hello World project using ratatui-template" -d gh-username=kdheepak -d msrv="stable" + +generate: + @just clean + @just generate-hello-world + +clean: + rm -rf {{binary-name}} + +generate-and-run: + @just generate + cd {{binary-name}} && cargo run + @just clean + diff --git a/async/.pre-commit-config.yaml b/async/.pre-commit-config.yaml new file mode 100644 index 0000000..dbfc93f --- /dev/null +++ b/async/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +default_stages: [commit] +default_install_hook_types: [pre-commit, commit-msg] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-json + - id: check-merge-conflict + - id: check-symlinks + - id: check-toml + - id: check-vcs-permalinks + - id: check-xml + - id: check-yaml + - id: destroyed-symlinks + - id: detect-private-key + - id: fix-byte-order-marker + - id: mixed-line-ending + - id: pretty-format-json + - id: trailing-whitespace + - repo: https://github.com/rhysd/actionlint + rev: v1.6.24 + hooks: + - id: actionlint + +ci: + autofix_prs: false + autoupdate_commit_msg: "chore: pre-commit autoupdate" + skip: [actionlint] diff --git a/async/LICENSE b/async/LICENSE new file mode 100644 index 0000000..d44dc00 --- /dev/null +++ b/async/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 Dheepak Krishnamurthy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/async/README.md b/async/README.md new file mode 100644 index 0000000..e391158 --- /dev/null +++ b/async/README.md @@ -0,0 +1,131 @@ +# async-template + +![async template demo](https://user-images.githubusercontent.com/1813121/277114001-0d25a09c-f24e-4ffc-8763-cd258828cec0.gif) + +## Usage + +You can start by using `cargo-generate`: + +```shell +cargo install cargo-generate +cargo generate ratatui-org/templates async --name ratatui-hello-world +cd ratatui-hello-world +``` + +## Features + +- Uses [tokio](https://tokio.rs/) for async events + - Start and stop key events to shell out to another TUI like vim + - Supports suspend signal hooks +- Logs using [tracing](https://github.com/tokio-rs/tracing) +- [better-panic](https://github.com/mitsuhiko/better-panic) +- [color-eyre](https://github.com/eyre/color-eyre) +- [human-panic](https://github.com/rust-cli/human-panic) +- Clap for command line argument parsing +- `Component` trait with + [`Home`](https://github.com/ratatui-org/async-template/blob/main/template/src/components/home.rs) + and + [`Fps`](https://github.com/ratatui-org/async-template/blob/main/template/src/components/fps.rs) + components as examples + +## Advanced Usage + +You can also use a +[`template.toml`](https://github.com/ratatui-org/async-template/blob/main/.github/workflows/template.toml) +file to skip the prompts: + +```bash +$ cargo generate --git https://github.com/ratatui-org/async-template --template-values-file .github/workflows/template.toml --name ratatui-hello-world +# OR generate from local clone +$ cargo generate --path . --template-values-file .github/workflows/template.toml --name ratatui-hello-world +``` + +## Running your App + +```bash +cargo run # Press `q` to exit +``` + +## Show help + +```bash +$ cargo run -- --help +Hello World project using ratatui-template + +Usage: ratatui-hello-world [OPTIONS] + +Options: + -t, --tick-rate Tick rate, i.e. number of ticks per second [default: 1] + -f, --frame-rate Frame rate, i.e. number of frames per second [default: 60] + -h, --help Print help + -V, --version Print version +``` + +## Show `version` + +Without direnv variables: + +```bash +$ cargo run -- --version + Finished dev [unoptimized + debuginfo] target(s) in 0.07s + Running `target/debug/ratatui-hello-world --version` +ratatui-hello-world v0.1.0-47-eb0a31a + +Authors: Dheepak Krishnamurthy + +Config directory: /Users/kd/Library/Application Support/com.kdheepak.ratatui-hello-world +Data directory: /Users/kd/Library/Application Support/com.kdheepak.ratatui-hello-world +``` + +With direnv variables: + +```bash +$ direnv allow +direnv: loading ~/gitrepos/async-template/ratatui-hello-world/.envrc +direnv: export +RATATUI_HELLO_WORLD_CONFIG +RATATUI_HELLO_WORLD_DATA +RATATUI_HELLO_WORLD_LOG_LEVEL + +$ # OR + +$ export RATATUI_HELLO_WORLD_CONFIG=`pwd`/.config +$ export RATATUI_HELLO_WORLD_DATA=`pwd`/.data +$ export RATATUI_HELLO_WORLD_LOG_LEVEL=debug + +$ cargo run -- --version + Finished dev [unoptimized + debuginfo] target(s) in 0.07s + Running `target/debug/ratatui-hello-world --version` +ratatui-hello-world v0.1.0-47-eb0a31a + +Authors: Dheepak Krishnamurthy + +Config directory: /Users/kd/gitrepos/async-template/ratatui-hello-world/.config +Data directory: /Users/kd/gitrepos/async-template/ratatui-hello-world/.data +``` + +## Documentation + +Read documentation on design decisions in the template here: + + +## Counter + Text Input Demo + +This repo contains a `ratatui-counter` folder that is a working demo as an example. If you wish to +run a demo without using `cargo generate`, you can run the counter + text input demo by following +the instructions below: + +```bash +git clone https://github.com/ratatui-org/async-template +cd async-template +cd ratatui-counter # counter + text input demo + +export RATATUI_COUNTER_CONFIG=`pwd`/.config +export RATATUI_COUNTER_DATA=`pwd`/.data +export RATATUI_COUNTER_LOG_LEVEL=debug +# OR +direnv allow + +cargo run +``` + +You should see a demo like this: + +![counter demo](https://github.com/ratatui-org/async-template/assets/1813121/057a0fe9-9f6d-4f8c-963c-ca2725721bdd) diff --git a/async/book/.gitignore b/async/book/.gitignore new file mode 100644 index 0000000..7585238 --- /dev/null +++ b/async/book/.gitignore @@ -0,0 +1 @@ +book diff --git a/async/book/book.toml b/async/book/book.toml new file mode 100644 index 0000000..732934a --- /dev/null +++ b/async/book/book.toml @@ -0,0 +1,31 @@ +[book] +authors = ["Dheepak Krishnamurthy"] +language = "en" +multilingual = false +src = "src" +title = "async-template" + +[preprocessor] + +[preprocessor.admonish] +command = "mdbook-admonish" +assets_version = "2.0.2" # do not edit: managed by `mdbook-admonish install` + +[preprocessor.svgbob2] + +[preprocessor.catppuccin] +assets_version = "0.2.1" # DO NOT EDIT: Managed by `mdbook-catppuccin install` + +[output] + +[output.html] +additional-css = ["./mdbook-admonish.css", "./theme/catppuccin.css", "./theme/catppuccin-highlight.css"] +git-repository-url = "https://github.com/ratatui-org/async-template" +git-repository-icon = "fa-github" +edit-url-template = "https://github.com/ratatui-org/async-template/edit/main/book/{path}" +site-url = "/async-template/" + +[output.html.playground] +runnable = false + +[output.linkcheck] diff --git a/async/book/mdbook-admonish.css b/async/book/mdbook-admonish.css new file mode 100644 index 0000000..c3e9869 --- /dev/null +++ b/async/book/mdbook-admonish.css @@ -0,0 +1,353 @@ +@charset "UTF-8"; +:root { + --md-admonition-icon--note: + url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--abstract: + url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--info: + url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--tip: + url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--success: + url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--question: + url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--warning: + url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--failure: + url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--danger: + url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--bug: + url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--example: + url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--quote: + url("data:image/svg+xml;charset=utf-8,"); + --md-details-icon: + url("data:image/svg+xml;charset=utf-8,"); +} + +:is(.admonition) { + display: flow-root; + margin: 1.5625em 0; + padding: 0 1.2rem; + color: var(--fg); + page-break-inside: avoid; + background-color: var(--bg); + border: 0 solid black; + border-inline-start-width: 0.4rem; + border-radius: 0.2rem; + box-shadow: 0 0.2rem 1rem rgba(0, 0, 0, 0.05), 0 0 0.1rem rgba(0, 0, 0, 0.1); +} +@media print { + :is(.admonition) { + box-shadow: none; + } +} +:is(.admonition) > * { + box-sizing: border-box; +} +:is(.admonition) :is(.admonition) { + margin-top: 1em; + margin-bottom: 1em; +} +:is(.admonition) > .tabbed-set:only-child { + margin-top: 0; +} +html :is(.admonition) > :last-child { + margin-bottom: 1.2rem; +} + +a.admonition-anchor-link { + display: none; + position: absolute; + left: -1.2rem; + padding-right: 1rem; +} +a.admonition-anchor-link:link, a.admonition-anchor-link:visited { + color: var(--fg); +} +a.admonition-anchor-link:link:hover, a.admonition-anchor-link:visited:hover { + text-decoration: none; +} +a.admonition-anchor-link::before { + content: "§"; +} + +:is(.admonition-title, summary.admonition-title) { + position: relative; + min-height: 4rem; + margin-block: 0; + margin-inline: -1.6rem -1.2rem; + padding-block: 0.8rem; + padding-inline: 4.4rem 1.2rem; + font-weight: 700; + background-color: rgba(68, 138, 255, 0.1); + display: flex; +} +:is(.admonition-title, summary.admonition-title) p { + margin: 0; +} +html :is(.admonition-title, summary.admonition-title):last-child { + margin-bottom: 0; +} +:is(.admonition-title, summary.admonition-title)::before { + position: absolute; + top: 0.625em; + inset-inline-start: 1.6rem; + width: 2rem; + height: 2rem; + background-color: #448aff; + mask-image: url('data:image/svg+xml;charset=utf-8,'); + -webkit-mask-image: url('data:image/svg+xml;charset=utf-8,'); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-size: contain; + content: ""; +} +:is(.admonition-title, summary.admonition-title):hover a.admonition-anchor-link { + display: initial; +} + +details.admonition > summary.admonition-title::after { + position: absolute; + top: 0.625em; + inset-inline-end: 1.6rem; + height: 2rem; + width: 2rem; + background-color: currentcolor; + mask-image: var(--md-details-icon); + -webkit-mask-image: var(--md-details-icon); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-size: contain; + content: ""; + transform: rotate(0deg); + transition: transform 0.25s; +} +details[open].admonition > summary.admonition-title::after { + transform: rotate(90deg); +} + +:is(.admonition):is(.note) { + border-color: #448aff; +} + +:is(.note) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(68, 138, 255, 0.1); +} +:is(.note) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #448aff; + mask-image: var(--md-admonition-icon--note); + -webkit-mask-image: var(--md-admonition-icon--note); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.abstract, .summary, .tldr) { + border-color: #00b0ff; +} + +:is(.abstract, .summary, .tldr) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(0, 176, 255, 0.1); +} +:is(.abstract, .summary, .tldr) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #00b0ff; + mask-image: var(--md-admonition-icon--abstract); + -webkit-mask-image: var(--md-admonition-icon--abstract); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.info, .todo) { + border-color: #00b8d4; +} + +:is(.info, .todo) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(0, 184, 212, 0.1); +} +:is(.info, .todo) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #00b8d4; + mask-image: var(--md-admonition-icon--info); + -webkit-mask-image: var(--md-admonition-icon--info); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.tip, .hint, .important) { + border-color: #00bfa5; +} + +:is(.tip, .hint, .important) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(0, 191, 165, 0.1); +} +:is(.tip, .hint, .important) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #00bfa5; + mask-image: var(--md-admonition-icon--tip); + -webkit-mask-image: var(--md-admonition-icon--tip); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.success, .check, .done) { + border-color: #00c853; +} + +:is(.success, .check, .done) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(0, 200, 83, 0.1); +} +:is(.success, .check, .done) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #00c853; + mask-image: var(--md-admonition-icon--success); + -webkit-mask-image: var(--md-admonition-icon--success); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.question, .help, .faq) { + border-color: #64dd17; +} + +:is(.question, .help, .faq) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(100, 221, 23, 0.1); +} +:is(.question, .help, .faq) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #64dd17; + mask-image: var(--md-admonition-icon--question); + -webkit-mask-image: var(--md-admonition-icon--question); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.warning, .caution, .attention) { + border-color: #ff9100; +} + +:is(.warning, .caution, .attention) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(255, 145, 0, 0.1); +} +:is(.warning, .caution, .attention) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #ff9100; + mask-image: var(--md-admonition-icon--warning); + -webkit-mask-image: var(--md-admonition-icon--warning); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.failure, .fail, .missing) { + border-color: #ff5252; +} + +:is(.failure, .fail, .missing) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(255, 82, 82, 0.1); +} +:is(.failure, .fail, .missing) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #ff5252; + mask-image: var(--md-admonition-icon--failure); + -webkit-mask-image: var(--md-admonition-icon--failure); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.danger, .error) { + border-color: #ff1744; +} + +:is(.danger, .error) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(255, 23, 68, 0.1); +} +:is(.danger, .error) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #ff1744; + mask-image: var(--md-admonition-icon--danger); + -webkit-mask-image: var(--md-admonition-icon--danger); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.bug) { + border-color: #f50057; +} + +:is(.bug) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(245, 0, 87, 0.1); +} +:is(.bug) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #f50057; + mask-image: var(--md-admonition-icon--bug); + -webkit-mask-image: var(--md-admonition-icon--bug); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.example) { + border-color: #7c4dff; +} + +:is(.example) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(124, 77, 255, 0.1); +} +:is(.example) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #7c4dff; + mask-image: var(--md-admonition-icon--example); + -webkit-mask-image: var(--md-admonition-icon--example); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.quote, .cite) { + border-color: #9e9e9e; +} + +:is(.quote, .cite) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(158, 158, 158, 0.1); +} +:is(.quote, .cite) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #9e9e9e; + mask-image: var(--md-admonition-icon--quote); + -webkit-mask-image: var(--md-admonition-icon--quote); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +.navy :is(.admonition) { + background-color: var(--sidebar-bg); +} + +.ayu :is(.admonition), .coal :is(.admonition) { + background-color: var(--theme-hover); +} + +.rust :is(.admonition) { + background-color: var(--sidebar-bg); + color: var(--sidebar-fg); +} +.rust .admonition-anchor-link:link, .rust .admonition-anchor-link:visited { + color: var(--sidebar-fg); +} diff --git a/async/book/src/00-structure.md b/async/book/src/00-structure.md new file mode 100644 index 0000000..713e0b5 --- /dev/null +++ b/async/book/src/00-structure.md @@ -0,0 +1,25 @@ +# Structure of files + +The rust files in the `async-template` project are organized as follows: + +```bash +$ tree +. +├── build.rs +└── src + ├── action.rs + ├── components + │ ├── app.rs + │ └── mod.rs + ├── config.rs + ├── main.rs + ├── runner.rs + ├── tui.rs + └── utils.rs +``` + +Once you have setup the project, you shouldn't need to change the contents of anything outside of +the `components` folder. + +Let's discuss the contents of the files in the `src` folder first, how these contents of these files +interact with each other and why they do what they are doing. diff --git a/async/book/src/01-structure.md b/async/book/src/01-structure.md new file mode 100644 index 0000000..21af908 --- /dev/null +++ b/async/book/src/01-structure.md @@ -0,0 +1,23 @@ +# `main.rs` + +In this section, let's just cover the contents of `main.rs`, `build.rs` and `utils.rs`. + +The `main.rs` file is the entry point of the application. Here's the complete `main.rs` file: + +```rust,no_run,noplayground +{{#include ../../ratatui-counter/src/main.rs:all}} +``` + +In essence, the `main` function creates an instance of `App` and calls `App.run()`, which runs +the "`handle event` -> `update state` -> `draw`" loop. We will talk more about this in a later +section. + +This `main.rs` file incorporates some key features that are not necessarily related to `ratatui`, +but in my opinion, essential for any Terminal User Interface (TUI) program: + +- Command Line Argument Parsing (`clap`) +- XDG Base Directory Specification +- Logging +- Panic Handler + +These are described in more detail in the [`utils.rs` section](./08-structure.md). diff --git a/async/book/src/02-structure.md b/async/book/src/02-structure.md new file mode 100644 index 0000000..f007260 --- /dev/null +++ b/async/book/src/02-structure.md @@ -0,0 +1,529 @@ +# `tui.rs` + +## Terminal + +In this section of the tutorial, we are going to discuss the basic components of the `Tui` struct. + +You'll find most people setup and teardown of a terminal application using `crossterm` like so: + +```rust +fn setup_terminal() -> Result>> { + let mut stdout = io::stdout(); + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture, HideCursor)?; + Terminal::new(CrosstermBackend::new(stdout)) +} + +fn teardown_terminal(terminal: &mut Terminal>) -> Result<()> { + let mut stdout = io::stdout(); + crossterm::terminal::disable_raw_mode()?; + crossterm::execute!(stdout, LeaveAlternateScreen, DisableMouseCapture, ShowCursor)?; + Ok(()) +} + +fn main() -> Result<()> { + let mut terminal = setup_terminal()?; + run_app(&mut terminal)?; + teardown_terminal(&mut terminal)?; + Ok(()) +} +``` + +You can use `termion` or `termwiz` instead here, and you'll have to change the implementation of +`setup_terminal` and `teardown_terminal`. + +I personally like to use `crossterm` so that I can run the TUI on windows as well. + +```admonish note +Terminals have two screen buffers for each window. +The default screen buffer is what you are dropped into when you start up a terminal. +The second screen buffer, called the alternate screen, is used for running interactive apps such as the `vim`, `less` etc. + +Here's a 8 minute talk on Terminal User Interfaces I gave at JuliaCon2020: that might be worth watching for more information about how terminal user interfaces work. +``` + +We can reorganize the setup and teardown functions into an `enter()` and `exit()` methods on a `Tui` +struct. + +```rust,no_run,noplayground +use color_eyre::eyre::{anyhow, Context, Result}; +use crossterm::{ + cursor, + event::{DisableMouseCapture, EnableMouseCapture}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::backend::CrosstermBackend as Backend; +use tokio::{ + sync::{mpsc, Mutex}, + task::JoinHandle, +}; + +pub type Frame<'a> = ratatui::Frame<'a, Backend>; + +pub struct Tui { + pub terminal: ratatui::Terminal>, +} + +impl Tui { + pub fn new() -> Result { + let terminal = ratatui::Terminal::new(Backend::new(std::io::stderr()))?; + Ok(Self { terminal }) + } + + pub fn enter(&self) -> Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(std::io::stderr(), EnterAlternateScreen, EnableMouseCapture, cursor::Hide)?; + Ok(()) + } + + pub fn exit(&self) -> Result<()> { + crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, DisableMouseCapture, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + Ok(()) + } + + pub fn suspend(&self) -> Result<()> { + self.exit()?; + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; + Ok(()) + } + + pub fn resume(&self) -> Result<()> { + self.enter()?; + Ok(()) + } +} +``` + +```admonish note +This is the same `Tui` struct we used in `initialize_panic_handler()`. We call `Tui::exit()` before printing the stacktrace. +``` + +Feel free to modify this as you need for use with `termion` or `wezterm`. + +The type alias to `Frame` is only to make the `components` folder easier to work with, and is not +strictly required. + +## Event + +In it's simplest form, most applications will have a `main` loop like this: + +```rust +fn main() -> Result<()> { + let mut app = App::new(); + + let mut t = Tui::new()?; + + t.enter()?; // raw mode enabled + + loop { + + // get key event and update state + // ... Special handling to read key or mouse events required here + + t.terminal.draw(|f| { // <- `terminal.draw` is the only ratatui function here + ui(app, f) // render state to terminal + })?; + + } + + t.exit()?; // raw mode disabled + + Ok(()) +} +``` + +```admonish note + +The `terminal.draw(|f| { ui(app, f); })` call is the only line in the code above +that uses `ratatui` functionality. +You can learn more about +[`draw` from the official documentation](https://docs.rs/ratatui/latest/ratatui/terminal/struct.Terminal.html#method.draw). +Essentially, `terminal.draw()` takes a callback that takes a +[`Frame`](https://docs.rs/ratatui/latest/ratatui/terminal/struct.Frame.html) and expects +the callback to render widgets to that frame, which is then drawn to the terminal +using a double buffer technique. + +``` + +While we are in the "raw mode", i.e. after we call `t.enter()`, any key presses in that terminal +window are sent to `stdin`. We have to read these key presses from `stdin` if we want to act on +them. + +There's a number of different ways to do that. `crossterm` has a `event` module that implements +features to read these key presses for us. + +Let's assume we were building a simple "counter" application, that incremented a counter when we +pressed `j` and decremented a counter when we pressed `k`. + +```rust +fn main() -> Result { + let mut app = App::new(); + + let mut t = Tui::new()?; + + t.enter()?; + + loop { + if crossterm::event::poll(Duration::from_millis(250))? { + if let Event::Key(key) = crossterm::event::read()? { + match key.code { + KeyCode::Char('j') => app.increment(), + KeyCode::Char('k') => app.decrement(), + KeyCode::Char('q') => break, + _ => (), + } + } + }; + + t.terminal.draw(|f| { + ui(app, f) + })?; + } + + t.exit()?; + + Ok(()) +} +``` + +This works perfectly fine, and a lot of small to medium size programs can get away with doing just +that. + +However, this approach conflates the key input handling with app state updates, and does so in the +"draw" loop. The practical issue with this approach is we block the draw loop for 250 ms waiting for +a key press. This can have odd side effects, for example pressing an holding a key will result in +faster draws to the terminal. + +In terms of architecture, the code could get complicated to reason about. For example, we may even +want key presses to mean _different_ things depending on the state of the app (when you are focused +on an input field, you may want to enter the letter `"j"` into the text input field, but when +focused on a list of items, you may want to scroll down the list.) + +![Pressing `j` 3 times to increment counter and 3 times in the text field](https://user-images.githubusercontent.com/1813121/254444604-de8cfcfa-eeec-417a-a8b0-92a7ccb5fcb5.gif) + + + +We have to do a few different things set ourselves up, so let's take things one step at a time. + +First, instead of polling, we are going to introduce channels to get the key presses asynchronously +and send them over a channel. We will then receive on the channel in the `main` loop. + +There are two ways to do this. We can either use OS threads or "green" threads, i.e. tasks, i.e. +rust's `async`-`await` features + a future executor. + +Here's example code of reading key presses asynchronously using `std::thread` and `tokio::task`. + +## `std::thread` + +```rust +enum Event { + Key(crossterm::event::KeyEvent) +} + +struct EventHandler { + rx: std::sync::mpsc::Receiver, +} + +impl EventHandler { + fn new() -> Self { + let tick_rate = std::time::Duration::from_millis(250); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + loop { + if crossterm::event::poll(tick_rate)? { + match crossterm::event::read()? { + CrosstermEvent::Key(e) => tx.send(Event::Key(e)), + _ => unimplemented!(), + }? + } + } + }) + + EventHandler { rx } + } + + fn next(&self) -> Result { + Ok(self.rx.recv()?) + } +} +``` + +## `tokio::task` + +```rust +enum Event { + Key(crossterm::event::KeyEvent) +} + +struct EventHandler { + rx: tokio::sync::mpsc::UnboundedReceiver, +} + +impl EventHandler { + fn new() -> Self { + let tick_rate = std::time::Duration::from_millis(250); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + loop { + if crossterm::event::poll(tick_rate)? { + match crossterm::event::read()? { + CrosstermEvent::Key(e) => tx.send(Event::Key(e)), + _ => unimplemented!(), + }? + } + } + }) + + EventHandler { rx } + } + + async fn next(&self) -> Result { + Ok(self.rx.recv().await.ok()?) + } +} +``` + +## `diff` + +```diff + enum Event { + Key(crossterm::event::KeyEvent) + } + + struct EventHandler { +- rx: std::sync::mpsc::Receiver, ++ rx: tokio::sync::mpsc::UnboundedReceiver, + } + + impl EventHandler { + fn new() -> Self { + let tick_rate = std::time::Duration::from_millis(250); +- let (tx, rx) = std::sync::mpsc::channel(); ++ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); +- std::thread::spawn(move || { ++ tokio::spawn(async move { + loop { + if crossterm::event::poll(tick_rate)? { + match crossterm::event::read()? { + CrosstermEvent::Key(e) => tx.send(Event::Key(e)), + _ => unimplemented!(), + }? + } + } + }) + + EventHandler { rx } + } + +- fn next(&self) -> Result { ++ async fn next(&self) -> Result { +- Ok(self.rx.recv()?) ++ Ok(self.rx.recv().await.ok()?) + } + } +``` + +````admonish warning + +A lot of examples out there in the wild might use the following code for sending key presses: + + +```rust + CrosstermEvent::Key(e) => tx.send(Event::Key(e)), +``` + +However, on Windows, when using `Crossterm`, this will send the same `Event::Key(e)` twice; one for when you press the key, i.e. `KeyEventKind::Press` and one for when you release the key, i.e. `KeyEventKind::Release`. +On `MacOS` and `Linux` only `KeyEventKind::Press` kinds of `key` event is generated. + +To make the code work as expected across all platforms, you can do this instead: + +```rust + CrosstermEvent::Key(key) => { + if key.kind == KeyEventKind::Press { + event_tx.send(Event::Key(key)).unwrap(); + } + }, +``` + +```` + +Tokio is an asynchronous runtime for the Rust programming language. It is one of the more popular +runtimes for asynchronous programming in rust. You can learn more about here +. For the rest of the tutorial here, we are going to assume we want +to use tokio. I highly recommend you read the official `tokio` documentation. + +If we use `tokio`, receiving a event requires `.await`. So our `main` loop now looks like this: + +```rust +#[tokio::main] +async fn main() -> { + let mut app = App::new(); + + let events = EventHandler::new(); + + let mut t = Tui::new()?; + + t.enter()?; + + loop { + if let Event::Key(key) = events.next().await? { + match key.code { + KeyCode::Char('j') => app.increment(), + KeyCode::Char('k') => app.decrement(), + KeyCode::Char('q') => break, + _ => (), + } + } + + t.terminal.draw(|f| { + ui(app, f) + })?; + } + + t.exit()?; + + Ok(()) +} +``` + +### Additional improvements + +We are going to modify our `EventHandler` to handle a `AppTick` event. We want the `Event::AppTick` +to be sent at regular intervals. We are also going to want to use a `CancellationToken` to stop the +tokio task on request. + +[`tokio`'s `select!` macro](https://tokio.rs/tokio/tutorial/select) allows us to wait on multiple +`async` computations and returns when a single computation completes. + +Here's what the completed `EventHandler` code now looks like: + +```rust,no_run,noplayground +use color_eyre::eyre::Result; +use crossterm::{ + cursor, + event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent}, +}; +use futures::{FutureExt, StreamExt}; +use tokio::{ + sync::{mpsc, oneshot}, + task::JoinHandle, +}; + +#[derive(Clone, Copy, Debug)] +pub enum Event { + Error, + AppTick, + Key(KeyEvent), +} + +#[derive(Debug)] +pub struct EventHandler { + _tx: mpsc::UnboundedSender, + rx: mpsc::UnboundedReceiver, + task: Option>, + stop_cancellation_token: CancellationToken, +} + +impl EventHandler { + pub fn new(tick_rate: u64) -> Self { + let tick_rate = std::time::Duration::from_millis(tick_rate); + + let (tx, rx) = mpsc::unbounded_channel(); + let _tx = tx.clone(); + + let stop_cancellation_token = CancellationToken::new(); + let _stop_cancellation_token = stop_cancellation_token.clone(); + + let task = tokio::spawn(async move { + let mut reader = crossterm::event::EventStream::new(); + let mut interval = tokio::time::interval(tick_rate); + loop { + let delay = interval.tick(); + let crossterm_event = reader.next().fuse(); + tokio::select! { + _ = _stop_cancellation_token.cancelled() => { + break; + } + maybe_event = crossterm_event => { + match maybe_event { + Some(Ok(evt)) => { + match evt { + CrosstermEvent::Key(key) => { + if key.kind == KeyEventKind::Press { + tx.send(Event::Key(key)).unwrap(); + } + }, + _ => {}, + } + } + Some(Err(_)) => { + tx.send(Event::Error).unwrap(); + } + None => {}, + } + }, + _ = delay => { + tx.send(Event::AppTick).unwrap(); + }, + } + } + }); + + Self { _tx, rx, task: Some(task), stop_cancellation_token } + } + + pub async fn next(&mut self) -> Option { + self.rx.recv().await + } + + pub async fn stop(&mut self) -> Result<()> { + self.stop_cancellation_token.cancel(); + if let Some(handle) = self.task.take() { + handle.await.unwrap(); + } + Ok(()) + } +} +``` + +````admonish note + +Using `crossterm::event::EventStream::new()` requires the `event-stream` feature to be enabled. + +```yml +crossterm = { version = "0.26.1", default-features = false, features = ["event-stream"] } +``` + +```` + +With this `EventHandler` implemented, we can use `tokio` to create a separate "task" that handles +any key asynchronously in our `main` loop. + +I personally like to combine the `EventHandler` and the `Tui` struct into one struct. Here's an +example of that `Tui` struct for your reference. + +```rust +{{#include ../../ratatui-counter/src/tui.rs}} +``` + +In the next section, we will introduce a `Command` pattern to bridge handling the effect of an +event. diff --git a/async/book/src/03-structure.md b/async/book/src/03-structure.md new file mode 100644 index 0000000..de5ef86 --- /dev/null +++ b/async/book/src/03-structure.md @@ -0,0 +1,234 @@ +# `action.rs` + +Now that we have created a `Tui` and `EventHandler`, we are also going to introduce the `Command` +pattern. + +```admonish tip +The `Command` pattern is the concept of "reified method calls". +You can learn a lot more about this pattern from the excellent [http://gameprogrammingpatterns.com](http://gameprogrammingpatterns.com/command.html). +``` + +These are also typically called `Action`s or `Message`s. + +```admonish note +It should come as no surprise that building a terminal user interface using `ratatui` (i.e. an immediate mode rendering library) has a lot of similarities with game development or user interface libraries. +For example, you'll find these domains all have their own version of "input handling", "event loop" and "draw" step. + +If you are coming to `ratatui` with a background in `Elm` or `React`, or if you are looking for a framework that extends the `ratatui` library to provide a more standard UI design paradigm, you can check out [`tui-realm`](https://github.com/veeso/tui-realm/) for a more featureful out of the box experience. +``` + +```rust +pub enum Action { + Quit, + Tick, + Increment, + Decrement, + Noop, +} +``` + +````admonish tip +You can attach payloads to enums in rust. +For example, in the following `Action` enum, `Increment(usize)` and `Decrement(usize)` have +a `usize` payload which can be used to represent the value to add to or subtract from +the counter as a payload. + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Quit, + Tick, + Increment(usize), + Decrement(usize), + Noop, +} +``` + +Also, note that we are using `Noop` here as a variant that means "no operation". +You can remove `Noop` from `Action` and return `Option` instead of `Action`, +using Rust's built in `None` type to represent "no operation". + +```` + +Let's define a simple `impl App` such that every `Event` from the `EventHandler` is mapped to an +`Action` from the enum. + +```rust +#[derive(Default)] +struct App { + counter: i64, + should_quit: bool, +} + +impl App { + pub fn new() -> Self { + Self::default() + } + + pub async fn run(&mut self) -> Result<()> { + let t = Tui::new(); + t.enter(); + let mut events = EventHandler::new(tick_rate); + loop { + let event = events.next().await; + let action = self.handle_events(event); + self.update(action); + t.terminal.draw(|f| self.draw(f))?; + if self.should_quit { + break + } + }; + t.exit(); + Ok(()) + } + + fn handle_events(&mut self, event: Option) -> Action { + match event { + Some(Event::Quit) => Action::Quit, + Some(Event::AppTick) => Action::Tick, + Some(Event::Key(key_event)) => { + if let Some(key) = event { + match key.code { + KeyCode::Char('q') => Action::Quit, + KeyCode::Char('j') => Action::Increment, + KeyCode::Char('k') => Action::Decrement + _ => {} + } + } + }, + Some(_) => Action::Noop, + None => Action::Noop, + } + } + + fn update(&mut self, action: Action) { + match action { + Action::Quit => self.should_quit = true, + Action::Tick => self.tick(), + Action::Increment => self.increment(), + Action::Decrement => self.decrement(), + } + + fn increment(&mut self) { + self.counter += 1; + } + + fn decrement(&mut self) { + self.counter -= 1; + } + + fn draw(&mut self, f: &mut Frame<'_>) { + f.render_widget( + Paragraph::new(format!( + "Press j or k to increment or decrement.\n\nCounter: {}", + self.counter + )) + ) + } +} +``` + +We use `handle_events(event) -> Action` to take a `Event` and map it to a `Action`. We use +`update(action)` to take an `Action` and modify the state of the app. + +One advantage of this approach is that we can modify `handle_key_events()` to use a key +configuration if we'd like, so that users can define their own map from key to action. + +Another advantage of this is that the business logic of the `App` struct can be tested without +having to create an instance of a `Tui` or `EventHandler`, e.g.: + +```rust +mod tests { + #[test] + fn test_app() { + let mut app = App::new(); + let old_counter = app.counter; + app.update(Action::Increment); + assert!(app.counter == old_counter + 1); + } +} +``` + +In the test above, we did not create an instance of the `Tui` or the `EventHandler`, and did not +call the `run` function, but we are still able to test the business logic of our application. +Updating the app state on `Action`s gets us one step closer to making our application a "state +machine", which improves understanding and testability. + +If we wanted to be purist about it, we would make our `AppState` immutable, and we would have an +`update` function like so: + +```rust +fn update(app_state::AppState, action::Action) -> new_app_state::State { + let mut state = app_state.clone(); + state.counter += 1; + // ... + state +} +``` + +In rare occasions, we may also want to choose a future action during `update`. + +```rust +fn update(app_state::AppState, action::Action) -> (new_app_state::State, Option) { + let mut state = app_state.clone(); + state.counter += 1; + // ... + (state, Action::Tick) +} +``` + +````admonish note +In [`Charm`'s `bubbletea`](https://github.com/charmbracelet/bubbletea), this function is called an `Update`. Here's an example of what that might look like: + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + // Is it a key press? + case tea.KeyMsg: + // These keys should exit the program. + case "q": + return m, tea.Quit + + case "k": + m.counter-- + + case "j": + m.counter++ + } + + // Note that we're not returning a command. + return m, nil +} +``` + +```` + +Writing code to follow this architecture in rust (in my opinion) requires more upfront design, +mostly because you have to make your `AppState` struct `Clone`-friendly. If I were in an exploratory +or prototype stage of a TUI, I wouldn't want to do that and would only be interested in refactoring +it this way once I got a handle on the design. + +My workaround for this (as you saw earlier) is to make `update` a method that takes a `&mut self`: + +```rust +impl App { + fn update(&mut self, action: Action) -> Option { + self.counter += 1 + None + } +} +``` + +You are free to reorganize the code as you see fit! + +You can also add more actions as required. For example, here's all the actions in the template: + +```rust,no_run,noplayground +{{#include ../../ratatui-counter/src/action.rs:action_enum}} +``` + +```admonish note +We are choosing to use `serde` for `Action` so that we can allow users to decide which key event maps to which `Action` using a file for configuration. +This is discussed in more detail in the [`config`](./08-structure.md) section. +``` diff --git a/async/book/src/04-structure.md b/async/book/src/04-structure.md new file mode 100644 index 0000000..fa93122 --- /dev/null +++ b/async/book/src/04-structure.md @@ -0,0 +1,434 @@ +# `app.rs` + +Finally, putting all the pieces together, we are almost ready to get the `Run` struct. Before we do, +we should discuss the process of a TUI. + +Most TUIs are single process, single threaded applications. + +```svgbob + ,-------------. + |Get Key Event| + `-----+-------' + | + | + ,-----v------. + |Update State| + `-----+------' + | + | + ,---v---. + | Draw | + `-------' +``` + +When an application is structured like this, the TUI is blocking at each step: + +1. Waiting for a Event. + - If no key or mouse event in 250ms, send `Tick`. +2. Update the state of the app based on `event` or `action`. +3. `draw` the state of the app to the terminal using `ratatui`. + +This works perfectly fine for small applications, and this is what I recommend starting out with. +For _most_ TUIs, you'll never need to graduate from this process methodology. + +Usually, `draw` and `get_events` are fast enough that it doesn't matter. But if you do need to do a +computationally demanding or I/O intensive task while updating state (e.g. reading a database, +computing math or making a web request), your app may "hang" while it is doing so. + +Let's say a user presses `j` to scroll down a list. And every time the user presses `j` you want to +check the web for additional items to add to the list. + +What should happen when a user presses and holds `j`? It is up to you to decide how you would like +your TUI application to behave in that instance. + +You may decide that the desired behavior for your app is to hang while downloading new elements for +the list, and all key presses while the app hangs are received and handled "instantly" after the +download completes. + +Or you may decide to `flush` all keyboard events so they are not buffered, and you may want to +implement something like the following: + +```rust +let mut app = App::new(); +loop { + // ... + let before_draw = Instant::now(); + t.terminal.draw(|f| self.render(f))?; + // If drawing to the terminal is slow, flush all keyboard events so they're not buffered. + if before_draw.elapsed() > Duration::from_millis(20) { + while let Ok(_) = events.try_next() {} + } + // ... +} +``` + +Alternatively, you may decide you want the app to update in the background, and a user should be +able to scroll through the existing list while the app is downloading new elements. + +In my experience, the trade-off is here is usually complexity for the developer versus ergonomics +for the user. + +Let's say we weren't worried about complexity, and were interested in performing a computationally +demanding or I/O intensive task in the background. For our example, let's say that we wanted to +trigger a increment to the counter after sleeping for `5` seconds. + +This means that we'll have to start a "task" that sleeps for 5 seconds, and then sends another +`Action` to be dispatched on. + +Now, our `update()` method takes the following shape: + +```rust + fn update(&mut self, action: Action) -> Option { + match action { + Action::Tick => self.tick(), + Action::ScheduleIncrement => self.schedule_increment(1), + Action::ScheduleDecrement => self.schedule_decrement(1), + Action::Increment(i) => self.increment(i), + Action::Decrement(i) => self.decrement(i), + _ => (), + } + None + } +``` + +And `schedule_increment()` and `schedule_decrement()` both spawn short lived `tokio` tasks: + +```rust + pub fn schedule_increment(&mut self, i: i64) { + let tx = self.action_tx.clone().unwrap(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(5)).await; + tx.send(Action::Increment(i)).unwrap(); + }); + } + + pub fn schedule_decrement(&mut self, i: i64) { + let tx = self.action_tx.clone().unwrap(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(5)).await; + tx.send(Action::Decrement(i)).unwrap(); + }); + } + + pub fn increment(&mut self, i: i64) { + self.counter += i; + } + + pub fn decrement(&mut self, i: i64) { + self.counter -= i; + } + +``` + +In order to do this, we want to set up a `action_tx` on the `App` struct: + +```rust +#[derive(Default)] +struct App { + counter: i64, + should_quit: bool, + action_tx: Option> +} +``` + +```admonish note +The only reason we are using an `Option` here for `action_tx` is that we are not initializing the action channel when creating the instance of the `App`. +``` + +This is what we want to do: + +```rust + pub async fn run(&mut self) -> Result<()> { + let (action_tx, mut action_rx) = mpsc::unbounded_channel(); + let t = Tui::new(); + t.enter(); + + tokio::spawn(async move { + let mut event = EventHandler::new(250); + loop { + let event = event.next().await; + let action = self.handle_events(event); // ERROR: self is moved to this tokio task + action_tx.send(action); + } + }) + + loop { + if let Some(action) = action_rx.recv().await { + self.update(action); + } + t.terminal.draw(|f| self.render(f))?; + if self.should_quit { + break + } + } + t.exit(); + Ok(()) + } +``` + +However, this doesn't quite work because we can't move `self`, i.e. the `App` to the +`event -> action` mapping, i.e. `self.handle_events()`, and still use it later for `self.update()`. + +One way to solve this is to pass a `Arc` instance to the `event -> action` mapping loop, +where it uses a `lock()` to get a reference to the object to call `obj.handle_events()`. We'll have +to use the same `lock()` functionality in the main loop as well to call `obj.update()`. + +```rust +pub struct App { + pub component: Arc>, + pub should_quit: bool, +} + +impl App { + pub async fn run(&mut self) -> Result<()> { + let (action_tx, mut action_rx) = mpsc::unbounded_channel(); + + let tui = Tui::new(); + tui.enter(); + + tokio::spawn(async move { + let component = self.component.clone(); + let mut event = EventHandler::new(250); + loop { + let event = event.next().await; + let action = component.lock().await.handle_events(event); + action_tx.send(action); + } + }) + + loop { + if let Some(action) = action_rx.recv().await { + match action { + Action::Render => { + let c = self.component.lock().await; + t.terminal.draw(|f| c.render(f))?; + }; + Action::Quit => self.should_quit = true, + _ => self.component.lock().await.update(action), + } + } + self.should_quit { + break; + } + } + + tui.exit(); + Ok(()) + } +} +``` + +Now our `App` is generic boilerplate that doesn't depend on any business logic. It is responsible +just to drive the application forward, i.e. call appropriate functions. + +We can go one step further and make the render loop its own `tokio` task: + +```rust +pub struct App { + pub component: Arc>, + pub should_quit: bool, +} + +impl App { + pub async fn run(&mut self) -> Result<()> { + let (render_tx, mut render_rx) = mpsc::unbounded_channel(); + + tokio::spawn(async move { + let component = self.component.clone(); + let tui = Tui::new(); + tui.enter(); + loop { + if let Some(_) = render_rx.recv() { + let c = self.component.lock().await; + tui.terminal.draw(|f| c.render(f))?; + } + } + tui.exit() + }) + + let (action_tx, mut action_rx) = mpsc::unbounded_channel(); + + tokio::spawn(async move { + let component = self.component.clone(); + let mut event = EventHandler::new(250); + loop { + let event = event.next().await; + let action = component.lock().await.handle_events(event); + action_tx.send(action); + } + }) + + loop { + if let Some(action) = action_rx.recv().await { + match action { + Action::Render => { + render_tx.send(()); + }; + Action::Quit => self.should_quit = true, + _ => self.component.lock().await.update(action), + } + } + self.should_quit { + break; + } + } + + Ok(()) + } +} +``` + +Now our final architecture would look like this: + +```svgbob + Render Thread Event Thread Main Thread + + ,------------------. + | Get Key Event | + `--------+---------' + | + ,---------v-----------. + | Map Event to Action | + `---------+-----------' + | + ,------------V--------------. ,-------------. + | Send Action on action_tx |------->| Recv Action | + `---------------------------' `-----+-------' + | +,-------------------. ,--------v--------. +| Recv on render_rx |<-------------------------------------| Dispatch Action | +`--------+----------' `--------+--------' + | | +,--------v---------. ,--------v---------. +| Render Component | | Update Component | +`------------------' `------------------' +``` + +You can change around when "thread" or "task" does what in your application if you'd like. + +It is up to you to decide is this pattern is worth it. In this template, we are going to keep things +a little simpler. We are going to use just one thread or task to handle all the `Event`s. + +```svgbob + Event Thread Main Thread + + ,------------------. + | Get Event | + `--------+---------' + | +,------------V--------------. ,------------------------------. +| Send Event on event_tx |------->| Recv Event and Map to Action | +`---------------------------' `--------------+---------------' + | + ,--------v---------. + | Update Component | + `------------------' +``` + +All business logic will be located in a `App` struct. + +```rust +#[derive(Default)] +struct App { + counter: i64, +} + +impl App { + fn handle_events(&mut self, event: Option) -> Action { + match event { + Some(Event::Quit) => Action::Quit, + Some(Event::AppTick) => Action::Tick, + Some(Event::Render) => Action::Render, + Some(Event::Key(key_event)) => { + if let Some(key) = event { + match key.code { + KeyCode::Char('j') => Action::Increment, + KeyCode::Char('k') => Action::Decrement + _ => {} + } + } + }, + Some(_) => Action::Noop, + None => Action::Noop, + } + } + + fn update(&mut self, action: Action) { + match action { + Action::Tick => self.tick(), + Action::Increment => self.increment(), + Action::Decrement => self.decrement(), + } + + fn increment(&mut self) { + self.counter += 1; + } + + fn decrement(&mut self) { + self.counter -= 1; + } + + fn render(&mut self, f: &mut Frame<'_>) { + f.render_widget( + Paragraph::new(format!( + "Press j or k to increment or decrement.\n\nCounter: {}", + self.counter + )) + ) + } +} +``` + +With that, our `App` becomes a little more simpler: + +```rust +pub struct App { + pub tick_rate: (u64, u64), + pub component: Home, + pub should_quit: bool, +} + +impl Component { + pub fn new(tick_rate: (u64, u64)) -> Result { + let component = Home::new(); + Ok(Self { tick_rate, component, should_quit: false, should_suspend: false }) + } + + pub async fn run(&mut self) -> Result<()> { + let (action_tx, mut action_rx) = mpsc::unbounded_channel(); + + let mut tui = Tui::new(); + tui.enter() + + loop { + if let Some(e) = tui.next().await { + if let Some(action) = self.component.handle_events(Some(e.clone())) { + action_tx.send(action)?; + } + } + + while let Ok(action) = action_rx.try_recv().await { + match action { + Action::Render => tui.draw(|f| self.component.render(f, f.size()))?, + Action::Quit => self.should_quit = true, + _ => self.component.update(action), + } + } + if self.should_quit { + tui.stop()?; + break; + } + } + tui.exit() + Ok(()) + } +} +``` + +Our `Component` currently does one thing and just one thing (increment and decrement a counter). But +we may want to do more complex things and combine `Component`s in interesting ways. For example, we +may want to add a text input field as well as show logs conditionally from our TUI application. + +In the next sections, we will talk about breaking out our app into various components, with the one +root component called `Home`. And we'll introduce a `Component` trait so it is easier to understand +where the TUI specific code ends and where our app's business logic begins. diff --git a/async/book/src/05-structure.md b/async/book/src/05-structure.md new file mode 100644 index 0000000..3dccbf6 --- /dev/null +++ b/async/book/src/05-structure.md @@ -0,0 +1,16 @@ +# `components/mod.rs` + +In `components/mod.rs`, we implement a `trait` called `Component`: + +```rust,no_run,noplayground +{{#include ../../ratatui-counter/src/components.rs:component}} +``` + +I personally like keeping the functions for `handle_events` (i.e. event -> action mapping), +`dispatch` (i.e. action -> state update mapping) and `render` (i.e. state -> drawing mapping) all in +one file for each component of my application. + +There's also an `init` function that can be used to setup the `Component` when it is loaded. + +The `Home` struct (i.e. the root struct that may hold other `Component`s) will implement the +`Component` trait. We'll have a look at `Home` next. diff --git a/async/book/src/06-structure.md b/async/book/src/06-structure.md new file mode 100644 index 0000000..79d33eb --- /dev/null +++ b/async/book/src/06-structure.md @@ -0,0 +1,83 @@ +# `components/home.rs` + +Here's an example of the `Home` component with additional state: + +1. `show_help` is a `bool` that tracks whether or not help should be rendered or not +1. `ticker` is a counter that increments every `AppTick`. + +This `Home` component also adds fields for `input: Input`, and stores a reference to +`action_tx: mpsc::UnboundedSender` + +```rust,no_run,noplayground +{{#include ../../ratatui-counter/src/components/home.rs}} +``` + +The `render` function takes a `Frame` and draws a paragraph to display a counter as well as a text +box input: + +![](https://user-images.githubusercontent.com/1813121/254134161-477b2182-a3ee-4be9-a180-1bcdc56c8a1d.png) + +The `Home` component has a couple of methods `increment` and `decrement` that we saw earlier, but +this time additional `Action`s are sent on the `action_tx` channel to track the start and end of the +increment. + +```rust + pub fn schedule_increment(&mut self, i: usize) { + let tx = self.action_tx.clone().unwrap(); + tokio::task::spawn(async move { + tx.send(Action::EnterProcessing).unwrap(); + tokio::time::sleep(Duration::from_secs(5)).await; + tx.send(Action::Increment(i)).unwrap(); + tx.send(Action::ExitProcessing).unwrap(); + }); + } + + pub fn schedule_decrement(&mut self, i: usize) { + let tx = self.action_tx.clone().unwrap(); + tokio::task::spawn(async move { + tx.send(Action::EnterProcessing).unwrap(); + tokio::time::sleep(Duration::from_secs(5)).await; + tx.send(Action::Decrement(i)).unwrap(); + tx.send(Action::ExitProcessing).unwrap(); + }); + } +``` + +When a `Action` is sent on the action channel, it is received in the `main` thread in the +`app.run()` loop which then calls the `dispatch` method with the appropriate action: + +```rust + fn dispatch(&mut self, action: Action) -> Option { + match action { + Action::Tick => self.tick(), + Action::ToggleShowHelp => self.show_help = !self.show_help, + Action::ScheduleIncrement=> self.schedule_increment(1), + Action::ScheduleDecrement=> self.schedule_decrement(1), + Action::Increment(i) => self.increment(i), + Action::Decrement(i) => self.decrement(i), + Action::EnterNormal => { + self.mode = Mode::Normal; + }, + Action::EnterInsert => { + self.mode = Mode::Insert; + }, + Action::EnterProcessing => { + self.mode = Mode::Processing; + }, + Action::ExitProcessing => { + // TODO: Make this go to previous mode instead + self.mode = Mode::Normal; + }, + _ => (), + } + None + } +``` + +This way, you can have `Action` affect multiple components by propagating the actions down all of +them. + +When the `Mode` is switched to `Insert`, all events are handled off the `Input` widget from the +excellent [`tui-input` crate](https://github.com/sayanarijit/tui-input). + +![](https://user-images.githubusercontent.com/1813121/254444604-de8cfcfa-eeec-417a-a8b0-92a7ccb5fcb5.gif) diff --git a/async/book/src/07-structure.md b/async/book/src/07-structure.md new file mode 100644 index 0000000..d32591d --- /dev/null +++ b/async/book/src/07-structure.md @@ -0,0 +1,242 @@ +# `config.rs` + +At the moment, our keys are hard coded into the app. + +```rust {filename="components/home.rs"} +impl Component for Home { + + fn handle_key_events(&mut self, key: KeyEvent) -> Action { + match self.mode { + Mode::Normal | Mode::Processing => { + match key.code { + KeyCode::Char('q') => Action::Quit, + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::Quit, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::Quit, + KeyCode::Char('z') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::Suspend, + KeyCode::Char('?') => Action::ToggleShowHelp, + KeyCode::Char('j') => Action::ScheduleIncrement, + KeyCode::Char('k') => Action::ScheduleDecrement, + KeyCode::Char('/') => Action::EnterInsert, + _ => Action::Tick, + } + }, + Mode::Insert => { + match key.code { + KeyCode::Esc => Action::EnterNormal, + KeyCode::Enter => Action::EnterNormal, + _ => { + self.input.handle_event(&crossterm::event::Event::Key(key)); + Action::Update + }, + } + }, + } + } +``` + +If a user wants to press `Up` and `Down` arrow key to `ScheduleIncrement` and `ScheduleDecrement`, +the only way for them to do it is having to make changes to the source code and recompile the app. +It would be better to provide a way for users to set up a configuration file that maps key presses +to actions. + +For example, assume we want a user to be able to set up a keyevents-to-actions mapping in a +`config.toml` file like below: + +```toml +[keymap] +"q" = "Quit" +"j" = "ScheduleIncrement" +"k" = "ScheduleDecrement" +"l" = "ToggleShowHelp" +"/" = "EnterInsert" +"ESC" = "EnterNormal" +"Enter" = "EnterNormal" +"Ctrl-d" = "Quit" +"Ctrl-c" = "Quit" +"Ctrl-z" = "Suspend" +``` + +We can set up a `Config` struct using +[the excellent `config` crate](https://docs.rs/config/0.13.3/config/): + +```rust +use std::collections::HashMap; + +use color_eyre::eyre::Result; +use crossterm::event::KeyEvent; +use serde_derive::Deserialize; + +use crate::action::Action; + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Config { + #[serde(default)] + pub keymap: KeyMap, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct KeyMap(pub HashMap); + +impl Config { + pub fn new() -> Result { + let mut builder = config::Config::builder(); + builder = builder + .add_source(config::File::from(config_dir.join("config.toml")).format(config::FileFormat::Toml).required(false)); + builder.build()?.try_deserialize() + } +} +``` + +We are using `serde` to deserialize from a TOML file. + +Now the default `KeyEvent` serialized format is not very user friendly, so let's implement our own +version: + +```rust +#[derive(Clone, Debug, Default)] +pub struct KeyMap(pub HashMap); + +impl<'de> Deserialize<'de> for KeyMap { + fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, + { + struct KeyMapVisitor; + impl<'de> Visitor<'de> for KeyMapVisitor { + type Value = KeyMap; + fn visit_map(self, mut access: M) -> Result + where + M: MapAccess<'de>, + { + let mut keymap = HashMap::new(); + while let Some((key_str, action)) = access.next_entry::()? { + let key_event = parse_key_event(&key_str).map_err(de::Error::custom)?; + keymap.insert(key_event, action); + } + Ok(KeyMap(keymap)) + } + } + deserializer.deserialize_map(KeyMapVisitor) + } +} +``` + +Now all we need to do is implement a `parse_key_event` function. +[You can check the source code for an example of this implementation](https://github.com/ratatui-org/async-template/blob/main/src/config.rs#L62-L138). + +With that implementation complete, we can add a `HashMap` to store a map of `KeyEvent`s and `Action` +in the `Home` component: + +```rust {filename="components/home.rs"} +#[derive(Default)] +pub struct Home { + ... + pub keymap: HashMap, +} +``` + +Now we have to create an instance of `Config` and pass the keymap to `Home`: + +```rust +impl App { + pub fn new(tick_rate: (u64, u64)) -> Result { + let h = Home::new(); + let config = Config::new()?; + let h = h.keymap(config.keymap.0.clone()); + let home = Arc::new(Mutex::new(h)); + Ok(Self { tick_rate, home, should_quit: false, should_suspend: false, config }) + } +} +``` + +```admonish tip +You can create different keyevent presses to map to different actions based on the mode of the app by adding more sections into the toml configuration file. +``` + +And in the `handle_key_events` we get the `Action` that should to be performed from the `HashMap` +directly. + +```rust +impl Component for Home { + fn handle_key_events(&mut self, key: KeyEvent) -> Action { + match self.mode { + Mode::Normal | Mode::Processing => { + if let Some(action) = self.keymap.get(&key) { + *action + } else { + Action::Tick + } + }, + Mode::Insert => { + match key.code { + KeyCode::Esc => Action::EnterNormal, + KeyCode::Enter => Action::EnterNormal, + _ => { + self.input.handle_event(&crossterm::event::Event::Key(key)); + Action::Update + }, + } + }, + } + } +} +``` + +In the template, it is set up to handle `Vec` mapped to an `Action`. This allows you to +map for example: + +- `` to `Action::GotoBottom` +- `` to `Action::GotoTop` + +```admonish note +Remember, if you add a new `Action` variant you also have to update the `deserialize` method accordingly. +``` + +And because we are now using multiple keys as input, you have to update the `app.rs` main loop +accordingly to handle that: + +```rust + // -- snip -- + loop { + if let Some(e) = tui.next().await { + match e { + // -- snip -- + tui::Event::Key(key) => { + if let Some(keymap) = self.config.keybindings.get(&self.mode) { + // If the key is a single key action + if let Some(action) = keymap.get(&vec![key.clone()]) { + log::info!("Got action: {action:?}"); + action_tx.send(action.clone())?; + } else { + // If the key was not handled as a single key action, + // then consider it for multi-key combinations. + self.last_tick_key_events.push(key); + + // Check for multi-key combinations + if let Some(action) = keymap.get(&self.last_tick_key_events) { + log::info!("Got action: {action:?}"); + action_tx.send(action.clone())?; + } + } + }; + }, + _ => {}, + } + // -- snip -- + } + while let Ok(action) = action_rx.try_recv() { + // -- snip -- + for component in self.components.iter_mut() { + if let Some(action) = component.update(action.clone())? { + action_tx.send(action)? + }; + } + } + // -- snip -- + } + // -- snip -- +``` + +Here's the JSON configuration we use for the counter application: + +```json +{{#include ../../ratatui-counter/.config/config.json5}} +``` diff --git a/async/book/src/08-structure.md b/async/book/src/08-structure.md new file mode 100644 index 0000000..9e40519 --- /dev/null +++ b/async/book/src/08-structure.md @@ -0,0 +1,92 @@ +# `utils.rs` + +### Command Line Argument Parsing (`clap`) + +In this file, we define a [`clap`](https://docs.rs/clap/latest/clap/) `Args` struct. + +```rust,no_run,noplayground +{{#include ../../ratatui-counter/src/cli.rs}} +``` + +This allows us to pass command line arguments to our terminal user interface if we need to. + +![](https://user-images.githubusercontent.com/1813121/252718163-ab1945d1-7d44-4b5b-928d-1164ac99f2c9.png) + +In addtion to command line arguments, we typically want the version of the command line program to +show up on request. In the `clap` command, we pass in an argument called `version()`. This +`version()` function (defined in `src/utils.rs`) uses a environment variable called +`RATATUI_ASYNC_TEMPLATE_GIT_INFO` to get the version number with the git commit hash. +`RATATUI_ASYNC_TEMPLATE_GIT_INFO` is populated in `./build.rs` when building with `cargo`, because +of this line: + +```rust + println!("cargo:rustc-env=RATATUI_ASYNC_TEMPLATE_GIT_INFO={}", git_describe); +``` + +![](https://user-images.githubusercontent.com/1813121/253160580-dc537c49-4191-4821-874a-9efc73cfe098.png) + +You can configure what the version string should look like by modifying the string template code in +`utils::version()`. + +### XDG Base Directory Specification + +Most command line tools have configuration files or data files that they need to store somewhere. To +be a good citizen, you might want to consider following the XDG Base Directory Specification. + +This template uses `directories-rs` and `ProjectDirs`'s config and data local directories. You can +find more information about the exact location for your operating system here: +. + +This template also prints out the location when you pass in the `--version` command line argument. + +![](https://user-images.githubusercontent.com/1813121/252721469-4d5ec38b-e868-46b4-b7b7-1c2c8bc496ac.png) + +There are situations where you or your users may want to override where the configuration and data +files should be located. This can be accomplished by using the environment variables +`RATATUI_ASYNC_TEMPLATE_CONFIG` and `RATATUI_ASYNC_TEMPLATE_DATA`. + +The functions that calculate the config and data directories are in `src/utils.rs`. Feel free to +modify the `utils::get_config_dir()` and `utils::get_data_dir()` functions as you see fit. + +### Logging + +The `utils::initialize_logging()` function is defined in `src/utils.rs`. The log level is decided by +the `RUST_LOG` environment variable (default = `log::LevelFilter::Info`). In addition, the location +of the log files are decided by the `RATATUI_ASYNC_TEMPLATE_DATA` environment variable (default = +`XDG_DATA_HOME (local)`). + +I tend to use `.envrc` and `direnv` for development purposes, and I have the following in my +`.envrc`: + +```bash +{{#include ../../ratatui-counter/.envrc}} +``` + +This puts the log files in the `RATATUI_ASYNC_TEMPLATE_DATA` folder, i.e. `.data` folder in the +current directory, and sets the log level to `RUST_LOG`, i.e. `debug` when I am prototyping and +developing using `cargo run`. + +![Top half is a Iterm2 terminal with the TUI showing a Vertical split with `tui-logger` widget. Bottom half is a ITerm2 terminal showing the output of running `tail -f` on the log file.](https://user-images.githubusercontent.com/1813121/254093932-46d8c6fd-c572-4675-bcaf-45a36eed51ff.png) + +Using the `RATATUI_ASYNC_TEMPLATE_CONFIG` environment variable also allows me to have configuration +data that I can use for testing when development that doesn't affect my local user configuration for +the same program. + +### Panic Handler + +Finally, let's discuss the `initialize_panic_handler()` function, which is also defined in +`src/utils.rs`, and is used to define a callback when the application panics. Your application may +panic for a number of reasons (e.g. when you call `.unwrap()` on a `None`). And when this happens, +you want to be a good citizen and: + +1. provide a useful stacktrace so that they can report errors back to you. +2. not leave the users terminal state in a botched condition, resetting it back to the way it was. + +In the screenshot below, I added a `None.unwrap()` into a function that is called on a keypress, so +that you can see what a prettier backtrace looks like: + +![](https://user-images.githubusercontent.com/1813121/266889163-2fb6b210-58aa-4519-b491-0d35d9fa2c87.png) + +`utils::initialize_panic_handler()` also calls `Tui::new().exit()` to reset the terminal state back +to the way it was before the user started the TUI program. We'll learn more about the `Tui` in the +next section. diff --git a/async/book/src/SUMMARY.md b/async/book/src/SUMMARY.md new file mode 100644 index 0000000..0805181 --- /dev/null +++ b/async/book/src/SUMMARY.md @@ -0,0 +1,12 @@ +# Summary + +- [Home](./index.md) +- [Tutorial](./00-structure.md) + - [`main.rs`](./01-structure.md) + - [`tui.rs`](./02-structure.md) + - [`action.rs`](./03-structure.md) + - [`app.rs`](./04-structure.md) + - [`components.rs`](./05-structure.md) + - [`components/home.rs`](./06-structure.md) + - [`config.rs`](./07-structure.md) + - [`utils.rs`](./08-structure.md) diff --git a/async/book/src/index.md b/async/book/src/index.md new file mode 100644 index 0000000..2ab72c2 --- /dev/null +++ b/async/book/src/index.md @@ -0,0 +1,55 @@ +# Home + +{{#include ../../README.md}} + +## Background + +[`ratatui`](https://github.com/ratatui-org/ratatui) is a Rust library to build rich terminal user +interfaces (TUIs) and dashboards. It is a community fork of the original +[`tui-rs`](https://github.com/fdehau/tui-rs) created to maintain and improve the project. + +The [source code of this project](https://github.com/ratatui-org/async-template) is an opinionated +template for getting up and running with `ratatui`. You can pick and choose the pieces of this +`async-template` to suit your needs and sensibilities. This rest of this documentation is a +walk-through of why the code is structured the way it is, so that you are aided in modifying it as +you require. + +`ratatui` is based on the principle of immediate rendering with intermediate buffers. This means +that at each new frame you have to build all widgets that are supposed to be part of the UI. In +short, the `ratatui` library is largely handles just drawing to the terminal. + +Additionally, the library does not provide any input handling nor any event system. The +responsibility of getting keyboard input events, modifying the state of your application based on +those events and figuring out which widgets best reflect the view of the state of your application +is on you. + +The `ratatui-org` project has added a template that covers the basics, and you find that here: +. + +I wanted to take another stab at a template, one that uses `tokio` and organizes the code a little +differently. This is an opinionated view on how to organize a `ratatui` project. + +```admonish info +Since `ratatui` is a immediate mode rendering based library, there are _multiple_ ways to organize your code, and there's no real "right" answer. +Choose whatever works best for you! +``` + +This project also adds commonly used dependencies like logging, command line arguments, +configuration options, etc. + +As part of this documentation, we'll walk through some of the different ways you may choose to +organize your code and project in order to build a functioning terminal user interface. You can pick +and choose the parts you like. + +You may also want to check out the following links (roughly in order of increasing complexity): + +- : Simple one-off examples to illustrate + various widgets and features in `ratatui`. +- : Starter kit for using `ratatui` +- : Tutorial + project that the user a simple interface to enter key-value pairs, which will printed in json. +- : Async tokio crossterm based opinionated starter + kit for using `ratatui`. +- : A framework for `tui.rs` to simplify the implementation of + terminal user interfaces adding the possibility to work with re-usable components with properties + and states. diff --git a/async/book/theme/catppuccin-highlight.css b/async/book/theme/catppuccin-highlight.css new file mode 100644 index 0000000..6658dc0 --- /dev/null +++ b/async/book/theme/catppuccin-highlight.css @@ -0,0 +1,607 @@ +.mocha code .hljs-keyword { + color: #cba6f7; +} +.mocha code .hljs-built_in { + color: #f38ba8; +} +.mocha code .hljs-type { + color: #f9e2af; +} +.mocha code .hljs-literal { + color: #fab387; +} +.mocha code .hljs-number { + color: #fab387; +} +.mocha code .hljs-operator { + color: #94e2d5; +} +.mocha code .hljs-punctuation { + color: #bac2de; +} +.mocha code .hljs-property { + color: #94e2d5; +} +.mocha code .hljs-regexp { + color: #f5c2e7; +} +.mocha code .hljs-string { + color: #a6e3a1; +} +.mocha code .hljs-char.escape_ { + color: #a6e3a1; +} +.mocha code .hljs-subst { + color: #a6adc8; +} +.mocha code .hljs-symbol { + color: #f2cdcd; +} +.mocha code .hljs-variable { + color: #cba6f7; +} +.mocha code .hljs-variable.language_ { + color: #cba6f7; +} +.mocha code .hljs-variable.constant_ { + color: #fab387; +} +.mocha code .hljs-title { + color: #89b4fa; +} +.mocha code .hljs-title.class_ { + color: #f9e2af; +} +.mocha code .hljs-title.function_ { + color: #89b4fa; +} +.mocha code .hljs-params { + color: #cdd6f4; +} +.mocha code .hljs-comment { + color: #585b70; +} +.mocha code .hljs-doctag { + color: #f38ba8; +} +.mocha code .hljs-meta { + color: #fab387; +} +.mocha code .hljs-section { + color: #89b4fa; +} +.mocha code .hljs-tag { + color: #a6adc8; +} +.mocha code .hljs-name { + color: #cba6f7; +} +.mocha code .hljs-attr { + color: #89b4fa; +} +.mocha code .hljs-attribute { + color: #a6e3a1; +} +.mocha code .hljs-bullet { + color: #94e2d5; +} +.mocha code .hljs-code { + color: #a6e3a1; +} +.mocha code .hljs-emphasis { + color: #f38ba8; + font-style: italic; +} +.mocha code .hljs-strong { + color: #f38ba8; + font-weight: bold; +} +.mocha code .hljs-formula { + color: #94e2d5; +} +.mocha code .hljs-link { + color: #74c7ec; + font-style: italic; +} +.mocha code .hljs-quote { + color: #a6e3a1; + font-style: italic; +} +.mocha code .hljs-selector-tag { + color: #f9e2af; +} +.mocha code .hljs-selector-id { + color: #89b4fa; +} +.mocha code .hljs-selector-class { + color: #94e2d5; +} +.mocha code .hljs-selector-attr { + color: #cba6f7; +} +.mocha code .hljs-selector-pseudo { + color: #94e2d5; +} +.mocha code .hljs-template-tag { + color: #f2cdcd; +} +.mocha code .hljs-template-variable { + color: #f2cdcd; +} +.mocha code .hljs-diff-addition { + color: #a6e3a1; + background: rgba(166, 227, 161, 0.15); +} +.mocha code .hljs-diff-deletion { + color: #f38ba8; + background: rgba(243, 139, 168, 0.15); +} + +.macchiato code .hljs-keyword { + color: #c6a0f6; +} +.macchiato code .hljs-built_in { + color: #ed8796; +} +.macchiato code .hljs-type { + color: #eed49f; +} +.macchiato code .hljs-literal { + color: #f5a97f; +} +.macchiato code .hljs-number { + color: #f5a97f; +} +.macchiato code .hljs-operator { + color: #8bd5ca; +} +.macchiato code .hljs-punctuation { + color: #b8c0e0; +} +.macchiato code .hljs-property { + color: #8bd5ca; +} +.macchiato code .hljs-regexp { + color: #f5bde6; +} +.macchiato code .hljs-string { + color: #a6da95; +} +.macchiato code .hljs-char.escape_ { + color: #a6da95; +} +.macchiato code .hljs-subst { + color: #a5adcb; +} +.macchiato code .hljs-symbol { + color: #f0c6c6; +} +.macchiato code .hljs-variable { + color: #c6a0f6; +} +.macchiato code .hljs-variable.language_ { + color: #c6a0f6; +} +.macchiato code .hljs-variable.constant_ { + color: #f5a97f; +} +.macchiato code .hljs-title { + color: #8aadf4; +} +.macchiato code .hljs-title.class_ { + color: #eed49f; +} +.macchiato code .hljs-title.function_ { + color: #8aadf4; +} +.macchiato code .hljs-params { + color: #cad3f5; +} +.macchiato code .hljs-comment { + color: #5b6078; +} +.macchiato code .hljs-doctag { + color: #ed8796; +} +.macchiato code .hljs-meta { + color: #f5a97f; +} +.macchiato code .hljs-section { + color: #8aadf4; +} +.macchiato code .hljs-tag { + color: #a5adcb; +} +.macchiato code .hljs-name { + color: #c6a0f6; +} +.macchiato code .hljs-attr { + color: #8aadf4; +} +.macchiato code .hljs-attribute { + color: #a6da95; +} +.macchiato code .hljs-bullet { + color: #8bd5ca; +} +.macchiato code .hljs-code { + color: #a6da95; +} +.macchiato code .hljs-emphasis { + color: #ed8796; + font-style: italic; +} +.macchiato code .hljs-strong { + color: #ed8796; + font-weight: bold; +} +.macchiato code .hljs-formula { + color: #8bd5ca; +} +.macchiato code .hljs-link { + color: #7dc4e4; + font-style: italic; +} +.macchiato code .hljs-quote { + color: #a6da95; + font-style: italic; +} +.macchiato code .hljs-selector-tag { + color: #eed49f; +} +.macchiato code .hljs-selector-id { + color: #8aadf4; +} +.macchiato code .hljs-selector-class { + color: #8bd5ca; +} +.macchiato code .hljs-selector-attr { + color: #c6a0f6; +} +.macchiato code .hljs-selector-pseudo { + color: #8bd5ca; +} +.macchiato code .hljs-template-tag { + color: #f0c6c6; +} +.macchiato code .hljs-template-variable { + color: #f0c6c6; +} +.macchiato code .hljs-diff-addition { + color: #a6da95; + background: rgba(166, 218, 149, 0.15); +} +.macchiato code .hljs-diff-deletion { + color: #ed8796; + background: rgba(237, 135, 150, 0.15); +} + +.frappe code .hljs-keyword { + color: #ca9ee6; +} +.frappe code .hljs-built_in { + color: #e78284; +} +.frappe code .hljs-type { + color: #e5c890; +} +.frappe code .hljs-literal { + color: #ef9f76; +} +.frappe code .hljs-number { + color: #ef9f76; +} +.frappe code .hljs-operator { + color: #81c8be; +} +.frappe code .hljs-punctuation { + color: #b5bfe2; +} +.frappe code .hljs-property { + color: #81c8be; +} +.frappe code .hljs-regexp { + color: #f4b8e4; +} +.frappe code .hljs-string { + color: #a6d189; +} +.frappe code .hljs-char.escape_ { + color: #a6d189; +} +.frappe code .hljs-subst { + color: #a5adce; +} +.frappe code .hljs-symbol { + color: #eebebe; +} +.frappe code .hljs-variable { + color: #ca9ee6; +} +.frappe code .hljs-variable.language_ { + color: #ca9ee6; +} +.frappe code .hljs-variable.constant_ { + color: #ef9f76; +} +.frappe code .hljs-title { + color: #8caaee; +} +.frappe code .hljs-title.class_ { + color: #e5c890; +} +.frappe code .hljs-title.function_ { + color: #8caaee; +} +.frappe code .hljs-params { + color: #c6d0f5; +} +.frappe code .hljs-comment { + color: #626880; +} +.frappe code .hljs-doctag { + color: #e78284; +} +.frappe code .hljs-meta { + color: #ef9f76; +} +.frappe code .hljs-section { + color: #8caaee; +} +.frappe code .hljs-tag { + color: #a5adce; +} +.frappe code .hljs-name { + color: #ca9ee6; +} +.frappe code .hljs-attr { + color: #8caaee; +} +.frappe code .hljs-attribute { + color: #a6d189; +} +.frappe code .hljs-bullet { + color: #81c8be; +} +.frappe code .hljs-code { + color: #a6d189; +} +.frappe code .hljs-emphasis { + color: #e78284; + font-style: italic; +} +.frappe code .hljs-strong { + color: #e78284; + font-weight: bold; +} +.frappe code .hljs-formula { + color: #81c8be; +} +.frappe code .hljs-link { + color: #85c1dc; + font-style: italic; +} +.frappe code .hljs-quote { + color: #a6d189; + font-style: italic; +} +.frappe code .hljs-selector-tag { + color: #e5c890; +} +.frappe code .hljs-selector-id { + color: #8caaee; +} +.frappe code .hljs-selector-class { + color: #81c8be; +} +.frappe code .hljs-selector-attr { + color: #ca9ee6; +} +.frappe code .hljs-selector-pseudo { + color: #81c8be; +} +.frappe code .hljs-template-tag { + color: #eebebe; +} +.frappe code .hljs-template-variable { + color: #eebebe; +} +.frappe code .hljs-diff-addition { + color: #a6d189; + background: rgba(166, 209, 137, 0.15); +} +.frappe code .hljs-diff-deletion { + color: #e78284; + background: rgba(231, 130, 132, 0.15); +} + +.latte code .hljs-keyword { + color: #8839ef; +} +.latte code .hljs-built_in { + color: #d20f39; +} +.latte code .hljs-type { + color: #df8e1d; +} +.latte code .hljs-literal { + color: #fe640b; +} +.latte code .hljs-number { + color: #fe640b; +} +.latte code .hljs-operator { + color: #179299; +} +.latte code .hljs-punctuation { + color: #5c5f77; +} +.latte code .hljs-property { + color: #179299; +} +.latte code .hljs-regexp { + color: #ea76cb; +} +.latte code .hljs-string { + color: #40a02b; +} +.latte code .hljs-char.escape_ { + color: #40a02b; +} +.latte code .hljs-subst { + color: #6c6f85; +} +.latte code .hljs-symbol { + color: #dd7878; +} +.latte code .hljs-variable { + color: #8839ef; +} +.latte code .hljs-variable.language_ { + color: #8839ef; +} +.latte code .hljs-variable.constant_ { + color: #fe640b; +} +.latte code .hljs-title { + color: #1e66f5; +} +.latte code .hljs-title.class_ { + color: #df8e1d; +} +.latte code .hljs-title.function_ { + color: #1e66f5; +} +.latte code .hljs-params { + color: #4c4f69; +} +.latte code .hljs-comment { + color: #acb0be; +} +.latte code .hljs-doctag { + color: #d20f39; +} +.latte code .hljs-meta { + color: #fe640b; +} +.latte code .hljs-section { + color: #1e66f5; +} +.latte code .hljs-tag { + color: #6c6f85; +} +.latte code .hljs-name { + color: #8839ef; +} +.latte code .hljs-attr { + color: #1e66f5; +} +.latte code .hljs-attribute { + color: #40a02b; +} +.latte code .hljs-bullet { + color: #179299; +} +.latte code .hljs-code { + color: #40a02b; +} +.latte code .hljs-emphasis { + color: #d20f39; + font-style: italic; +} +.latte code .hljs-strong { + color: #d20f39; + font-weight: bold; +} +.latte code .hljs-formula { + color: #179299; +} +.latte code .hljs-link { + color: #209fb5; + font-style: italic; +} +.latte code .hljs-quote { + color: #40a02b; + font-style: italic; +} +.latte code .hljs-selector-tag { + color: #df8e1d; +} +.latte code .hljs-selector-id { + color: #1e66f5; +} +.latte code .hljs-selector-class { + color: #179299; +} +.latte code .hljs-selector-attr { + color: #8839ef; +} +.latte code .hljs-selector-pseudo { + color: #179299; +} +.latte code .hljs-template-tag { + color: #dd7878; +} +.latte code .hljs-template-variable { + color: #dd7878; +} +.latte code .hljs-diff-addition { + color: #40a02b; + background: rgba(64, 160, 43, 0.15); +} +.latte code .hljs-diff-deletion { + color: #d20f39; + background: rgba(210, 15, 57, 0.15); +} + +.mocha code { + color: #a6adc8; + background: #181825; +} +.mocha .ace_gutter { + color: #7f849c; + background: #181825; +} +.mocha .ace_gutter-active-line.ace_gutter-cell { + color: #f5c2e7; + background: #181825; +} + +.macchiato code { + color: #a5adcb; + background: #1e2030; +} +.macchiato .ace_gutter { + color: #8087a2; + background: #1e2030; +} +.macchiato .ace_gutter-active-line.ace_gutter-cell { + color: #f5bde6; + background: #1e2030; +} + +.frappe code { + color: #a5adce; + background: #292c3c; +} +.frappe .ace_gutter { + color: #838ba7; + background: #292c3c; +} +.frappe .ace_gutter-active-line.ace_gutter-cell { + color: #f4b8e4; + background: #292c3c; +} + +.latte code { + color: #6c6f85; + background: #e6e9ef; +} +.latte .ace_gutter { + color: #8c8fa1; + background: #e6e9ef; +} +.latte .ace_gutter-active-line.ace_gutter-cell { + color: #ea76cb; + background: #e6e9ef; +} diff --git a/async/book/theme/catppuccin.css b/async/book/theme/catppuccin.css new file mode 100644 index 0000000..ddf953c --- /dev/null +++ b/async/book/theme/catppuccin.css @@ -0,0 +1,123 @@ +.mocha { + --bg: #1e1e2e; + --fg: #cdd6f4; + --sidebar-bg: #181825; + --sidebar-fg: #cdd6f4; + --sidebar-non-existant: #6c7086; + --sidebar-active: #89b4fa; + --sidebar-spacer: #6c7086; + --scrollbar: #6c7086; + --icons: #6c7086; + --icons-hover: #7f849c; + --links: #89b4fa; + --inline-code-color: #fab387; + --theme-popup-bg: #181825; + --theme-popup-border: #6c7086; + --theme-hover: #6c7086; + --quote-bg: #181825; + --quote-border: #11111b; + --table-border-color: #11111b; + --table-header-bg: #181825; + --table-alternate-bg: #11111b; + --searchbar-border-color: #11111b; + --searchbar-bg: #181825; + --searchbar-fg: #cdd6f4; + --searchbar-shadow-color: #11111b; + --searchresults-header-fg: #cdd6f4; + --searchresults-border-color: #11111b; + --searchresults-li-bg: #1e1e2e; + --search-mark-bg: #fab387; +} + +.macchiato { + --bg: #24273a; + --fg: #cad3f5; + --sidebar-bg: #1e2030; + --sidebar-fg: #cad3f5; + --sidebar-non-existant: #6e738d; + --sidebar-active: #8aadf4; + --sidebar-spacer: #6e738d; + --scrollbar: #6e738d; + --icons: #6e738d; + --icons-hover: #8087a2; + --links: #8aadf4; + --inline-code-color: #f5a97f; + --theme-popup-bg: #1e2030; + --theme-popup-border: #6e738d; + --theme-hover: #6e738d; + --quote-bg: #1e2030; + --quote-border: #181926; + --table-border-color: #181926; + --table-header-bg: #1e2030; + --table-alternate-bg: #181926; + --searchbar-border-color: #181926; + --searchbar-bg: #1e2030; + --searchbar-fg: #cad3f5; + --searchbar-shadow-color: #181926; + --searchresults-header-fg: #cad3f5; + --searchresults-border-color: #181926; + --searchresults-li-bg: #24273a; + --search-mark-bg: #f5a97f; +} + +.frappe { + --bg: #303446; + --fg: #c6d0f5; + --sidebar-bg: #292c3c; + --sidebar-fg: #c6d0f5; + --sidebar-non-existant: #737994; + --sidebar-active: #8caaee; + --sidebar-spacer: #737994; + --scrollbar: #737994; + --icons: #737994; + --icons-hover: #838ba7; + --links: #8caaee; + --inline-code-color: #ef9f76; + --theme-popup-bg: #292c3c; + --theme-popup-border: #737994; + --theme-hover: #737994; + --quote-bg: #292c3c; + --quote-border: #232634; + --table-border-color: #232634; + --table-header-bg: #292c3c; + --table-alternate-bg: #232634; + --searchbar-border-color: #232634; + --searchbar-bg: #292c3c; + --searchbar-fg: #c6d0f5; + --searchbar-shadow-color: #232634; + --searchresults-header-fg: #c6d0f5; + --searchresults-border-color: #232634; + --searchresults-li-bg: #303446; + --search-mark-bg: #ef9f76; +} + +.latte { + --bg: #eff1f5; + --fg: #4c4f69; + --sidebar-bg: #e6e9ef; + --sidebar-fg: #4c4f69; + --sidebar-non-existant: #9ca0b0; + --sidebar-active: #1e66f5; + --sidebar-spacer: #9ca0b0; + --scrollbar: #9ca0b0; + --icons: #9ca0b0; + --icons-hover: #8c8fa1; + --links: #1e66f5; + --inline-code-color: #fe640b; + --theme-popup-bg: #e6e9ef; + --theme-popup-border: #9ca0b0; + --theme-hover: #9ca0b0; + --quote-bg: #e6e9ef; + --quote-border: #dce0e8; + --table-border-color: #dce0e8; + --table-header-bg: #e6e9ef; + --table-alternate-bg: #dce0e8; + --searchbar-border-color: #dce0e8; + --searchbar-bg: #e6e9ef; + --searchbar-fg: #4c4f69; + --searchbar-shadow-color: #dce0e8; + --searchresults-header-fg: #4c4f69; + --searchresults-border-color: #dce0e8; + --searchresults-li-bg: #eff1f5; + --search-mark-bg: #fe640b; +} diff --git a/async/book/theme/index.hbs b/async/book/theme/index.hbs new file mode 100644 index 0000000..69ad430 --- /dev/null +++ b/async/book/theme/index.hbs @@ -0,0 +1,318 @@ + + + + + + {{ title }} + {{#if is_print }} + + {{/if}} + {{#if base_url}} + + {{/if}} + + + + {{> head}} + + + + + + + {{#if favicon_svg}} + + {{/if}} + {{#if favicon_png}} + + {{/if}} + + + + {{#if print_enable}} + + {{/if}} + + + + {{#if copy_fonts}} + + {{/if}} + + + + + + + + {{#each additional_css}} + + {{/each}} + + {{#if mathjax_support}} + + + {{/if}} + + + + + + + + + + + + + + + + +
+ +
+ {{> header}} + + + + {{#if search_enabled}} + + {{/if}} + + + + +
+
+ {{{ content }}} +
+ + +
+
+ + + +
+ + {{#if live_reload_endpoint}} + + + {{/if}} + + {{#if google_analytics}} + + + {{/if}} + + {{#if playground_line_numbers}} + + {{/if}} + + {{#if playground_copyable}} + + {{/if}} + + {{#if playground_js}} + + + + + + {{/if}} + + {{#if search_js}} + + + + {{/if}} + + + + + + + {{#each additional_js}} + + {{/each}} + + {{#if is_print}} + {{#if mathjax_support}} + + {{else}} + + {{/if}} + {{/if}} + + + diff --git a/async/ratatui-counter/.config/config.json5 b/async/ratatui-counter/.config/config.json5 new file mode 100644 index 0000000..f830c31 --- /dev/null +++ b/async/ratatui-counter/.config/config.json5 @@ -0,0 +1,14 @@ +{ + "keybindings": { + "Home": { + "": "Quit", // Quit the application + "": "ScheduleIncrement", + "": "ScheduleDecrement", + "": "ToggleShowHelp", + "": "EnterInsert", + "": "Quit", // Another way to quit + "": "Quit", // Yet another way to quit + "": "Suspend" // Suspend the application + }, + } +} diff --git a/async/ratatui-counter/.envrc b/async/ratatui-counter/.envrc new file mode 100644 index 0000000..09409f8 --- /dev/null +++ b/async/ratatui-counter/.envrc @@ -0,0 +1,3 @@ +export RATATUI_COUNTER_CONFIG=`pwd`/.config +export RATATUI_COUNTER_DATA=`pwd`/.data +export RATATUI_COUNTER_LOG_LEVEL=debug diff --git a/async/ratatui-counter/.gitignore b/async/ratatui-counter/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/async/ratatui-counter/.gitignore @@ -0,0 +1 @@ +/target diff --git a/async/ratatui-counter/.rustfmt.toml b/async/ratatui-counter/.rustfmt.toml new file mode 100644 index 0000000..1cf4c15 --- /dev/null +++ b/async/ratatui-counter/.rustfmt.toml @@ -0,0 +1,16 @@ +max_width = 120 +use_small_heuristics = "Max" +empty_item_single_line = false +force_multiline_blocks = true +format_code_in_doc_comments = true +match_block_trailing_comma = true +imports_granularity = "Crate" +normalize_comments = true +normalize_doc_attributes = true +overflow_delimited_expr = true +reorder_impl_items = true +reorder_imports = true +group_imports = "StdExternalCrate" +tab_spaces = 2 +use_field_init_shorthand = true +use_try_shorthand = true diff --git a/async/ratatui-counter/Cargo.toml b/async/ratatui-counter/Cargo.toml new file mode 100644 index 0000000..dc43f9a --- /dev/null +++ b/async/ratatui-counter/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "ratatui-counter" +version = "0.1.0" +edition = "2021" +description = "Counter application with async-template" +repository = "https://github.com/ratatui-org/ratatui-counter" +authors = ["Dheepak Krishnamurthy"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +better-panic = "0.3.0" +clap = { version = "4.4.5", features = ["std", "color", "help", "usage", "error-context", "suggestions", "derive", "cargo", "wrap_help", "unicode", "string", "unstable-styles"] } +color-eyre = "0.6.2" +config = "0.13.3" +crossterm = { version = "0.27.0", features = ["serde", "event-stream"] } +derive_deref = "1.1.1" +directories = "5.0.1" +futures = "0.3.28" +human-panic = "1.2.0" +json5 = "0.4.1" +lazy_static = "1.4.0" +libc = "0.2.148" +log = "0.4.20" +pretty_assertions = "1.4.0" +ratatui = { version = "0.23.0", features = ["serde", "macros"] } +serde = { version = "1.0.188", features = ["derive"] } +serde_json = "1.0.107" +signal-hook = "0.3.17" +strip-ansi-escapes = "0.2.0" +strum = { version = "0.25.0", features = ["derive"] } +tokio = { version = "1.32.0", features = ["full"] } +tokio-util = "0.7.9" +tracing = "0.1.37" +tracing-error = "0.2.0" +tracing-subscriber = { version = "0.3.17", features = ["env-filter", "serde"] } +tui-input = { version = "0.8.0", features = ["serde"] } diff --git a/async/ratatui-counter/LICENSE b/async/ratatui-counter/LICENSE new file mode 100644 index 0000000..e6d680a --- /dev/null +++ b/async/ratatui-counter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Dheepak Krishnamurthy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/async/ratatui-counter/README.md b/async/ratatui-counter/README.md new file mode 100644 index 0000000..eb92a31 --- /dev/null +++ b/async/ratatui-counter/README.md @@ -0,0 +1,15 @@ +# ratatui-counter + +Counter application with async-template + +## Run demo + +```rust +export RATATUI_COUNTER_CONFIG=`pwd`/.config +export RATATUI_COUNTER_DATA=`pwd`/.data +export RATATUI_COUNTER_LOG_LEVEL=debug + +cargo run +``` + +![](https://user-images.githubusercontent.com/1813121/271287288-057a0fe9-9f6d-4f8c-963c-ca2725721bdd.gif) diff --git a/async/ratatui-counter/build.rs b/async/ratatui-counter/build.rs new file mode 100644 index 0000000..bc84301 --- /dev/null +++ b/async/ratatui-counter/build.rs @@ -0,0 +1,46 @@ +fn main() { + let git_output = std::process::Command::new("git").args(["rev-parse", "--git-dir"]).output().ok(); + let git_dir = git_output.as_ref().and_then(|output| { + std::str::from_utf8(&output.stdout).ok().and_then(|s| s.strip_suffix('\n').or_else(|| s.strip_suffix("\r\n"))) + }); + + // Tell cargo to rebuild if the head or any relevant refs change. + if let Some(git_dir) = git_dir { + let git_path = std::path::Path::new(git_dir); + let refs_path = git_path.join("refs"); + if git_path.join("HEAD").exists() { + println!("cargo:rerun-if-changed={}/HEAD", git_dir); + } + if git_path.join("packed-refs").exists() { + println!("cargo:rerun-if-changed={}/packed-refs", git_dir); + } + if refs_path.join("heads").exists() { + println!("cargo:rerun-if-changed={}/refs/heads", git_dir); + } + if refs_path.join("tags").exists() { + println!("cargo:rerun-if-changed={}/refs/tags", git_dir); + } + } + + let git_output = + std::process::Command::new("git").args(["describe", "--always", "--tags", "--long", "--dirty"]).output().ok(); + let git_info = git_output.as_ref().and_then(|output| std::str::from_utf8(&output.stdout).ok().map(str::trim)); + let cargo_pkg_version = env!("CARGO_PKG_VERSION"); + + // Default git_describe to cargo_pkg_version + let mut git_describe = String::from(cargo_pkg_version); + + if let Some(git_info) = git_info { + // If the `git_info` contains `CARGO_PKG_VERSION`, we simply use `git_info` as it is. + // Otherwise, prepend `CARGO_PKG_VERSION` to `git_info`. + if git_info.contains(cargo_pkg_version) { + // Remove the 'g' before the commit sha + let git_info = &git_info.replace('g', ""); + git_describe = git_info.to_string(); + } else { + git_describe = format!("v{}-{}", cargo_pkg_version, git_info); + } + } + + println!("cargo:rustc-env=RATATUI_COUNTER_GIT_INFO={}", git_describe); +} diff --git a/async/ratatui-counter/rust-toolchain.toml b/async/ratatui-counter/rust-toolchain.toml new file mode 100644 index 0000000..292fe49 --- /dev/null +++ b/async/ratatui-counter/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/async/ratatui-counter/src/action.rs b/async/ratatui-counter/src/action.rs new file mode 100644 index 0000000..8db5e1d --- /dev/null +++ b/async/ratatui-counter/src/action.rs @@ -0,0 +1,33 @@ +use std::{fmt, string::ToString}; + +use serde::{ + de::{self, Deserializer, Visitor}, + Deserialize, Serialize, +}; +use strum::Display; + +//// ANCHOR: action_enum +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Display, Deserialize)] +pub enum Action { + Tick, + Render, + Resize(u16, u16), + Suspend, + Resume, + Quit, + Refresh, + Error(String), + Help, + ToggleShowHelp, + ScheduleIncrement, + ScheduleDecrement, + Increment(usize), + Decrement(usize), + CompleteInput(String), + EnterNormal, + EnterInsert, + EnterProcessing, + ExitProcessing, + Update, +} +//// ANCHOR_END: action_enum diff --git a/async/ratatui-counter/src/app.rs b/async/ratatui-counter/src/app.rs new file mode 100644 index 0000000..3e5ab68 --- /dev/null +++ b/async/ratatui-counter/src/app.rs @@ -0,0 +1,158 @@ +use color_eyre::eyre::Result; +use crossterm::event::KeyEvent; +use ratatui::prelude::Rect; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +use crate::{ + action::Action, + components::{fps::FpsCounter, home::Home, Component}, + config::Config, + tui, +}; + +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Mode { + #[default] + Home, +} + +pub struct App { + pub config: Config, + pub tick_rate: f64, + pub frame_rate: f64, + pub components: Vec>, + pub should_quit: bool, + pub should_suspend: bool, + pub mode: Mode, + pub last_tick_key_events: Vec, +} + +impl App { + pub fn new(tick_rate: f64, frame_rate: f64) -> Result { + let home = Home::new(); + let fps = FpsCounter::new(); + let config = Config::new()?; + let mode = Mode::Home; + Ok(Self { + tick_rate, + frame_rate, + components: vec![Box::new(home), Box::new(fps)], + should_quit: false, + should_suspend: false, + config, + mode, + last_tick_key_events: Vec::new(), + }) + } + + pub async fn run(&mut self) -> Result<()> { + let (action_tx, mut action_rx) = mpsc::unbounded_channel(); + + let mut tui = tui::Tui::new()?; + tui.tick_rate(self.tick_rate); + tui.frame_rate(self.frame_rate); + tui.enter()?; + + for component in self.components.iter_mut() { + component.register_action_handler(action_tx.clone())?; + } + + for component in self.components.iter_mut() { + component.register_config_handler(self.config.clone())?; + } + + for component in self.components.iter_mut() { + component.init()?; + } + + loop { + if let Some(e) = tui.next().await { + match e { + tui::Event::Quit => action_tx.send(Action::Quit)?, + tui::Event::Tick => action_tx.send(Action::Tick)?, + tui::Event::Render => action_tx.send(Action::Render)?, + tui::Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, + tui::Event::Key(key) => { + if let Some(keymap) = self.config.keybindings.get(&self.mode) { + if let Some(action) = keymap.get(&vec![key.clone()]) { + log::info!("Got action: {action:?}"); + action_tx.send(action.clone())?; + } else { + // If the key was not handled as a single key action, + // then consider it for multi-key combinations. + self.last_tick_key_events.push(key); + + // Check for multi-key combinations + if let Some(action) = keymap.get(&self.last_tick_key_events) { + log::info!("Got action: {action:?}"); + action_tx.send(action.clone())?; + } + } + }; + }, + _ => {}, + } + for component in self.components.iter_mut() { + if let Some(action) = component.handle_events(Some(e.clone()))? { + action_tx.send(action)?; + } + } + } + + while let Ok(action) = action_rx.try_recv() { + if action != Action::Tick && action != Action::Render { + log::debug!("{action:?}"); + } + match action { + Action::Tick => { + self.last_tick_key_events.drain(..); + }, + Action::Quit => self.should_quit = true, + Action::Suspend => self.should_suspend = true, + Action::Resume => self.should_suspend = false, + Action::Resize(w, h) => { + tui.resize(Rect::new(0, 0, w, h))?; + tui.draw(|f| { + for component in self.components.iter_mut() { + let r = component.draw(f, f.size()); + if let Err(e) = r { + action_tx.send(Action::Error(format!("Failed to draw: {:?}", e))).unwrap(); + } + } + })?; + }, + Action::Render => { + tui.draw(|f| { + for component in self.components.iter_mut() { + let r = component.draw(f, f.size()); + if let Err(e) = r { + action_tx.send(Action::Error(format!("Failed to draw: {:?}", e))).unwrap(); + } + } + })?; + }, + _ => {}, + } + for component in self.components.iter_mut() { + if let Some(action) = component.update(action.clone())? { + action_tx.send(action)? + }; + } + } + if self.should_suspend { + tui.suspend()?; + action_tx.send(Action::Resume)?; + tui = tui::Tui::new()?; + tui.tick_rate(self.tick_rate); + tui.frame_rate(self.frame_rate); + tui.enter()?; + } else if self.should_quit { + tui.stop()?; + break; + } + } + tui.exit()?; + Ok(()) + } +} diff --git a/async/ratatui-counter/src/cli.rs b/async/ratatui-counter/src/cli.rs new file mode 100644 index 0000000..f43f955 --- /dev/null +++ b/async/ratatui-counter/src/cli.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; + +use clap::Parser; + +use crate::utils::version; + +#[derive(Parser, Debug)] +#[command(author, version = version(), about)] +pub struct Cli { + #[arg(short, long, value_name = "FLOAT", help = "Tick rate, i.e. number of ticks per second", default_value_t = 1.0)] + pub tick_rate: f64, + + #[arg( + short, + long, + value_name = "FLOAT", + help = "Frame rate, i.e. number of frames per second", + default_value_t = 60.0 + )] + pub frame_rate: f64, +} diff --git a/async/ratatui-counter/src/components.rs b/async/ratatui-counter/src/components.rs new file mode 100644 index 0000000..93b2966 --- /dev/null +++ b/async/ratatui-counter/src/components.rs @@ -0,0 +1,50 @@ +use color_eyre::eyre::Result; +use crossterm::event::{KeyEvent, MouseEvent}; +use ratatui::layout::Rect; +use tokio::sync::mpsc::UnboundedSender; + +use crate::{ + action::Action, + config::Config, + tui::{Event, Frame}, +}; + +pub mod home; +pub mod fps; + +//// ANCHOR: component +pub trait Component { + #[allow(unused_variables)] + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + Ok(()) + } + #[allow(unused_variables)] + fn register_config_handler(&mut self, config: Config) -> Result<()> { + Ok(()) + } + fn init(&mut self) -> Result<()> { + Ok(()) + } + fn handle_events(&mut self, event: Option) -> Result> { + let r = match event { + Some(Event::Key(key_event)) => self.handle_key_events(key_event)?, + Some(Event::Mouse(mouse_event)) => self.handle_mouse_events(mouse_event)?, + _ => None, + }; + Ok(r) + } + #[allow(unused_variables)] + fn handle_key_events(&mut self, key: KeyEvent) -> Result> { + Ok(None) + } + #[allow(unused_variables)] + fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result> { + Ok(None) + } + #[allow(unused_variables)] + fn update(&mut self, action: Action) -> Result> { + Ok(None) + } + fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()>; +} +//// ANCHOR_END: component diff --git a/async/ratatui-counter/src/components/fps.rs b/async/ratatui-counter/src/components/fps.rs new file mode 100644 index 0000000..2568fe2 --- /dev/null +++ b/async/ratatui-counter/src/components/fps.rs @@ -0,0 +1,96 @@ +use std::time::Instant; + +use color_eyre::eyre::Result; +use ratatui::{prelude::*, widgets::*}; + +use super::Component; +use crate::{action::Action, tui::Frame}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Ticker { + AppTick, + RenderTick, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct FpsCounter { + app_start_time: Instant, + app_frames: u32, + app_fps: f64, + + render_start_time: Instant, + render_frames: u32, + render_fps: f64, +} + +impl Default for FpsCounter { + fn default() -> Self { + Self::new() + } +} + +impl FpsCounter { + pub fn new() -> Self { + Self { + app_start_time: Instant::now(), + app_frames: 0, + app_fps: 0.0, + render_start_time: Instant::now(), + render_frames: 0, + render_fps: 0.0, + } + } + + fn app_tick(&mut self) -> Result<()> { + self.app_frames += 1; + let now = Instant::now(); + let elapsed = (now - self.app_start_time).as_secs_f64(); + if elapsed >= 1.0 { + self.app_fps = self.app_frames as f64 / elapsed; + self.app_start_time = now; + self.app_frames = 0; + } + Ok(()) + } + + fn render_tick(&mut self) -> Result<()> { + self.render_frames += 1; + let now = Instant::now(); + let elapsed = (now - self.render_start_time).as_secs_f64(); + if elapsed >= 1.0 { + self.render_fps = self.render_frames as f64 / elapsed; + self.render_start_time = now; + self.render_frames = 0; + } + Ok(()) + } +} + +impl Component for FpsCounter { + fn update(&mut self, action: Action) -> Result> { + if let Action::Tick = action { + self.app_tick()? + }; + if let Action::Render = action { + self.render_tick()? + }; + Ok(None) + } + + fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> { + let rects = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![ + Constraint::Length(1), // first row + Constraint::Min(0), + ]) + .split(rect); + + let rect = rects[0]; + + let s = format!("{:.2} fps (app) {:.2} fps (render)", self.app_fps, self.render_fps); + let block = Block::default().title(block::Title::from(s.dim()).alignment(Alignment::Right)); + f.render_widget(block, rect); + Ok(()) + } +} diff --git a/async/ratatui-counter/src/components/home.rs b/async/ratatui-counter/src/components/home.rs new file mode 100644 index 0000000..fbd6447 --- /dev/null +++ b/async/ratatui-counter/src/components/home.rs @@ -0,0 +1,253 @@ +use std::{collections::HashMap, time::Duration}; + +use color_eyre::eyre::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use log::error; +use ratatui::{prelude::*, widgets::*}; +use tokio::sync::mpsc::UnboundedSender; +use tracing::trace; +use tui_input::{backend::crossterm::EventHandler, Input}; + +use super::{Component, Frame}; +use crate::{action::Action, config::key_event_to_string}; + +#[derive(Default, Copy, Clone, PartialEq, Eq)] +pub enum Mode { + #[default] + Normal, + Insert, + Processing, +} + +#[derive(Default)] +pub struct Home { + pub show_help: bool, + pub counter: usize, + pub app_ticker: usize, + pub render_ticker: usize, + pub mode: Mode, + pub input: Input, + pub action_tx: Option>, + pub keymap: HashMap, + pub text: Vec, + pub last_events: Vec, +} + +impl Home { + pub fn new() -> Self { + Self::default() + } + + pub fn keymap(mut self, keymap: HashMap) -> Self { + self.keymap = keymap; + self + } + + pub fn tick(&mut self) { + log::info!("Tick"); + self.app_ticker = self.app_ticker.saturating_add(1); + self.last_events.drain(..); + } + + pub fn render_tick(&mut self) { + log::debug!("Render Tick"); + self.render_ticker = self.render_ticker.saturating_add(1); + } + + pub fn add(&mut self, s: String) { + self.text.push(s) + } + + pub fn schedule_increment(&mut self, i: usize) { + let tx = self.action_tx.clone().unwrap(); + tokio::spawn(async move { + tx.send(Action::EnterProcessing).unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + tx.send(Action::Increment(i)).unwrap(); + tx.send(Action::ExitProcessing).unwrap(); + }); + } + + pub fn schedule_decrement(&mut self, i: usize) { + let tx = self.action_tx.clone().unwrap(); + tokio::spawn(async move { + tx.send(Action::EnterProcessing).unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + tx.send(Action::Decrement(i)).unwrap(); + tx.send(Action::ExitProcessing).unwrap(); + }); + } + + pub fn increment(&mut self, i: usize) { + self.counter = self.counter.saturating_add(i); + } + + pub fn decrement(&mut self, i: usize) { + self.counter = self.counter.saturating_sub(i); + } +} + +impl Component for Home { + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + self.action_tx = Some(tx); + Ok(()) + } + + fn handle_key_events(&mut self, key: KeyEvent) -> Result> { + self.last_events.push(key.clone()); + let action = match self.mode { + Mode::Normal | Mode::Processing => return Ok(None), + Mode::Insert => { + match key.code { + KeyCode::Esc => Action::EnterNormal, + KeyCode::Enter => { + if let Some(sender) = &self.action_tx { + if let Err(e) = sender.send(Action::CompleteInput(self.input.value().to_string())) { + error!("Failed to send action: {:?}", e); + } + } + Action::EnterNormal + }, + _ => { + self.input.handle_event(&crossterm::event::Event::Key(key)); + Action::Update + }, + } + }, + }; + Ok(Some(action)) + } + + fn update(&mut self, action: Action) -> Result> { + match action { + Action::Tick => self.tick(), + Action::Render => self.render_tick(), + Action::ToggleShowHelp => self.show_help = !self.show_help, + Action::ScheduleIncrement => self.schedule_increment(1), + Action::ScheduleDecrement => self.schedule_decrement(1), + Action::Increment(i) => self.increment(i), + Action::Decrement(i) => self.decrement(i), + Action::CompleteInput(s) => self.add(s), + Action::EnterNormal => { + self.mode = Mode::Normal; + }, + Action::EnterInsert => { + self.mode = Mode::Insert; + }, + Action::EnterProcessing => { + self.mode = Mode::Processing; + }, + Action::ExitProcessing => { + // TODO: Make this go to previous mode instead + self.mode = Mode::Normal; + }, + _ => (), + } + Ok(None) + } + + fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> { + let rects = Layout::default().constraints([Constraint::Percentage(100), Constraint::Min(3)].as_ref()).split(rect); + + let mut text: Vec = self.text.clone().iter().map(|l| Line::from(l.clone())).collect(); + text.insert(0, "".into()); + text.insert(0, "Type into input and hit enter to display here".dim().into()); + text.insert(0, "".into()); + text.insert(0, format!("Render Ticker: {}", self.render_ticker).into()); + text.insert(0, format!("App Ticker: {}", self.app_ticker).into()); + text.insert(0, format!("Counter: {}", self.counter).into()); + text.insert(0, "".into()); + text.insert( + 0, + Line::from(vec![ + "Press ".into(), + Span::styled("j", Style::default().fg(Color::Red)), + " or ".into(), + Span::styled("k", Style::default().fg(Color::Red)), + " to ".into(), + Span::styled("increment", Style::default().fg(Color::Yellow)), + " or ".into(), + Span::styled("decrement", Style::default().fg(Color::Yellow)), + ".".into(), + ]), + ); + text.insert(0, "".into()); + + f.render_widget( + Paragraph::new(text) + .block( + Block::default() + .title("ratatui async template") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_style(match self.mode { + Mode::Processing => Style::default().fg(Color::Yellow), + _ => Style::default(), + }) + .border_type(BorderType::Rounded), + ) + .style(Style::default().fg(Color::Cyan)) + .alignment(Alignment::Center), + rects[0], + ); + let width = rects[1].width.max(3) - 3; // keep 2 for borders and 1 for cursor + let scroll = self.input.visual_scroll(width as usize); + let input = Paragraph::new(self.input.value()) + .style(match self.mode { + Mode::Insert => Style::default().fg(Color::Yellow), + _ => Style::default(), + }) + .scroll((0, scroll as u16)) + .block(Block::default().borders(Borders::ALL).title(Line::from(vec![ + Span::raw("Enter Input Mode "), + Span::styled("(Press ", Style::default().fg(Color::DarkGray)), + Span::styled("/", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)), + Span::styled(" to start, ", Style::default().fg(Color::DarkGray)), + Span::styled("ESC", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)), + Span::styled(" to finish)", Style::default().fg(Color::DarkGray)), + ]))); + f.render_widget(input, rects[1]); + if self.mode == Mode::Insert { + f.set_cursor((rects[1].x + 1 + self.input.cursor() as u16).min(rects[1].x + rects[1].width - 2), rects[1].y + 1) + } + + if self.show_help { + let rect = rect.inner(&Margin { horizontal: 4, vertical: 2 }); + f.render_widget(Clear, rect); + let block = Block::default() + .title(Line::from(vec![Span::styled("Key Bindings", Style::default().add_modifier(Modifier::BOLD))])) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)); + f.render_widget(block, rect); + let rows = vec![ + Row::new(vec!["j", "Increment"]), + Row::new(vec!["k", "Decrement"]), + Row::new(vec!["/", "Enter Input"]), + Row::new(vec!["ESC", "Exit Input"]), + Row::new(vec!["Enter", "Submit Input"]), + Row::new(vec!["q", "Quit"]), + Row::new(vec!["?", "Open Help"]), + ]; + let table = Table::new(rows) + .header(Row::new(vec!["Key", "Action"]).bottom_margin(1).style(Style::default().add_modifier(Modifier::BOLD))) + .widths(&[Constraint::Percentage(10), Constraint::Percentage(90)]) + .column_spacing(1); + f.render_widget(table, rect.inner(&Margin { vertical: 4, horizontal: 2 })); + }; + + f.render_widget( + Block::default() + .title( + ratatui::widgets::block::Title::from(format!( + "{:?}", + &self.last_events.iter().map(|k| key_event_to_string(k)).collect::>() + )) + .alignment(Alignment::Right), + ) + .title_style(Style::default().add_modifier(Modifier::BOLD)), + Rect { x: rect.x + 1, y: rect.height.saturating_sub(1), width: rect.width.saturating_sub(2), height: 1 }, + ); + + Ok(()) + } +} diff --git a/async/ratatui-counter/src/config.rs b/async/ratatui-counter/src/config.rs new file mode 100644 index 0000000..7a3adc9 --- /dev/null +++ b/async/ratatui-counter/src/config.rs @@ -0,0 +1,503 @@ +use std::{collections::HashMap, fmt, path::PathBuf}; + +use color_eyre::eyre::Result; +use config::Value; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use derive_deref::{Deref, DerefMut}; +use ratatui::style::{Color, Modifier, Style}; +use serde::{ + de::{self, Deserializer, MapAccess, Visitor}, + Deserialize, Serialize, +}; +use serde_json::Value as JsonValue; + +use crate::{action::Action, app::Mode}; + +const CONFIG: &str = include_str!("../.config/config.json5"); + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct AppConfig { + #[serde(default)] + pub _data_dir: PathBuf, + #[serde(default)] + pub _config_dir: PathBuf, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Config { + #[serde(default, flatten)] + pub config: AppConfig, + #[serde(default)] + pub keybindings: KeyBindings, + #[serde(default)] + pub styles: Styles, +} + +impl Config { + pub fn new() -> Result { + let default_config: Config = json5::from_str(CONFIG).unwrap(); + let data_dir = crate::utils::get_data_dir(); + let config_dir = crate::utils::get_config_dir(); + let mut builder = config::Config::builder() + .set_default("_data_dir", data_dir.to_str().unwrap())? + .set_default("_config_dir", config_dir.to_str().unwrap())?; + + let config_files = [ + ("config.json5", config::FileFormat::Json5), + ("config.json", config::FileFormat::Json), + ("config.yaml", config::FileFormat::Yaml), + ("config.toml", config::FileFormat::Toml), + ("config.ini", config::FileFormat::Ini), + ]; + let mut found_config = false; + for (file, format) in &config_files { + builder = builder.add_source(config::File::from(config_dir.join(file)).format(*format).required(false)); + if config_dir.join(file).exists() { + found_config = true + } + } + if !found_config { + log::error!("No configuration file found. Application may not behave as expected"); + } + + let mut cfg: Self = builder.build()?.try_deserialize()?; + + for (mode, default_bindings) in default_config.keybindings.iter() { + let user_bindings = cfg.keybindings.entry(*mode).or_default(); + for (key, cmd) in default_bindings.iter() { + user_bindings.entry(key.clone()).or_insert_with(|| cmd.clone()); + } + } + for (mode, default_styles) in default_config.styles.iter() { + let user_styles = cfg.styles.entry(*mode).or_default(); + for (style_key, style) in default_styles.iter() { + user_styles.entry(style_key.clone()).or_insert_with(|| style.clone()); + } + } + + Ok(cfg) + } +} + +#[derive(Clone, Debug, Default, Deref, DerefMut)] +pub struct KeyBindings(pub HashMap, Action>>); + +impl<'de> Deserialize<'de> for KeyBindings { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let parsed_map = HashMap::>::deserialize(deserializer)?; + + let keybindings = parsed_map + .into_iter() + .map(|(mode, inner_map)| { + let converted_inner_map = + inner_map.into_iter().map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)).collect(); + (mode, converted_inner_map) + }) + .collect(); + + Ok(KeyBindings(keybindings)) + } +} + +fn parse_key_event(raw: &str) -> Result { + let raw_lower = raw.to_ascii_lowercase(); + let (remaining, modifiers) = extract_modifiers(&raw_lower); + parse_key_code_with_modifiers(remaining, modifiers) +} + +fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { + let mut modifiers = KeyModifiers::empty(); + let mut current = raw; + + loop { + match current { + rest if rest.starts_with("ctrl-") => { + modifiers.insert(KeyModifiers::CONTROL); + current = &rest[5..]; + }, + rest if rest.starts_with("alt-") => { + modifiers.insert(KeyModifiers::ALT); + current = &rest[4..]; + }, + rest if rest.starts_with("shift-") => { + modifiers.insert(KeyModifiers::SHIFT); + current = &rest[6..]; + }, + _ => break, // break out of the loop if no known prefix is detected + }; + } + + (current, modifiers) +} + +fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result { + let c = match raw { + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "backtab" => { + modifiers.insert(KeyModifiers::SHIFT); + KeyCode::BackTab + }, + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "f1" => KeyCode::F(1), + "f2" => KeyCode::F(2), + "f3" => KeyCode::F(3), + "f4" => KeyCode::F(4), + "f5" => KeyCode::F(5), + "f6" => KeyCode::F(6), + "f7" => KeyCode::F(7), + "f8" => KeyCode::F(8), + "f9" => KeyCode::F(9), + "f10" => KeyCode::F(10), + "f11" => KeyCode::F(11), + "f12" => KeyCode::F(12), + "space" => KeyCode::Char(' '), + "hyphen" => KeyCode::Char('-'), + "minus" => KeyCode::Char('-'), + "tab" => KeyCode::Tab, + c if c.len() == 1 => { + let mut c = c.chars().next().unwrap(); + if modifiers.contains(KeyModifiers::SHIFT) { + c = c.to_ascii_uppercase(); + } + KeyCode::Char(c) + }, + _ => return Err(format!("Unable to parse {raw}")), + }; + Ok(KeyEvent::new(c, modifiers)) +} + +pub fn key_event_to_string(key_event: &KeyEvent) -> String { + let char; + let key_code = match key_event.code { + KeyCode::Backspace => "backspace", + KeyCode::Enter => "enter", + KeyCode::Left => "left", + KeyCode::Right => "right", + KeyCode::Up => "up", + KeyCode::Down => "down", + KeyCode::Home => "home", + KeyCode::End => "end", + KeyCode::PageUp => "pageup", + KeyCode::PageDown => "pagedown", + KeyCode::Tab => "tab", + KeyCode::BackTab => "backtab", + KeyCode::Delete => "delete", + KeyCode::Insert => "insert", + KeyCode::F(c) => { + char = format!("f({c})"); + &char + }, + KeyCode::Char(c) if c == ' ' => "space", + KeyCode::Char(c) => { + char = c.to_string(); + &char + }, + KeyCode::Esc => "esc", + KeyCode::Null => "", + KeyCode::CapsLock => "", + KeyCode::Menu => "", + KeyCode::ScrollLock => "", + KeyCode::Media(_) => "", + KeyCode::NumLock => "", + KeyCode::PrintScreen => "", + KeyCode::Pause => "", + KeyCode::KeypadBegin => "", + KeyCode::Modifier(_) => "", + }; + + let mut modifiers = Vec::with_capacity(3); + + if key_event.modifiers.intersects(KeyModifiers::CONTROL) { + modifiers.push("ctrl"); + } + + if key_event.modifiers.intersects(KeyModifiers::SHIFT) { + modifiers.push("shift"); + } + + if key_event.modifiers.intersects(KeyModifiers::ALT) { + modifiers.push("alt"); + } + + let mut key = modifiers.join("-"); + + if !key.is_empty() { + key.push('-'); + } + key.push_str(key_code); + + key +} + +pub fn parse_key_sequence(raw: &str) -> Result, String> { + if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { + return Err(format!("Unable to parse `{}`", raw)); + } + let raw = if !raw.contains("><") { + let raw = raw.strip_prefix('<').unwrap_or(raw); + let raw = raw.strip_prefix('>').unwrap_or(raw); + raw + } else { + raw + }; + let sequences = raw + .split("><") + .map(|seq| { + if let Some(s) = seq.strip_prefix('<') { + s + } else if let Some(s) = seq.strip_suffix('>') { + s + } else { + seq + } + }) + .collect::>(); + + sequences.into_iter().map(parse_key_event).collect() +} + +#[derive(Clone, Debug, Default, Deref, DerefMut)] +pub struct Styles(pub HashMap>); + +impl<'de> Deserialize<'de> for Styles { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let parsed_map = HashMap::>::deserialize(deserializer)?; + + let styles = parsed_map + .into_iter() + .map(|(mode, inner_map)| { + let converted_inner_map = inner_map.into_iter().map(|(str, style)| (str, parse_style(&style))).collect(); + (mode, converted_inner_map) + }) + .collect(); + + Ok(Styles(styles)) + } +} + +pub fn parse_style(line: &str) -> Style { + let (foreground, background) = line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len())); + let foreground = process_color_string(foreground); + let background = process_color_string(&background.replace("on ", "")); + + let mut style = Style::default(); + if let Some(fg) = parse_color(&foreground.0) { + style = style.fg(fg); + } + if let Some(bg) = parse_color(&background.0) { + style = style.bg(bg); + } + style = style.add_modifier(foreground.1 | background.1); + style +} + +fn process_color_string(color_str: &str) -> (String, Modifier) { + let color = color_str + .replace("grey", "gray") + .replace("bright ", "") + .replace("bold ", "") + .replace("underline ", "") + .replace("inverse ", ""); + + let mut modifiers = Modifier::empty(); + if color_str.contains("underline") { + modifiers |= Modifier::UNDERLINED; + } + if color_str.contains("bold") { + modifiers |= Modifier::BOLD; + } + if color_str.contains("inverse") { + modifiers |= Modifier::REVERSED; + } + + (color, modifiers) +} + +fn parse_color(s: &str) -> Option { + let s = s.trim_start(); + let s = s.trim_end(); + if s.contains("bright color") { + let s = s.trim_start_matches("bright "); + let c = s.trim_start_matches("color").parse::().unwrap_or_default(); + Some(Color::Indexed(c.wrapping_shl(8))) + } else if s.contains("color") { + let c = s.trim_start_matches("color").parse::().unwrap_or_default(); + Some(Color::Indexed(c)) + } else if s.contains("gray") { + let c = 232 + s.trim_start_matches("gray").parse::().unwrap_or_default(); + Some(Color::Indexed(c)) + } else if s.contains("rgb") { + let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; + let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8; + let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8; + let c = 16 + red * 36 + green * 6 + blue; + Some(Color::Indexed(c)) + } else if s == "bold black" { + Some(Color::Indexed(8)) + } else if s == "bold red" { + Some(Color::Indexed(9)) + } else if s == "bold green" { + Some(Color::Indexed(10)) + } else if s == "bold yellow" { + Some(Color::Indexed(11)) + } else if s == "bold blue" { + Some(Color::Indexed(12)) + } else if s == "bold magenta" { + Some(Color::Indexed(13)) + } else if s == "bold cyan" { + Some(Color::Indexed(14)) + } else if s == "bold white" { + Some(Color::Indexed(15)) + } else if s == "black" { + Some(Color::Indexed(0)) + } else if s == "red" { + Some(Color::Indexed(1)) + } else if s == "green" { + Some(Color::Indexed(2)) + } else if s == "yellow" { + Some(Color::Indexed(3)) + } else if s == "blue" { + Some(Color::Indexed(4)) + } else if s == "magenta" { + Some(Color::Indexed(5)) + } else if s == "cyan" { + Some(Color::Indexed(6)) + } else if s == "white" { + Some(Color::Indexed(7)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_parse_style_default() { + let style = parse_style(""); + assert_eq!(style, Style::default()); + } + + #[test] + fn test_parse_style_foreground() { + let style = parse_style("red"); + assert_eq!(style.fg, Some(Color::Indexed(1))); + } + + #[test] + fn test_parse_style_background() { + let style = parse_style("on blue"); + assert_eq!(style.bg, Some(Color::Indexed(4))); + } + + #[test] + fn test_parse_style_modifiers() { + let style = parse_style("underline red on blue"); + assert_eq!(style.fg, Some(Color::Indexed(1))); + assert_eq!(style.bg, Some(Color::Indexed(4))); + } + + #[test] + fn test_process_color_string() { + let (color, modifiers) = process_color_string("underline bold inverse gray"); + assert_eq!(color, "gray"); + assert!(modifiers.contains(Modifier::UNDERLINED)); + assert!(modifiers.contains(Modifier::BOLD)); + assert!(modifiers.contains(Modifier::REVERSED)); + } + + #[test] + fn test_parse_color_rgb() { + let color = parse_color("rgb123"); + let expected = 16 + 1 * 36 + 2 * 6 + 3; + assert_eq!(color, Some(Color::Indexed(expected))); + } + + #[test] + fn test_parse_color_unknown() { + let color = parse_color("unknown"); + assert_eq!(color, None); + } + + #[test] + fn test_config() -> Result<()> { + let c = Config::new()?; + assert_eq!( + c.keybindings.get(&Mode::Home).unwrap().get(&parse_key_sequence("").unwrap_or_default()).unwrap(), + &Action::Quit + ); + Ok(()) + } + + #[test] + fn test_simple_keys() { + assert_eq!(parse_key_event("a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + + assert_eq!(parse_key_event("enter").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); + + assert_eq!(parse_key_event("esc").unwrap(), KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); + } + + #[test] + fn test_with_modifiers() { + assert_eq!(parse_key_event("ctrl-a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); + + assert_eq!(parse_key_event("alt-enter").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)); + + assert_eq!(parse_key_event("shift-esc").unwrap(), KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)); + } + + #[test] + fn test_multiple_modifiers() { + assert_eq!( + parse_key_event("ctrl-alt-a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT) + ); + + assert_eq!( + parse_key_event("ctrl-shift-enter").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) + ); + } + + #[test] + fn test_reverse_multiple_modifiers() { + assert_eq!( + key_event_to_string(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)), + "ctrl-alt-a".to_string() + ); + } + + #[test] + fn test_invalid_keys() { + assert!(parse_key_event("invalid-key").is_err()); + assert!(parse_key_event("ctrl-invalid-key").is_err()); + } + + #[test] + fn test_case_insensitivity() { + assert_eq!(parse_key_event("CTRL-a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); + + assert_eq!(parse_key_event("AlT-eNtEr").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)); + } +} diff --git a/async/ratatui-counter/src/main.rs b/async/ratatui-counter/src/main.rs new file mode 100644 index 0000000..f1ebccd --- /dev/null +++ b/async/ratatui-counter/src/main.rs @@ -0,0 +1,44 @@ +#![allow(dead_code)] +#![allow(unused_imports)] +#![allow(unused_variables)] + +// ANCHOR: all +pub mod action; +pub mod app; +pub mod cli; +pub mod components; +pub mod config; +pub mod tui; +pub mod utils; + +use clap::Parser; +use cli::Cli; +use color_eyre::eyre::Result; + +use crate::{ + app::App, + utils::{initialize_logging, initialize_panic_handler, version}, +}; + +async fn tokio_main() -> Result<()> { + initialize_logging()?; + + initialize_panic_handler()?; + + let args = Cli::parse(); + let mut app = App::new(args.tick_rate, args.frame_rate)?; + app.run().await?; + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + if let Err(e) = tokio_main().await { + eprintln!("{} error: Something went wrong", env!("CARGO_PKG_NAME")); + Err(e) + } else { + Ok(()) + } +} +// ANCHOR_END: all diff --git a/async/ratatui-counter/src/tui.rs b/async/ratatui-counter/src/tui.rs new file mode 100644 index 0000000..0e635a0 --- /dev/null +++ b/async/ratatui-counter/src/tui.rs @@ -0,0 +1,204 @@ +use std::{ + ops::{Deref, DerefMut}, + time::Duration, +}; + +use color_eyre::eyre::Result; +use crossterm::{ + cursor, + event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::{FutureExt, StreamExt}; +use ratatui::backend::CrosstermBackend as Backend; +use serde::{Deserialize, Serialize}; +use tokio::{ + sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; + +pub type Frame<'a> = ratatui::Frame<'a, Backend>; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Event { + Init, + Quit, + Error, + Closed, + Tick, + Render, + FocusGained, + FocusLost, + Paste(String), + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} + +pub struct Tui { + pub terminal: ratatui::Terminal>, + pub task: JoinHandle<()>, + pub cancellation_token: CancellationToken, + pub event_rx: UnboundedReceiver, + pub event_tx: UnboundedSender, + pub frame_rate: f64, + pub tick_rate: f64, +} + +impl Tui { + pub fn new() -> Result { + let tick_rate = 4.0; + let frame_rate = 60.0; + let terminal = ratatui::Terminal::new(Backend::new(std::io::stderr()))?; + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let cancellation_token = CancellationToken::new(); + let task = tokio::spawn(async {}); + Ok(Self { terminal, task, cancellation_token, event_rx, event_tx, frame_rate, tick_rate }) + } + + pub fn tick_rate(&mut self, tick_rate: f64) { + self.tick_rate = tick_rate; + } + + pub fn frame_rate(&mut self, frame_rate: f64) { + self.frame_rate = frame_rate; + } + + pub fn start(&mut self) { + let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate); + let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate); + self.cancel(); + self.cancellation_token = CancellationToken::new(); + let _cancellation_token = self.cancellation_token.clone(); + let _event_tx = self.event_tx.clone(); + self.task = tokio::spawn(async move { + let mut reader = crossterm::event::EventStream::new(); + let mut tick_interval = tokio::time::interval(tick_delay); + let mut render_interval = tokio::time::interval(render_delay); + _event_tx.send(Event::Init).unwrap(); + loop { + let tick_delay = tick_interval.tick(); + let render_delay = render_interval.tick(); + let crossterm_event = reader.next().fuse(); + tokio::select! { + _ = _cancellation_token.cancelled() => { + break; + } + maybe_event = crossterm_event => { + match maybe_event { + Some(Ok(evt)) => { + match evt { + CrosstermEvent::Key(key) => { + if key.kind == KeyEventKind::Press { + _event_tx.send(Event::Key(key)).unwrap(); + } + }, + CrosstermEvent::Mouse(mouse) => { + _event_tx.send(Event::Mouse(mouse)).unwrap(); + }, + CrosstermEvent::Resize(x, y) => { + _event_tx.send(Event::Resize(x, y)).unwrap(); + }, + CrosstermEvent::FocusLost => { + _event_tx.send(Event::FocusLost).unwrap(); + }, + CrosstermEvent::FocusGained => { + _event_tx.send(Event::FocusGained).unwrap(); + }, + CrosstermEvent::Paste(s) => { + _event_tx.send(Event::Paste(s)).unwrap(); + }, + } + } + Some(Err(_)) => { + _event_tx.send(Event::Error).unwrap(); + } + None => {}, + } + }, + _ = tick_delay => { + _event_tx.send(Event::Tick).unwrap(); + }, + _ = render_delay => { + _event_tx.send(Event::Render).unwrap(); + }, + } + } + }); + } + + pub fn stop(&self) -> Result<()> { + self.cancel(); + let mut counter = 0; + while !self.task.is_finished() { + std::thread::sleep(Duration::from_millis(1)); + counter += 1; + if counter > 50 { + self.task.abort(); + } + if counter > 100 { + log::error!("Failed to abort task in 100 milliseconds for unknown reason"); + break; + } + } + Ok(()) + } + + pub fn enter(&mut self) -> Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?; + self.start(); + Ok(()) + } + + pub fn exit(&mut self) -> Result<()> { + self.stop()?; + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } + + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + pub fn suspend(&mut self) -> Result<()> { + self.exit()?; + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; + Ok(()) + } + + pub fn resume(&mut self) -> Result<()> { + self.enter()?; + Ok(()) + } + + pub async fn next(&mut self) -> Option { + self.event_rx.recv().await + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} diff --git a/async/ratatui-counter/src/utils.rs b/async/ratatui-counter/src/utils.rs new file mode 100644 index 0000000..a00b6eb --- /dev/null +++ b/async/ratatui-counter/src/utils.rs @@ -0,0 +1,162 @@ +use std::path::PathBuf; + +use color_eyre::eyre::Result; +use directories::ProjectDirs; +use lazy_static::lazy_static; +use tracing::error; +use tracing_error::ErrorLayer; +use tracing_subscriber::{self, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, Layer}; + +pub static GIT_COMMIT_HASH: &'static str = env!("RATATUI_COUNTER_GIT_INFO"); + +lazy_static! { + pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); + pub static ref DATA_FOLDER: Option = + std::env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from); + pub static ref CONFIG_FOLDER: Option = + std::env::var(format!("{}_CONFIG", PROJECT_NAME.clone())).ok().map(PathBuf::from); + pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", PROJECT_NAME.clone()); + pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); +} + +fn project_directory() -> Option { + ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME")) +} + +pub fn initialize_panic_handler() -> Result<()> { + let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() + .panic_section(format!("This is a bug. Consider reporting it at {}", env!("CARGO_PKG_REPOSITORY"))) + .capture_span_trace_by_default(false) + .display_location_section(false) + .display_env_section(false) + .into_hooks(); + eyre_hook.install()?; + std::panic::set_hook(Box::new(move |panic_info| { + if let Ok(mut t) = crate::tui::Tui::new() { + if let Err(r) = t.exit() { + error!("Unable to exit Terminal: {:?}", r); + } + } + + #[cfg(not(debug_assertions))] + { + use human_panic::{handle_dump, print_msg, Metadata}; + let meta = Metadata { + version: env!("CARGO_PKG_VERSION").into(), + name: env!("CARGO_PKG_NAME").into(), + authors: env!("CARGO_PKG_AUTHORS").replace(':', ", ").into(), + homepage: env!("CARGO_PKG_HOMEPAGE").into(), + }; + + let file_path = handle_dump(&meta, panic_info); + // prints human-panic message + print_msg(file_path, &meta).expect("human-panic: printing error message to console failed"); + eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr + } + let msg = format!("{}", panic_hook.panic_report(panic_info)); + log::error!("Error: {}", strip_ansi_escapes::strip_str(msg)); + + #[cfg(debug_assertions)] + { + // Better Panic stacktrace that is only enabled when debugging. + better_panic::Settings::auto() + .most_recent_first(false) + .lineno_suffix(true) + .verbosity(better_panic::Verbosity::Full) + .create_panic_handler()(panic_info); + } + + std::process::exit(libc::EXIT_FAILURE); + })); + Ok(()) +} + +pub fn get_data_dir() -> PathBuf { + let directory = if let Some(s) = DATA_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.data_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".data") + }; + directory +} + +pub fn get_config_dir() -> PathBuf { + let directory = if let Some(s) = CONFIG_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.config_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".config") + }; + directory +} + +pub fn initialize_logging() -> Result<()> { + let directory = get_data_dir(); + std::fs::create_dir_all(directory.clone())?; + let log_path = directory.join(LOG_FILE.clone()); + let log_file = std::fs::File::create(log_path)?; + std::env::set_var( + "RUST_LOG", + std::env::var("RUST_LOG") + .or_else(|_| std::env::var(LOG_ENV.clone())) + .unwrap_or_else(|_| format!("{}=info", env!("CARGO_CRATE_NAME"))), + ); + let file_subscriber = tracing_subscriber::fmt::layer() + .with_file(true) + .with_line_number(true) + .with_writer(log_file) + .with_target(false) + .with_ansi(false) + .with_filter(tracing_subscriber::filter::EnvFilter::from_default_env()); + tracing_subscriber::registry().with(file_subscriber).with(ErrorLayer::default()).init(); + Ok(()) +} + +/// Similar to the `std::dbg!` macro, but generates `tracing` events rather +/// than printing to stdout. +/// +/// By default, the verbosity level for the generated events is `DEBUG`, but +/// this can be customized. +#[macro_export] +macro_rules! trace_dbg { + (target: $target:expr, level: $level:expr, $ex:expr) => {{ + match $ex { + value => { + tracing::event!(target: $target, $level, ?value, stringify!($ex)); + value + } + } + }}; + (level: $level:expr, $ex:expr) => { + trace_dbg!(target: module_path!(), level: $level, $ex) + }; + (target: $target:expr, $ex:expr) => { + trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) + }; + ($ex:expr) => { + trace_dbg!(level: tracing::Level::DEBUG, $ex) + }; +} + +pub fn version() -> String { + let author = clap::crate_authors!(); + + let commit_hash = GIT_COMMIT_HASH.clone(); + + // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string(); + let config_dir_path = get_config_dir().display().to_string(); + let data_dir_path = get_data_dir().display().to_string(); + + format!( + "\ +{commit_hash} + +Authors: {author} + +Config directory: {config_dir_path} +Data directory: {data_dir_path}" + ) +} diff --git a/async/template/.config/config.json5 b/async/template/.config/config.json5 new file mode 100644 index 0000000..c746239 --- /dev/null +++ b/async/template/.config/config.json5 @@ -0,0 +1,10 @@ +{ + "keybindings": { + "Home": { + "": "Quit", // Quit the application + "": "Quit", // Another way to quit + "": "Quit", // Yet another way to quit + "": "Suspend" // Suspend the application + }, + } +} diff --git a/async/template/.envrc b/async/template/.envrc new file mode 100644 index 0000000..4daf9df --- /dev/null +++ b/async/template/.envrc @@ -0,0 +1,3 @@ +export {{crate_name | shouty_snake_case}}_CONFIG=`pwd`/.config +export {{crate_name | shouty_snake_case}}_DATA=`pwd`/.data +export {{crate_name | shouty_snake_case}}_LOG_LEVEL=debug diff --git a/async/template/.github/workflows/cd.yml b/async/template/.github/workflows/cd.yml new file mode 100644 index 0000000..dd66b92 --- /dev/null +++ b/async/template/.github/workflows/cd.yml @@ -0,0 +1,152 @@ +name: CD # Continuous Deployment + +on: + push: + tags: + - '[v]?[0-9]+.[0-9]+.[0-9]+' + +jobs: + publish: +{% raw %} + name: Publishing for ${{ matrix.os }} + runs-on: ${{ matrix.os }} +{% endraw %} + strategy: + matrix: + include: + - os: macos-latest + os-name: macos + target: x86_64-apple-darwin + architecture: x86_64 + binary-postfix: "" + binary-name: {{ project-name }} + use-cross: false + - os: macos-latest + os-name: macos + target: aarch64-apple-darwin + architecture: arm64 + binary-postfix: "" + use-cross: false + binary-name: {{ project-name }} + - os: ubuntu-latest + os-name: linux + target: x86_64-unknown-linux-gnu + architecture: x86_64 + binary-postfix: "" + use-cross: false + binary-name: {{ project-name }} + - os: windows-latest + os-name: windows + target: x86_64-pc-windows-msvc + architecture: x86_64 + binary-postfix: ".exe" + use-cross: false + binary-name: {{ project-name }} + - os: ubuntu-latest + os-name: linux + target: aarch64-unknown-linux-gnu + architecture: arm64 + binary-postfix: "" + use-cross: true + binary-name: {{ project-name }} + - os: ubuntu-latest + os-name: linux + target: i686-unknown-linux-gnu + architecture: i686 + binary-postfix: "" + use-cross: true + binary-name: {{ project-name }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable +{% raw %} + target: ${{ matrix.target }} +{% endraw %} + profile: minimal + override: true + - uses: Swatinem/rust-cache@v2 + - name: Cargo build + uses: actions-rs/cargo@v1 + with: + command: build +{% raw %} + use-cross: ${{ matrix.use-cross }} +{% endraw %} + toolchain: stable +{% raw %} + args: --release --target ${{ matrix.target }} +{% endraw %} + + - name: install strip command + shell: bash + run: | +{% raw %} + if [[ ${{ matrix.target }} == aarch64-unknown-linux-gnu ]]; then +{% endraw %} + sudo apt update + sudo apt-get install -y binutils-aarch64-linux-gnu + fi + - name: Packaging final binary + shell: bash + run: | +{% raw %} + cd target/${{ matrix.target }}/release +{% endraw %} + + ####### reduce binary size by removing debug symbols ####### +{% raw %} + BINARY_NAME=${{ matrix.binary-name }}${{ matrix.binary-postfix }} + if [[ ${{ matrix.target }} == aarch64-unknown-linux-gnu ]]; then +{% endraw %} + GCC_PREFIX="aarch64-linux-gnu-" + else + GCC_PREFIX="" + fi + "$GCC_PREFIX"strip $BINARY_NAME + + ########## create tar.gz ########## +{% raw %} + RELEASE_NAME=${{ matrix.binary-name }}-${GITHUB_REF/refs\/tags\//}-${{ matrix.os-name }}-${{ matrix.architecture }} +{% endraw %} + tar czvf $RELEASE_NAME.tar.gz $BINARY_NAME + + ########## create sha256 ########## +{% raw %} + if [[ ${{ runner.os }} == 'Windows' ]]; then +{% endraw %} + certutil -hashfile $RELEASE_NAME.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.sha256 + else + shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.sha256 + fi + - name: Releasing assets + uses: softprops/action-gh-release@v1 + with: + files: | +{% raw %} + target/${{ matrix.target }}/release/${{ matrix.binary-name }}-*.tar.gz + target/${{ matrix.target }}/release/${{ matrix.binary-name }}-*.sha256 +{% endraw %} + env: +{% raw %} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +{% endraw %} + + publish-cargo: + name: Publishing to Cargo + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo publish + env: +{% raw %} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} +{% endraw %} diff --git a/async/template/.github/workflows/ci.yml b/async/template/.github/workflows/ci.yml new file mode 100644 index 0000000..c64fbee --- /dev/null +++ b/async/template/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI # Continuous Integration + +on: + push: + branches: + - main + pull_request: + +jobs: + + test: + name: Test Suite + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + - name: Run tests + run: cargo test --all-features --workspace + + rustfmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt + - uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: cargo fmt --all --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: Clippy check + run: cargo clippy --all-targets --all-features --workspace -- -D warnings + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + - name: Check documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --document-private-items --all-features --workspace --examples diff --git a/async/template/.gitignore b/async/template/.gitignore new file mode 100644 index 0000000..8d5254c --- /dev/null +++ b/async/template/.gitignore @@ -0,0 +1,2 @@ +/target +.data/*.log diff --git a/async/template/.rustfmt.toml b/async/template/.rustfmt.toml new file mode 100644 index 0000000..1cf4c15 --- /dev/null +++ b/async/template/.rustfmt.toml @@ -0,0 +1,16 @@ +max_width = 120 +use_small_heuristics = "Max" +empty_item_single_line = false +force_multiline_blocks = true +format_code_in_doc_comments = true +match_block_trailing_comma = true +imports_granularity = "Crate" +normalize_comments = true +normalize_doc_attributes = true +overflow_delimited_expr = true +reorder_impl_items = true +reorder_imports = true +group_imports = "StdExternalCrate" +tab_spaces = 2 +use_field_init_shorthand = true +use_try_shorthand = true diff --git a/async/template/Cargo.toml b/async/template/Cargo.toml new file mode 100644 index 0000000..69ea61b --- /dev/null +++ b/async/template/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "{{project-name | kebab_case}}" +version = "0.1.0" +edition = "2021" +description = "{{project-description}}" +{% if repository != "" %}repository = "{{repository}}"{% endif %} +authors = ["{{authors}}"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +better-panic = "0.3.0" +clap = { version = "4.4.5", features = ["derive", "cargo", "wrap_help", "unicode", "string", "unstable-styles"] } +color-eyre = "0.6.2" +config = "0.13.3" +crossterm = { version = "0.27.0", features = ["serde", "event-stream"] } +derive_deref = "1.1.1" +directories = "5.0.1" +futures = "0.3.28" +human-panic = "1.2.0" +json5 = "0.4.1" +lazy_static = "1.4.0" +libc = "0.2.148" +log = "0.4.20" +pretty_assertions = "1.4.0" +ratatui = { version = "0.24.0", features = ["serde", "macros"] } +serde = { version = "1.0.188", features = ["derive"] } +serde_json = "1.0.107" +signal-hook = "0.3.17" +strip-ansi-escapes = "0.2.0" +strum = { version = "0.25.0", features = ["derive"] } +tokio = { version = "1.32.0", features = ["full"] } +tokio-util = "0.7.9" +tracing = "0.1.37" +tracing-error = "0.2.0" +tracing-subscriber = { version = "0.3.17", features = ["env-filter", "serde"] } diff --git a/async/template/LICENSE b/async/template/LICENSE new file mode 100644 index 0000000..8998f01 --- /dev/null +++ b/async/template/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 {{authors}} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/async/template/README.md b/async/template/README.md new file mode 100644 index 0000000..63d9eea --- /dev/null +++ b/async/template/README.md @@ -0,0 +1,5 @@ +# {{project-name}} + +[![CI](https://github.com/{{gh-username}}/{{project-name}}/workflows/CI/badge.svg)](https://github.com/{{gh-username}}/{{project-name}}/actions) + +{{project-description}} diff --git a/async/template/build.rs b/async/template/build.rs new file mode 100644 index 0000000..69582c2 --- /dev/null +++ b/async/template/build.rs @@ -0,0 +1,46 @@ +fn main() { + let git_output = std::process::Command::new("git").args(["rev-parse", "--git-dir"]).output().ok(); + let git_dir = git_output.as_ref().and_then(|output| { + std::str::from_utf8(&output.stdout).ok().and_then(|s| s.strip_suffix('\n').or_else(|| s.strip_suffix("\r\n"))) + }); + + // Tell cargo to rebuild if the head or any relevant refs change. + if let Some(git_dir) = git_dir { + let git_path = std::path::Path::new(git_dir); + let refs_path = git_path.join("refs"); + if git_path.join("HEAD").exists() { + println!("cargo:rerun-if-changed={}/HEAD", git_dir); + } + if git_path.join("packed-refs").exists() { + println!("cargo:rerun-if-changed={}/packed-refs", git_dir); + } + if refs_path.join("heads").exists() { + println!("cargo:rerun-if-changed={}/refs/heads", git_dir); + } + if refs_path.join("tags").exists() { + println!("cargo:rerun-if-changed={}/refs/tags", git_dir); + } + } + + let git_output = + std::process::Command::new("git").args(["describe", "--always", "--tags", "--long", "--dirty"]).output().ok(); + let git_info = git_output.as_ref().and_then(|output| std::str::from_utf8(&output.stdout).ok().map(str::trim)); + let cargo_pkg_version = env!("CARGO_PKG_VERSION"); + + // Default git_describe to cargo_pkg_version + let mut git_describe = String::from(cargo_pkg_version); + + if let Some(git_info) = git_info { + // If the `git_info` contains `CARGO_PKG_VERSION`, we simply use `git_info` as it is. + // Otherwise, prepend `CARGO_PKG_VERSION` to `git_info`. + if git_info.contains(cargo_pkg_version) { + // Remove the 'g' before the commit sha + let git_info = &git_info.replace('g', ""); + git_describe = git_info.to_string(); + } else { + git_describe = format!("v{}-{}", cargo_pkg_version, git_info); + } + } + + println!("cargo:rustc-env=_GIT_INFO={}", git_describe); +} diff --git a/async/template/cargo-generate.toml b/async/template/cargo-generate.toml new file mode 100644 index 0000000..c1c5172 --- /dev/null +++ b/async/template/cargo-generate.toml @@ -0,0 +1,5 @@ +[hooks] +pre = ["./hooks/pre-get-repository.rhai"] + +[template] +cargo_generate_version = ">=0.10.0" diff --git a/async/template/hooks/pre-get-repository.rhai b/async/template/hooks/pre-get-repository.rhai new file mode 100644 index 0000000..bb5b507 --- /dev/null +++ b/async/template/hooks/pre-get-repository.rhai @@ -0,0 +1,95 @@ +// loosely based on https://github.com/xoac/opinionated-rust-template/blob/main/template/pre-get-repository.rhai +if !variable::is_set("project-description") { + let project_description = variable::prompt("Enter a short description of the project?"); + variable::set("project-description", project_description); +} + +let use_gitserver = if variable::is_set("use_gitserver") { + variable::get("use_gitserver") +} else { + variable::prompt("Would you like to set a Git repository URL?", true) +}; + +if use_gitserver { + let gs_username = variable::prompt("Please enter your gitserver username:"); + variable::set("gs_username", gs_username); + let project_name = variable::get("project-name"); + let origin_repository = variable::prompt("Please enter repository URL: ", "https://github.com/" + {gs_username} + "/" + {project_name}); + origin_repository.trim(); + let repository = origin_repository; // make copy for parse + + // remove `http://` `https://` or `git@` + let schemas = ["http://", "https://", "git@"]; + for schema in schemas { + let schema_index = repository.index_of(schema); + if schema_index == 0 { + let splitted_repo = repository.split(schema); + repository = splitted_repo[1]; + break; + } + } + + // split by `/` assume there is `git server`, `organization`, `project-name` + let splitted_repo = repository.split(`/`); + + if splitted_repo.len() != 3 { + abort("could not parse repository url. Expected format /username/project-name ") + } + + let git_server = splitted_repo[0]; + let git_user = splitted_repo[1]; + let git_project_name = splitted_repo[2]; + + if git_project_name != project_name { + // FIXME: this should be only warning + let warn_msg = "!!! => project-name(" + {project_name} + ") has different name than used in git-url(" + {git_project_name} + ")!"; + print(warn_msg); + + project_name = variable::prompt("The project name in link and used in prompt are different. Which `project-name` to use?", {project_name}, [{project_name}, {git_project_name}]); + } + + variable::set("repository", "https://" + {repository}); + variable::set("project-name", project_name); +} else { + print("!!! repository in Cargo.toml will not be set"); + variable::set("repository", ""); +} + +if !variable::is_set("crossterm_io") { + let crossterm_io = variable::prompt("Would you like to use stderr or stdout for Crossterm IO?", "stderr", ["stderr", "stdout"]); + variable::set("crossterm_io", crossterm_io); +} else { + if variable::get("crossterm_io").to_lower() == "stdout" { + variable::set("crossterm_io", "stdout"); + } else if variable::get("crossterm_io").to_lower() == "stderr" { + variable::set("crossterm_io", "stderr"); + } else { + print("!!! Unknown value for `crossterm_io`: " + variable::get("crossterm_io") + ". Using `stderr`."); + variable::set("crossterm_io", "stderr"); + } +}; + +let use_rustfmt = if variable::is_set("use_rustfmt") { + variable::get("use_rustfmt") +} else { + variable::prompt("Would you like to use an opinionated rustfmt.toml file?", true) +}; + +if !use_rustfmt { + file::delete("./.rustfmt.toml"); +} + +let project_name = variable::get("project-name"); +let crate_name = project_name; +crate_name.replace("-", "_"); +crate_name.replace(" ", "_"); +variable::set("crate_name", crate_name); + +let msrv = if variable::is_set("msrv") { + variable::get("msrv") +} else { + variable::prompt("What is your Minimum Supported Rust Version", "stable") +}; +variable::set("msrv", msrv); + +file::delete("./hooks"); diff --git a/async/template/rust-toolchain.toml b/async/template/rust-toolchain.toml new file mode 100644 index 0000000..ed7bcca --- /dev/null +++ b/async/template/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "{{msrv}}" diff --git a/async/template/src/action.rs b/async/template/src/action.rs new file mode 100644 index 0000000..f422a62 --- /dev/null +++ b/async/template/src/action.rs @@ -0,0 +1,20 @@ +use std::{fmt, string::ToString}; + +use serde::{ + de::{self, Deserializer, Visitor}, + Deserialize, Serialize, +}; +use strum::Display; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Display, Deserialize)] +pub enum Action { + Tick, + Render, + Resize(u16, u16), + Suspend, + Resume, + Quit, + Refresh, + Error(String), + Help, +} diff --git a/async/template/src/app.rs b/async/template/src/app.rs new file mode 100644 index 0000000..e528d05 --- /dev/null +++ b/async/template/src/app.rs @@ -0,0 +1,151 @@ +use color_eyre::eyre::Result; +use crossterm::event::KeyEvent; +use ratatui::prelude::Rect; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +use crate::{ + action::Action, + components::{home::Home, fps::FpsCounter, Component}, + config::Config, + mode::Mode, + tui, +}; + +pub struct App { + pub config: Config, + pub tick_rate: f64, + pub frame_rate: f64, + pub components: Vec>, + pub should_quit: bool, + pub should_suspend: bool, + pub mode: Mode, + pub last_tick_key_events: Vec, +} + +impl App { + pub fn new(tick_rate: f64, frame_rate: f64) -> Result { + let home = Home::new(); + let fps = FpsCounter::default(); + let config = Config::new()?; + let mode = Mode::Home; + Ok(Self { + tick_rate, + frame_rate, + components: vec![Box::new(home), Box::new(fps)], + should_quit: false, + should_suspend: false, + config, + mode, + last_tick_key_events: Vec::new(), + }) + } + + pub async fn run(&mut self) -> Result<()> { + let (action_tx, mut action_rx) = mpsc::unbounded_channel(); + + let mut tui = tui::Tui::new()?.tick_rate(self.tick_rate).frame_rate(self.frame_rate); + // tui.mouse(true); + tui.enter()?; + + for component in self.components.iter_mut() { + component.register_action_handler(action_tx.clone())?; + } + + for component in self.components.iter_mut() { + component.register_config_handler(self.config.clone())?; + } + + for component in self.components.iter_mut() { + component.init(tui.size()?)?; + } + + loop { + if let Some(e) = tui.next().await { + match e { + tui::Event::Quit => action_tx.send(Action::Quit)?, + tui::Event::Tick => action_tx.send(Action::Tick)?, + tui::Event::Render => action_tx.send(Action::Render)?, + tui::Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, + tui::Event::Key(key) => { + if let Some(keymap) = self.config.keybindings.get(&self.mode) { + if let Some(action) = keymap.get(&vec![key]) { + log::info!("Got action: {action:?}"); + action_tx.send(action.clone())?; + } else { + // If the key was not handled as a single key action, + // then consider it for multi-key combinations. + self.last_tick_key_events.push(key); + + // Check for multi-key combinations + if let Some(action) = keymap.get(&self.last_tick_key_events) { + log::info!("Got action: {action:?}"); + action_tx.send(action.clone())?; + } + } + }; + }, + _ => {}, + } + for component in self.components.iter_mut() { + if let Some(action) = component.handle_events(Some(e.clone()))? { + action_tx.send(action)?; + } + } + } + + while let Ok(action) = action_rx.try_recv() { + if action != Action::Tick && action != Action::Render { + log::debug!("{action:?}"); + } + match action { + Action::Tick => { + self.last_tick_key_events.drain(..); + }, + Action::Quit => self.should_quit = true, + Action::Suspend => self.should_suspend = true, + Action::Resume => self.should_suspend = false, + Action::Resize(w, h) => { + tui.resize(Rect::new(0, 0, w, h))?; + tui.draw(|f| { + for component in self.components.iter_mut() { + let r = component.draw(f, f.size()); + if let Err(e) = r { + action_tx.send(Action::Error(format!("Failed to draw: {:?}", e))).unwrap(); + } + } + })?; + }, + Action::Render => { + tui.draw(|f| { + for component in self.components.iter_mut() { + let r = component.draw(f, f.size()); + if let Err(e) = r { + action_tx.send(Action::Error(format!("Failed to draw: {:?}", e))).unwrap(); + } + } + })?; + }, + _ => {}, + } + for component in self.components.iter_mut() { + if let Some(action) = component.update(action.clone())? { + action_tx.send(action)? + }; + } + } + if self.should_suspend { + tui.suspend()?; + action_tx.send(Action::Resume)?; + tui = tui::Tui::new()?.tick_rate(self.tick_rate).frame_rate(self.frame_rate); + // tui.mouse(true); + tui.enter()?; + } else if self.should_quit { + tui.stop()?; + break; + } + } + tui.exit()?; + Ok(()) + } +} diff --git a/async/template/src/cli.rs b/async/template/src/cli.rs new file mode 100644 index 0000000..e1cc94e --- /dev/null +++ b/async/template/src/cli.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; + +use clap::Parser; + +use crate::utils::version; + +#[derive(Parser, Debug)] +#[command(author, version = version(), about)] +pub struct Cli { + #[arg(short, long, value_name = "FLOAT", help = "Tick rate, i.e. number of ticks per second", default_value_t = 1.0)] + pub tick_rate: f64, + + #[arg( + short, + long, + value_name = "FLOAT", + help = "Frame rate, i.e. number of frames per second", + default_value_t = 4.0 + )] + pub frame_rate: f64, +} diff --git a/async/template/src/components.rs b/async/template/src/components.rs new file mode 100644 index 0000000..861df56 --- /dev/null +++ b/async/template/src/components.rs @@ -0,0 +1,124 @@ +use color_eyre::eyre::Result; +use crossterm::event::{KeyEvent, MouseEvent}; +use ratatui::layout::Rect; +use tokio::sync::mpsc::UnboundedSender; + +use crate::{ + action::Action, + config::Config, + tui::{Event, Frame}, +}; + +pub mod fps; +pub mod home; + +/// `Component` is a trait that represents a visual and interactive element of the user interface. +/// Implementors of this trait can be registered with the main application loop and will be able to receive events, +/// update state, and be rendered on the screen. +pub trait Component { + /// Register an action handler that can send actions for processing if necessary. + /// + /// # Arguments + /// + /// * `tx` - An unbounded sender that can send actions. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + #[allow(unused_variables)] + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + Ok(()) + } + /// Register a configuration handler that provides configuration settings if necessary. + /// + /// # Arguments + /// + /// * `config` - Configuration settings. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + #[allow(unused_variables)] + fn register_config_handler(&mut self, config: Config) -> Result<()> { + Ok(()) + } + /// Initialize the component with a specified area if necessary. + /// + /// # Arguments + /// + /// * `area` - Rectangular area to initialize the component within. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + fn init(&mut self, area: Rect) -> Result<()> { + Ok(()) + } + /// Handle incoming events and produce actions if necessary. + /// + /// # Arguments + /// + /// * `event` - An optional event to be processed. + /// + /// # Returns + /// + /// * `Result>` - An action to be processed or none. + fn handle_events(&mut self, event: Option) -> Result> { + let r = match event { + Some(Event::Key(key_event)) => self.handle_key_events(key_event)?, + Some(Event::Mouse(mouse_event)) => self.handle_mouse_events(mouse_event)?, + _ => None, + }; + Ok(r) + } + /// Handle key events and produce actions if necessary. + /// + /// # Arguments + /// + /// * `key` - A key event to be processed. + /// + /// # Returns + /// + /// * `Result>` - An action to be processed or none. + #[allow(unused_variables)] + fn handle_key_events(&mut self, key: KeyEvent) -> Result> { + Ok(None) + } + /// Handle mouse events and produce actions if necessary. + /// + /// # Arguments + /// + /// * `mouse` - A mouse event to be processed. + /// + /// # Returns + /// + /// * `Result>` - An action to be processed or none. + #[allow(unused_variables)] + fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result> { + Ok(None) + } + /// Update the state of the component based on a received action. (REQUIRED) + /// + /// # Arguments + /// + /// * `action` - An action that may modify the state of the component. + /// + /// # Returns + /// + /// * `Result>` - An action to be processed or none. + #[allow(unused_variables)] + fn update(&mut self, action: Action) -> Result> { + Ok(None) + } + /// Render the component on the screen. (REQUIRED) + /// + /// # Arguments + /// + /// * `f` - A frame used for rendering. + /// * `area` - The area in which the component should be drawn. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()>; +} diff --git a/async/template/src/components/fps.rs b/async/template/src/components/fps.rs new file mode 100644 index 0000000..7c79805 --- /dev/null +++ b/async/template/src/components/fps.rs @@ -0,0 +1,90 @@ +use std::time::Instant; + +use color_eyre::eyre::Result; +use ratatui::{prelude::*, widgets::*}; + +use super::Component; +use crate::{action::Action, tui::Frame}; + +#[derive(Debug, Clone, PartialEq)] +pub struct FpsCounter { + app_start_time: Instant, + app_frames: u32, + app_fps: f64, + + render_start_time: Instant, + render_frames: u32, + render_fps: f64, +} + +impl Default for FpsCounter { + fn default() -> Self { + Self::new() + } +} + +impl FpsCounter { + pub fn new() -> Self { + Self { + app_start_time: Instant::now(), + app_frames: 0, + app_fps: 0.0, + render_start_time: Instant::now(), + render_frames: 0, + render_fps: 0.0, + } + } + + fn app_tick(&mut self) -> Result<()> { + self.app_frames += 1; + let now = Instant::now(); + let elapsed = (now - self.app_start_time).as_secs_f64(); + if elapsed >= 1.0 { + self.app_fps = self.app_frames as f64 / elapsed; + self.app_start_time = now; + self.app_frames = 0; + } + Ok(()) + } + + fn render_tick(&mut self) -> Result<()> { + self.render_frames += 1; + let now = Instant::now(); + let elapsed = (now - self.render_start_time).as_secs_f64(); + if elapsed >= 1.0 { + self.render_fps = self.render_frames as f64 / elapsed; + self.render_start_time = now; + self.render_frames = 0; + } + Ok(()) + } +} + +impl Component for FpsCounter { + fn update(&mut self, action: Action) -> Result> { + if let Action::Tick = action { + self.app_tick()? + }; + if let Action::Render = action { + self.render_tick()? + }; + Ok(None) + } + + fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> { + let rects = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![ + Constraint::Length(1), // first row + Constraint::Min(0), + ]) + .split(rect); + + let rect = rects[0]; + + let s = format!("{:.2} ticks per sec (app) {:.2} frames per sec (render)", self.app_fps, self.render_fps); + let block = Block::default().title(block::Title::from(s.dim()).alignment(Alignment::Right)); + f.render_widget(block, rect); + Ok(()) + } +} diff --git a/async/template/src/components/home.rs b/async/template/src/components/home.rs new file mode 100644 index 0000000..a1e9cc6 --- /dev/null +++ b/async/template/src/components/home.rs @@ -0,0 +1,52 @@ +use std::{collections::HashMap, time::Duration}; + +use color_eyre::eyre::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{prelude::*, widgets::*}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc::UnboundedSender; + +use super::{Component, Frame}; +use crate::{ + action::Action, + config::{Config, KeyBindings}, +}; + +#[derive(Default)] +pub struct Home { + command_tx: Option>, + config: Config, +} + +impl Home { + pub fn new() -> Self { + Self::default() + } +} + +impl Component for Home { + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + self.command_tx = Some(tx); + Ok(()) + } + + fn register_config_handler(&mut self, config: Config) -> Result<()> { + self.config = config; + Ok(()) + } + + fn update(&mut self, action: Action) -> Result> { + match action { + Action::Tick => { + }, + _ => {}, + } + Ok(None) + } + + fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { + f.render_widget(Paragraph::new("hello world"), area); + Ok(()) + } +} + diff --git a/async/template/src/config.rs b/async/template/src/config.rs new file mode 100644 index 0000000..73b45dc --- /dev/null +++ b/async/template/src/config.rs @@ -0,0 +1,503 @@ +use std::{collections::HashMap, fmt, path::PathBuf}; + +use color_eyre::eyre::Result; +use config::Value; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use derive_deref::{Deref, DerefMut}; +use ratatui::style::{Color, Modifier, Style}; +use serde::{ + de::{self, Deserializer, MapAccess, Visitor}, + Deserialize, Serialize, +}; +use serde_json::Value as JsonValue; + +use crate::{action::Action, mode::Mode}; + +const CONFIG: &str = include_str!("../.config/config.json5"); + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct AppConfig { + #[serde(default)] + pub _data_dir: PathBuf, + #[serde(default)] + pub _config_dir: PathBuf, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Config { + #[serde(default, flatten)] + pub config: AppConfig, + #[serde(default)] + pub keybindings: KeyBindings, + #[serde(default)] + pub styles: Styles, +} + +impl Config { + pub fn new() -> Result { + let default_config: Config = json5::from_str(CONFIG).unwrap(); + let data_dir = crate::utils::get_data_dir(); + let config_dir = crate::utils::get_config_dir(); + let mut builder = config::Config::builder() + .set_default("_data_dir", data_dir.to_str().unwrap())? + .set_default("_config_dir", config_dir.to_str().unwrap())?; + + let config_files = [ + ("config.json5", config::FileFormat::Json5), + ("config.json", config::FileFormat::Json), + ("config.yaml", config::FileFormat::Yaml), + ("config.toml", config::FileFormat::Toml), + ("config.ini", config::FileFormat::Ini), + ]; + let mut found_config = false; + for (file, format) in &config_files { + builder = builder.add_source(config::File::from(config_dir.join(file)).format(*format).required(false)); + if config_dir.join(file).exists() { + found_config = true + } + } + if !found_config { + log::error!("No configuration file found. Application may not behave as expected"); + } + + let mut cfg: Self = builder.build()?.try_deserialize()?; + + for (mode, default_bindings) in default_config.keybindings.iter() { + let user_bindings = cfg.keybindings.entry(*mode).or_default(); + for (key, cmd) in default_bindings.iter() { + user_bindings.entry(key.clone()).or_insert_with(|| cmd.clone()); + } + } + for (mode, default_styles) in default_config.styles.iter() { + let user_styles = cfg.styles.entry(*mode).or_default(); + for (style_key, style) in default_styles.iter() { + user_styles.entry(style_key.clone()).or_insert_with(|| style.clone()); + } + } + + Ok(cfg) + } +} + +#[derive(Clone, Debug, Default, Deref, DerefMut)] +pub struct KeyBindings(pub HashMap, Action>>); + +impl<'de> Deserialize<'de> for KeyBindings { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let parsed_map = HashMap::>::deserialize(deserializer)?; + + let keybindings = parsed_map + .into_iter() + .map(|(mode, inner_map)| { + let converted_inner_map = + inner_map.into_iter().map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)).collect(); + (mode, converted_inner_map) + }) + .collect(); + + Ok(KeyBindings(keybindings)) + } +} + +fn parse_key_event(raw: &str) -> Result { + let raw_lower = raw.to_ascii_lowercase(); + let (remaining, modifiers) = extract_modifiers(&raw_lower); + parse_key_code_with_modifiers(remaining, modifiers) +} + +fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { + let mut modifiers = KeyModifiers::empty(); + let mut current = raw; + + loop { + match current { + rest if rest.starts_with("ctrl-") => { + modifiers.insert(KeyModifiers::CONTROL); + current = &rest[5..]; + }, + rest if rest.starts_with("alt-") => { + modifiers.insert(KeyModifiers::ALT); + current = &rest[4..]; + }, + rest if rest.starts_with("shift-") => { + modifiers.insert(KeyModifiers::SHIFT); + current = &rest[6..]; + }, + _ => break, // break out of the loop if no known prefix is detected + }; + } + + (current, modifiers) +} + +fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result { + let c = match raw { + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "backtab" => { + modifiers.insert(KeyModifiers::SHIFT); + KeyCode::BackTab + }, + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "f1" => KeyCode::F(1), + "f2" => KeyCode::F(2), + "f3" => KeyCode::F(3), + "f4" => KeyCode::F(4), + "f5" => KeyCode::F(5), + "f6" => KeyCode::F(6), + "f7" => KeyCode::F(7), + "f8" => KeyCode::F(8), + "f9" => KeyCode::F(9), + "f10" => KeyCode::F(10), + "f11" => KeyCode::F(11), + "f12" => KeyCode::F(12), + "space" => KeyCode::Char(' '), + "hyphen" => KeyCode::Char('-'), + "minus" => KeyCode::Char('-'), + "tab" => KeyCode::Tab, + c if c.len() == 1 => { + let mut c = c.chars().next().unwrap(); + if modifiers.contains(KeyModifiers::SHIFT) { + c = c.to_ascii_uppercase(); + } + KeyCode::Char(c) + }, + _ => return Err(format!("Unable to parse {raw}")), + }; + Ok(KeyEvent::new(c, modifiers)) +} + +pub fn key_event_to_string(key_event: &KeyEvent) -> String { + let char; + let key_code = match key_event.code { + KeyCode::Backspace => "backspace", + KeyCode::Enter => "enter", + KeyCode::Left => "left", + KeyCode::Right => "right", + KeyCode::Up => "up", + KeyCode::Down => "down", + KeyCode::Home => "home", + KeyCode::End => "end", + KeyCode::PageUp => "pageup", + KeyCode::PageDown => "pagedown", + KeyCode::Tab => "tab", + KeyCode::BackTab => "backtab", + KeyCode::Delete => "delete", + KeyCode::Insert => "insert", + KeyCode::F(c) => { + char = format!("f({c})"); + &char + }, + KeyCode::Char(c) if c == ' ' => "space", + KeyCode::Char(c) => { + char = c.to_string(); + &char + }, + KeyCode::Esc => "esc", + KeyCode::Null => "", + KeyCode::CapsLock => "", + KeyCode::Menu => "", + KeyCode::ScrollLock => "", + KeyCode::Media(_) => "", + KeyCode::NumLock => "", + KeyCode::PrintScreen => "", + KeyCode::Pause => "", + KeyCode::KeypadBegin => "", + KeyCode::Modifier(_) => "", + }; + + let mut modifiers = Vec::with_capacity(3); + + if key_event.modifiers.intersects(KeyModifiers::CONTROL) { + modifiers.push("ctrl"); + } + + if key_event.modifiers.intersects(KeyModifiers::SHIFT) { + modifiers.push("shift"); + } + + if key_event.modifiers.intersects(KeyModifiers::ALT) { + modifiers.push("alt"); + } + + let mut key = modifiers.join("-"); + + if !key.is_empty() { + key.push('-'); + } + key.push_str(key_code); + + key +} + +pub fn parse_key_sequence(raw: &str) -> Result, String> { + if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { + return Err(format!("Unable to parse `{}`", raw)); + } + let raw = if !raw.contains("><") { + let raw = raw.strip_prefix('<').unwrap_or(raw); + let raw = raw.strip_prefix('>').unwrap_or(raw); + raw + } else { + raw + }; + let sequences = raw + .split("><") + .map(|seq| { + if let Some(s) = seq.strip_prefix('<') { + s + } else if let Some(s) = seq.strip_suffix('>') { + s + } else { + seq + } + }) + .collect::>(); + + sequences.into_iter().map(parse_key_event).collect() +} + +#[derive(Clone, Debug, Default, Deref, DerefMut)] +pub struct Styles(pub HashMap>); + +impl<'de> Deserialize<'de> for Styles { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let parsed_map = HashMap::>::deserialize(deserializer)?; + + let styles = parsed_map + .into_iter() + .map(|(mode, inner_map)| { + let converted_inner_map = inner_map.into_iter().map(|(str, style)| (str, parse_style(&style))).collect(); + (mode, converted_inner_map) + }) + .collect(); + + Ok(Styles(styles)) + } +} + +pub fn parse_style(line: &str) -> Style { + let (foreground, background) = line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len())); + let foreground = process_color_string(foreground); + let background = process_color_string(&background.replace("on ", "")); + + let mut style = Style::default(); + if let Some(fg) = parse_color(&foreground.0) { + style = style.fg(fg); + } + if let Some(bg) = parse_color(&background.0) { + style = style.bg(bg); + } + style = style.add_modifier(foreground.1 | background.1); + style +} + +fn process_color_string(color_str: &str) -> (String, Modifier) { + let color = color_str + .replace("grey", "gray") + .replace("bright ", "") + .replace("bold ", "") + .replace("underline ", "") + .replace("inverse ", ""); + + let mut modifiers = Modifier::empty(); + if color_str.contains("underline") { + modifiers |= Modifier::UNDERLINED; + } + if color_str.contains("bold") { + modifiers |= Modifier::BOLD; + } + if color_str.contains("inverse") { + modifiers |= Modifier::REVERSED; + } + + (color, modifiers) +} + +fn parse_color(s: &str) -> Option { + let s = s.trim_start(); + let s = s.trim_end(); + if s.contains("bright color") { + let s = s.trim_start_matches("bright "); + let c = s.trim_start_matches("color").parse::().unwrap_or_default(); + Some(Color::Indexed(c.wrapping_shl(8))) + } else if s.contains("color") { + let c = s.trim_start_matches("color").parse::().unwrap_or_default(); + Some(Color::Indexed(c)) + } else if s.contains("gray") { + let c = 232 + s.trim_start_matches("gray").parse::().unwrap_or_default(); + Some(Color::Indexed(c)) + } else if s.contains("rgb") { + let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; + let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8; + let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8; + let c = 16 + red * 36 + green * 6 + blue; + Some(Color::Indexed(c)) + } else if s == "bold black" { + Some(Color::Indexed(8)) + } else if s == "bold red" { + Some(Color::Indexed(9)) + } else if s == "bold green" { + Some(Color::Indexed(10)) + } else if s == "bold yellow" { + Some(Color::Indexed(11)) + } else if s == "bold blue" { + Some(Color::Indexed(12)) + } else if s == "bold magenta" { + Some(Color::Indexed(13)) + } else if s == "bold cyan" { + Some(Color::Indexed(14)) + } else if s == "bold white" { + Some(Color::Indexed(15)) + } else if s == "black" { + Some(Color::Indexed(0)) + } else if s == "red" { + Some(Color::Indexed(1)) + } else if s == "green" { + Some(Color::Indexed(2)) + } else if s == "yellow" { + Some(Color::Indexed(3)) + } else if s == "blue" { + Some(Color::Indexed(4)) + } else if s == "magenta" { + Some(Color::Indexed(5)) + } else if s == "cyan" { + Some(Color::Indexed(6)) + } else if s == "white" { + Some(Color::Indexed(7)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_parse_style_default() { + let style = parse_style(""); + assert_eq!(style, Style::default()); + } + + #[test] + fn test_parse_style_foreground() { + let style = parse_style("red"); + assert_eq!(style.fg, Some(Color::Indexed(1))); + } + + #[test] + fn test_parse_style_background() { + let style = parse_style("on blue"); + assert_eq!(style.bg, Some(Color::Indexed(4))); + } + + #[test] + fn test_parse_style_modifiers() { + let style = parse_style("underline red on blue"); + assert_eq!(style.fg, Some(Color::Indexed(1))); + assert_eq!(style.bg, Some(Color::Indexed(4))); + } + + #[test] + fn test_process_color_string() { + let (color, modifiers) = process_color_string("underline bold inverse gray"); + assert_eq!(color, "gray"); + assert!(modifiers.contains(Modifier::UNDERLINED)); + assert!(modifiers.contains(Modifier::BOLD)); + assert!(modifiers.contains(Modifier::REVERSED)); + } + + #[test] + fn test_parse_color_rgb() { + let color = parse_color("rgb123"); + let expected = 16 + 1 * 36 + 2 * 6 + 3; + assert_eq!(color, Some(Color::Indexed(expected))); + } + + #[test] + fn test_parse_color_unknown() { + let color = parse_color("unknown"); + assert_eq!(color, None); + } + + #[test] + fn test_config() -> Result<()> { + let c = Config::new()?; + assert_eq!( + c.keybindings.get(&Mode::Home).unwrap().get(&parse_key_sequence("").unwrap_or_default()).unwrap(), + &Action::Quit + ); + Ok(()) + } + + #[test] + fn test_simple_keys() { + assert_eq!(parse_key_event("a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); + + assert_eq!(parse_key_event("enter").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); + + assert_eq!(parse_key_event("esc").unwrap(), KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); + } + + #[test] + fn test_with_modifiers() { + assert_eq!(parse_key_event("ctrl-a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); + + assert_eq!(parse_key_event("alt-enter").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)); + + assert_eq!(parse_key_event("shift-esc").unwrap(), KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)); + } + + #[test] + fn test_multiple_modifiers() { + assert_eq!( + parse_key_event("ctrl-alt-a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT) + ); + + assert_eq!( + parse_key_event("ctrl-shift-enter").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) + ); + } + + #[test] + fn test_reverse_multiple_modifiers() { + assert_eq!( + key_event_to_string(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)), + "ctrl-alt-a".to_string() + ); + } + + #[test] + fn test_invalid_keys() { + assert!(parse_key_event("invalid-key").is_err()); + assert!(parse_key_event("ctrl-invalid-key").is_err()); + } + + #[test] + fn test_case_insensitivity() { + assert_eq!(parse_key_event("CTRL-a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); + + assert_eq!(parse_key_event("AlT-eNtEr").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)); + } +} diff --git a/async/template/src/main.rs b/async/template/src/main.rs new file mode 100644 index 0000000..cd3303b --- /dev/null +++ b/async/template/src/main.rs @@ -0,0 +1,43 @@ +#![allow(dead_code)] +#![allow(unused_imports)] +#![allow(unused_variables)] + +pub mod action; +pub mod app; +pub mod cli; +pub mod components; +pub mod config; +pub mod mode; +pub mod tui; +pub mod utils; + +use clap::Parser; +use cli::Cli; +use color_eyre::eyre::Result; + +use crate::{ + app::App, + utils::{initialize_logging, initialize_panic_handler, version}, +}; + +async fn tokio_main() -> Result<()> { + initialize_logging()?; + + initialize_panic_handler()?; + + let args = Cli::parse(); + let mut app = App::new(args.tick_rate, args.frame_rate)?; + app.run().await?; + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + if let Err(e) = tokio_main().await { + eprintln!("{} error: Something went wrong", env!("CARGO_PKG_NAME")); + Err(e) + } else { + Ok(()) + } +} diff --git a/async/template/src/mode.rs b/async/template/src/mode.rs new file mode 100644 index 0000000..dde06b1 --- /dev/null +++ b/async/template/src/mode.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Mode { + #[default] + Home, +} diff --git a/async/template/src/tui.rs b/async/template/src/tui.rs new file mode 100644 index 0000000..64fe04a --- /dev/null +++ b/async/template/src/tui.rs @@ -0,0 +1,239 @@ +use std::{ + ops::{Deref, DerefMut}, + time::Duration, +}; + +use color_eyre::eyre::Result; +use crossterm::{ + cursor, + event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event as CrosstermEvent, + KeyEvent, KeyEventKind, MouseEvent, + }, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::{FutureExt, StreamExt}; +use ratatui::backend::CrosstermBackend as Backend; +use serde::{Deserialize, Serialize}; +use tokio::{ + sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; + +pub type IO = std::io::{{crossterm_io | title_case}}; +pub fn io() -> IO { + std::io::{{crossterm_io}}() +} +pub type Frame<'a> = ratatui::Frame<'a>; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Event { + Init, + Quit, + Error, + Closed, + Tick, + Render, + FocusGained, + FocusLost, + Paste(String), + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} + +pub struct Tui { + pub terminal: ratatui::Terminal>, + pub task: JoinHandle<()>, + pub cancellation_token: CancellationToken, + pub event_rx: UnboundedReceiver, + pub event_tx: UnboundedSender, + pub frame_rate: f64, + pub tick_rate: f64, + pub mouse: bool, + pub paste: bool, +} + +impl Tui { + pub fn new() -> Result { + let tick_rate = 4.0; + let frame_rate = 60.0; + let terminal = ratatui::Terminal::new(Backend::new(io()))?; + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let cancellation_token = CancellationToken::new(); + let task = tokio::spawn(async {}); + let mouse = false; + let paste = false; + Ok(Self { terminal, task, cancellation_token, event_rx, event_tx, frame_rate, tick_rate, mouse, paste }) + } + + pub fn tick_rate(mut self, tick_rate: f64) -> Self { + self.tick_rate = tick_rate; + self + } + + pub fn frame_rate(mut self, frame_rate: f64) -> Self { + self.frame_rate = frame_rate; + self + } + + pub fn mouse(mut self, mouse: bool) -> Self { + self.mouse = mouse; + self + } + + pub fn paste(mut self, paste: bool) -> Self { + self.paste = paste; + self + } + + pub fn start(&mut self) { + let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate); + let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate); + self.cancel(); + self.cancellation_token = CancellationToken::new(); + let _cancellation_token = self.cancellation_token.clone(); + let _event_tx = self.event_tx.clone(); + self.task = tokio::spawn(async move { + let mut reader = crossterm::event::EventStream::new(); + let mut tick_interval = tokio::time::interval(tick_delay); + let mut render_interval = tokio::time::interval(render_delay); + _event_tx.send(Event::Init).unwrap(); + loop { + let tick_delay = tick_interval.tick(); + let render_delay = render_interval.tick(); + let crossterm_event = reader.next().fuse(); + tokio::select! { + _ = _cancellation_token.cancelled() => { + break; + } + maybe_event = crossterm_event => { + match maybe_event { + Some(Ok(evt)) => { + match evt { + CrosstermEvent::Key(key) => { + if key.kind == KeyEventKind::Press { + _event_tx.send(Event::Key(key)).unwrap(); + } + }, + CrosstermEvent::Mouse(mouse) => { + _event_tx.send(Event::Mouse(mouse)).unwrap(); + }, + CrosstermEvent::Resize(x, y) => { + _event_tx.send(Event::Resize(x, y)).unwrap(); + }, + CrosstermEvent::FocusLost => { + _event_tx.send(Event::FocusLost).unwrap(); + }, + CrosstermEvent::FocusGained => { + _event_tx.send(Event::FocusGained).unwrap(); + }, + CrosstermEvent::Paste(s) => { + _event_tx.send(Event::Paste(s)).unwrap(); + }, + } + } + Some(Err(_)) => { + _event_tx.send(Event::Error).unwrap(); + } + None => {}, + } + }, + _ = tick_delay => { + _event_tx.send(Event::Tick).unwrap(); + }, + _ = render_delay => { + _event_tx.send(Event::Render).unwrap(); + }, + } + } + }); + } + + pub fn stop(&self) -> Result<()> { + self.cancel(); + let mut counter = 0; + while !self.task.is_finished() { + std::thread::sleep(Duration::from_millis(1)); + counter += 1; + if counter > 50 { + self.task.abort(); + } + if counter > 100 { + log::error!("Failed to abort task in 100 milliseconds for unknown reason"); + break; + } + } + Ok(()) + } + + pub fn enter(&mut self) -> Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(io(), EnterAlternateScreen, cursor::Hide)?; + if self.mouse { + crossterm::execute!(io(), EnableMouseCapture)?; + } + if self.paste { + crossterm::execute!(io(), EnableBracketedPaste)?; + } + self.start(); + Ok(()) + } + + pub fn exit(&mut self) -> Result<()> { + self.stop()?; + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + if self.paste { + crossterm::execute!(io(), DisableBracketedPaste)?; + } + if self.mouse { + crossterm::execute!(io(), DisableMouseCapture)?; + } + crossterm::execute!(io(), LeaveAlternateScreen, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } + + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + pub fn suspend(&mut self) -> Result<()> { + self.exit()?; + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; + Ok(()) + } + + pub fn resume(&mut self) -> Result<()> { + self.enter()?; + Ok(()) + } + + pub async fn next(&mut self) -> Option { + self.event_rx.recv().await + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} diff --git a/async/template/src/utils.rs b/async/template/src/utils.rs new file mode 100644 index 0000000..b1171e5 --- /dev/null +++ b/async/template/src/utils.rs @@ -0,0 +1,162 @@ +use std::path::PathBuf; + +use color_eyre::eyre::Result; +use directories::ProjectDirs; +use lazy_static::lazy_static; +use tracing::error; +use tracing_error::ErrorLayer; +use tracing_subscriber::{self, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, Layer}; + +pub static GIT_COMMIT_HASH: &'static str = env!("_GIT_INFO"); + +lazy_static! { + pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); + pub static ref DATA_FOLDER: Option = + std::env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from); + pub static ref CONFIG_FOLDER: Option = + std::env::var(format!("{}_CONFIG", PROJECT_NAME.clone())).ok().map(PathBuf::from); + pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", PROJECT_NAME.clone()); + pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); +} + +fn project_directory() -> Option { + ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME")) +} + +pub fn initialize_panic_handler() -> Result<()> { + let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() + .panic_section(format!("This is a bug. Consider reporting it at {}", env!("CARGO_PKG_REPOSITORY"))) + .capture_span_trace_by_default(false) + .display_location_section(false) + .display_env_section(false) + .into_hooks(); + eyre_hook.install()?; + std::panic::set_hook(Box::new(move |panic_info| { + if let Ok(mut t) = crate::tui::Tui::new() { + if let Err(r) = t.exit() { + error!("Unable to exit Terminal: {:?}", r); + } + } + + #[cfg(not(debug_assertions))] + { + use human_panic::{handle_dump, print_msg, Metadata}; + let meta = Metadata { + version: env!("CARGO_PKG_VERSION").into(), + name: env!("CARGO_PKG_NAME").into(), + authors: env!("CARGO_PKG_AUTHORS").replace(':', ", ").into(), + homepage: env!("CARGO_PKG_HOMEPAGE").into(), + }; + + let file_path = handle_dump(&meta, panic_info); + // prints human-panic message + print_msg(file_path, &meta).expect("human-panic: printing error message to console failed"); + eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr + } + let msg = format!("{}", panic_hook.panic_report(panic_info)); + log::error!("Error: {}", strip_ansi_escapes::strip_str(msg)); + + #[cfg(debug_assertions)] + { + // Better Panic stacktrace that is only enabled when debugging. + better_panic::Settings::auto() + .most_recent_first(false) + .lineno_suffix(true) + .verbosity(better_panic::Verbosity::Full) + .create_panic_handler()(panic_info); + } + + std::process::exit(libc::EXIT_FAILURE); + })); + Ok(()) +} + +pub fn get_data_dir() -> PathBuf { + let directory = if let Some(s) = DATA_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.data_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".data") + }; + directory +} + +pub fn get_config_dir() -> PathBuf { + let directory = if let Some(s) = CONFIG_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.config_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".config") + }; + directory +} + +pub fn initialize_logging() -> Result<()> { + let directory = get_data_dir(); + std::fs::create_dir_all(directory.clone())?; + let log_path = directory.join(LOG_FILE.clone()); + let log_file = std::fs::File::create(log_path)?; + std::env::set_var( + "RUST_LOG", + std::env::var("RUST_LOG") + .or_else(|_| std::env::var(LOG_ENV.clone())) + .unwrap_or_else(|_| format!("{}=info", env!("CARGO_CRATE_NAME"))), + ); + let file_subscriber = tracing_subscriber::fmt::layer() + .with_file(true) + .with_line_number(true) + .with_writer(log_file) + .with_target(false) + .with_ansi(false) + .with_filter(tracing_subscriber::filter::EnvFilter::from_default_env()); + tracing_subscriber::registry().with(file_subscriber).with(ErrorLayer::default()).init(); + Ok(()) +} + +/// Similar to the `std::dbg!` macro, but generates `tracing` events rather +/// than printing to stdout. +/// +/// By default, the verbosity level for the generated events is `DEBUG`, but +/// this can be customized. +#[macro_export] +macro_rules! trace_dbg { + (target: $target:expr, level: $level:expr, $ex:expr) => {{ + match $ex { + value => { + tracing::event!(target: $target, $level, ?value, stringify!($ex)); + value + } + } + }}; + (level: $level:expr, $ex:expr) => { + trace_dbg!(target: module_path!(), level: $level, $ex) + }; + (target: $target:expr, $ex:expr) => { + trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) + }; + ($ex:expr) => { + trace_dbg!(level: tracing::Level::DEBUG, $ex) + }; +} + +pub fn version() -> String { + let author = clap::crate_authors!(); + + let commit_hash = GIT_COMMIT_HASH.clone(); + + // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string(); + let config_dir_path = get_config_dir().display().to_string(); + let data_dir_path = get_data_dir().display().to_string(); + + format!( + "\ +{commit_hash} + +Authors: {author} + +Config directory: {config_dir_path} +Data directory: {data_dir_path}" + ) +} diff --git a/cargo-generate.toml b/cargo-generate.toml index 538b785..fa2305b 100644 --- a/cargo-generate.toml +++ b/cargo-generate.toml @@ -1,4 +1,4 @@ # configuration for https://cargo-generate.github.io/cargo-generate/ [template] -sub_templates = ["simple"] +sub_templates = ["simple", "async"]