Vendor ratatui temporarily (#835)
* Vendor ratatui temporarily Once https://github.com/tui-rs-revival/ratatui/pull/114 has been merged, we can undo this! But otherwise we can't publish to crates.io with a git dependency. * make tests pass * Shush. * these literally just fail in nix, nowhere else idk how to work with nix properly, and they're also not our tests
This commit is contained in:
parent
3552c7e0d3
commit
a515b06bcb
41 changed files with 14697 additions and 21 deletions
16
Cargo.lock
generated
16
Cargo.lock
generated
|
@ -77,6 +77,8 @@ dependencies = [
|
||||||
"atuin-common",
|
"atuin-common",
|
||||||
"atuin-server",
|
"atuin-server",
|
||||||
"base64 0.20.0",
|
"base64 0.20.0",
|
||||||
|
"bitflags",
|
||||||
|
"cassowary",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
|
@ -93,7 +95,6 @@ dependencies = [
|
||||||
"interim",
|
"interim",
|
||||||
"itertools",
|
"itertools",
|
||||||
"log",
|
"log",
|
||||||
"ratatui",
|
|
||||||
"rpassword",
|
"rpassword",
|
||||||
"runtime-format",
|
"runtime-format",
|
||||||
"semver",
|
"semver",
|
||||||
|
@ -102,6 +103,7 @@ dependencies = [
|
||||||
"tiny-bip39",
|
"tiny-bip39",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"unicode-segmentation",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
|
@ -1583,18 +1585,6 @@ dependencies = [
|
||||||
"getrandom",
|
"getrandom",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ratatui"
|
|
||||||
version = "0.20.1"
|
|
||||||
source = "git+https://github.com/conradludgate/tui-rs-revival?branch=inline#6ed61959ecfc560e4e6a00a1410bb5fcbf0eda91"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"cassowary",
|
|
||||||
"crossterm",
|
|
||||||
"unicode-segmentation",
|
|
||||||
"unicode-width",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
|
|
|
@ -73,15 +73,16 @@ semver = "1.0.14"
|
||||||
runtime-format = "0.1.2"
|
runtime-format = "0.1.2"
|
||||||
tiny-bip39 = "1"
|
tiny-bip39 = "1"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
ratatui = "0.20.1"
|
|
||||||
fuzzy-matcher = "0.3.7"
|
fuzzy-matcher = "0.3.7"
|
||||||
colored = "2.0.0"
|
colored = "2.0.0"
|
||||||
|
|
||||||
|
# ratatui
|
||||||
|
bitflags = "1.3"
|
||||||
|
cassowary = "0.3"
|
||||||
|
unicode-segmentation = "1.2"
|
||||||
|
|
||||||
[dependencies.tracing-subscriber]
|
[dependencies.tracing-subscriber]
|
||||||
version = "0.3"
|
version = "0.3"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["ansi", "fmt", "registry", "env-filter"]
|
features = ["ansi", "fmt", "registry", "env-filter"]
|
||||||
optional = true
|
optional = true
|
||||||
|
|
||||||
[patch.crates-io]
|
|
||||||
ratatui = { git = "https://github.com/conradludgate/tui-rs-revival", branch = "inline" }
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use atuin_client::history::History;
|
use crate::ratatui::{
|
||||||
use ratatui::{
|
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
widgets::{Block, StatefulWidget, Widget},
|
widgets::{Block, StatefulWidget, Widget},
|
||||||
};
|
};
|
||||||
|
use atuin_client::history::History;
|
||||||
|
|
||||||
use super::format_duration;
|
use super::format_duration;
|
||||||
|
|
||||||
|
|
|
@ -23,8 +23,7 @@ use super::{
|
||||||
engines::{SearchEngine, SearchState},
|
engines::{SearchEngine, SearchState},
|
||||||
history_list::{HistoryList, ListState, PREFIX_LENGTH},
|
history_list::{HistoryList, ListState, PREFIX_LENGTH},
|
||||||
};
|
};
|
||||||
use crate::{command::client::search::engines, VERSION};
|
use crate::ratatui::{
|
||||||
use ratatui::{
|
|
||||||
backend::{Backend, CrosstermBackend},
|
backend::{Backend, CrosstermBackend},
|
||||||
layout::{Alignment, Constraint, Direction, Layout},
|
layout::{Alignment, Constraint, Direction, Layout},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
|
@ -32,6 +31,7 @@ use ratatui::{
|
||||||
widgets::{Block, BorderType, Borders, Paragraph},
|
widgets::{Block, BorderType, Borders, Paragraph},
|
||||||
Frame, Terminal, TerminalOptions, Viewport,
|
Frame, Terminal, TerminalOptions, Viewport,
|
||||||
};
|
};
|
||||||
|
use crate::{command::client::search::engines, VERSION};
|
||||||
|
|
||||||
const RETURN_ORIGINAL: usize = usize::MAX;
|
const RETURN_ORIGINAL: usize = usize::MAX;
|
||||||
const RETURN_QUERY: usize = usize::MAX - 1;
|
const RETURN_QUERY: usize = usize::MAX - 1;
|
||||||
|
|
|
@ -7,6 +7,9 @@ use eyre::Result;
|
||||||
use command::AtuinCmd;
|
use command::AtuinCmd;
|
||||||
mod command;
|
mod command;
|
||||||
|
|
||||||
|
#[allow(clippy::all)]
|
||||||
|
mod ratatui;
|
||||||
|
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
static HELP_TEMPLATE: &str = "\
|
static HELP_TEMPLATE: &str = "\
|
||||||
|
|
60
src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
60
src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create an issue about a bug you encountered
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Hi there, sorry `ratatui` is not working as expected.
|
||||||
|
Please fill this bug report conscientiously.
|
||||||
|
A detailed and complete issue is more likely to be processed quickly.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
<!--
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
## To Reproduce
|
||||||
|
<!--
|
||||||
|
Try to reduce the issue to a simple code sample exhibiting the problem.
|
||||||
|
Ideally, fork the project and add a test or an example.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
## Expected behavior
|
||||||
|
<!--
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
<!--
|
||||||
|
If applicable, add screenshots, gifs or videos to help explain your problem.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
<!--
|
||||||
|
Add a description of the systems where you are observing the issue. For example:
|
||||||
|
- OS: Linux
|
||||||
|
- Terminal Emulator: xterm
|
||||||
|
- Font: Inconsolata (Patched)
|
||||||
|
- Crate version: 0.7
|
||||||
|
- Backend: termion
|
||||||
|
-->
|
||||||
|
|
||||||
|
- OS:
|
||||||
|
- Terminal Emulator:
|
||||||
|
- Font:
|
||||||
|
- Crate version:
|
||||||
|
- Backend:
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
<!--
|
||||||
|
Add any other context about the problem here.
|
||||||
|
If you already looked into the issue, include all the leads you have explored.
|
||||||
|
-->
|
1
src/ratatui/.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
src/ratatui/.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
blank_issues_enabled: false
|
32
src/ratatui/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
32
src/ratatui/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
<!--
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
<!--
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
Things to consider:
|
||||||
|
- backward compatibility
|
||||||
|
- ease of use of the API (https://rust-lang.github.io/api-guidelines/)
|
||||||
|
- consistency with the rest of the crate
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
<!--
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
<!--
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
|
-->
|
19
src/ratatui/.github/workflows/cd.yml
vendored
Normal file
19
src/ratatui/.github/workflows/cd.yml
vendored
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
name: Continuous Deployment
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Publish on crates.io
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout the repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Publish
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: publish
|
||||||
|
args: --token ${{ secrets.CARGO_TOKEN }}
|
76
src/ratatui/.github/workflows/ci.yml
vendored
Normal file
76
src/ratatui/.github/workflows/ci.yml
vendored
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
env:
|
||||||
|
CI_CARGO_MAKE_VERSION: 0.35.16
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
rust: ["1.59.0", "stable"]
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
triple: x86_64-unknown-linux-musl
|
||||||
|
- os: windows-latest
|
||||||
|
triple: x86_64-pc-windows-msvc
|
||||||
|
- os: macos-latest
|
||||||
|
triple: x86_64-apple-darwin
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: hecrj/setup-rust-action@50a120e4d34903c2c1383dec0e9b1d349a9cc2b1
|
||||||
|
with:
|
||||||
|
rust-version: ${{ matrix.rust }}
|
||||||
|
components: rustfmt,clippy
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Install cargo-make on Linux or macOS
|
||||||
|
if: ${{ runner.os != 'windows' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
curl -LO 'https://github.com/sagiegurari/cargo-make/releases/download/${{ env.CI_CARGO_MAKE_VERSION }}/cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip'
|
||||||
|
unzip 'cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip'
|
||||||
|
cp 'cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}/cargo-make' ~/.cargo/bin/
|
||||||
|
cargo make --version
|
||||||
|
- name: Install cargo-make on Windows
|
||||||
|
if: ${{ runner.os == 'windows' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# `cargo-make-v0.35.16-{target}/` directory is created on Linux and macOS, but it is not creatd on Windows.
|
||||||
|
mkdir cargo-make-temporary
|
||||||
|
cd cargo-make-temporary
|
||||||
|
curl -LO 'https://github.com/sagiegurari/cargo-make/releases/download/${{ env.CI_CARGO_MAKE_VERSION }}/cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip'
|
||||||
|
unzip 'cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip'
|
||||||
|
cp cargo-make.exe ~/.cargo/bin/
|
||||||
|
cd ..
|
||||||
|
cargo make --version
|
||||||
|
- name: "Format / Build / Test"
|
||||||
|
run: cargo make ci
|
||||||
|
env:
|
||||||
|
RUST_BACKTRACE: full
|
||||||
|
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Checkout
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
- name: "Check conventional commits"
|
||||||
|
uses: crate-ci/committed@master
|
||||||
|
with:
|
||||||
|
args: "-vv"
|
||||||
|
commits: "HEAD"
|
||||||
|
- name: "Check typos"
|
||||||
|
uses: crate-ci/typos@master
|
6
src/ratatui/.gitignore
vendored
Normal file
6
src/ratatui/.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
target
|
||||||
|
Cargo.lock
|
||||||
|
*.log
|
||||||
|
*.rs.rustfmt
|
||||||
|
.gdb_history
|
||||||
|
.idea/
|
21
src/ratatui/LICENSE
Normal file
21
src/ratatui/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2016 Florian Dehau
|
||||||
|
|
||||||
|
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.
|
136
src/ratatui/README.md
Normal file
136
src/ratatui/README.md
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
# ratatui
|
||||||
|
|
||||||
|
An actively maintained `tui`-rs fork.
|
||||||
|
|
||||||
|
[![Build Status](https://github.com/tui-rs-revival/ratatui/workflows/CI/badge.svg)](https://github.com/tui-rs-revival/ratatui/actions?query=workflow%3ACI+)
|
||||||
|
[![Crate Status](https://img.shields.io/crates/v/ratatui.svg)](https://crates.io/crates/ratatui)
|
||||||
|
[![Docs Status](https://docs.rs/ratatui/badge.svg)](https://docs.rs/crate/ratatui/)
|
||||||
|
|
||||||
|
<img src="./assets/demo.gif" alt="Demo cast under Linux Termite with Inconsolata font 12pt">
|
||||||
|
|
||||||
|
# Install
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
tui = { package = "ratatui" }
|
||||||
|
```
|
||||||
|
|
||||||
|
# What is this fork?
|
||||||
|
|
||||||
|
This fork was created to continue maintenance on the original TUI project. The original maintainer had created an [issue](https://github.com/fdehau/tui-rs/issues/654) explaining how he couldn't find time to continue development, which led to us creating this fork.
|
||||||
|
|
||||||
|
With that in mind, **we the community** look forward to continuing the work started by [**Florian Dehau.**](https://github.com/fdehau) :rocket:
|
||||||
|
|
||||||
|
In order to organize ourselves, we currently use a [discord server](https://discord.gg/pMCEU9hNEj), feel free to join and come chat ! There are also plans to implement a [matrix](https://matrix.org/) bridge in the near future.
|
||||||
|
**Discord is not a MUST to contribute,** we follow a pretty standard github centered open source workflow keeping the most important conversations on github, open an issue or PR and it will be addressed. :smile:
|
||||||
|
|
||||||
|
Please make sure you read the updated contributing guidelines, especially if you are interested in working on a PR or issue opened in the previous repository.
|
||||||
|
|
||||||
|
# Introduction
|
||||||
|
|
||||||
|
`ratatui`-rs is a [Rust](https://www.rust-lang.org) library to build rich terminal
|
||||||
|
user interfaces and dashboards. It is heavily inspired by the `Javascript`
|
||||||
|
library [blessed-contrib](https://github.com/yaronn/blessed-contrib) and the
|
||||||
|
`Go` library [termui](https://github.com/gizak/termui).
|
||||||
|
|
||||||
|
The library supports multiple backends:
|
||||||
|
|
||||||
|
- [crossterm](https://github.com/crossterm-rs/crossterm) [default]
|
||||||
|
- [termion](https://github.com/ticki/termion)
|
||||||
|
|
||||||
|
The library is based on the principle of immediate rendering with intermediate
|
||||||
|
buffers. This means that at each new frame you should build all widgets that are
|
||||||
|
supposed to be part of the UI. While providing a great flexibility for rich and
|
||||||
|
interactive UI, this may introduce overhead for highly dynamic content. So, the
|
||||||
|
implementation try to minimize the number of ansi escapes sequences generated to
|
||||||
|
draw the updated UI. In practice, given the speed of `Rust` the overhead rather
|
||||||
|
comes from the terminal emulator than the library itself.
|
||||||
|
|
||||||
|
Moreover, the library does not provide any input handling nor any event system and
|
||||||
|
you may rely on the previously cited libraries to achieve such features.
|
||||||
|
|
||||||
|
## Rust version requirements
|
||||||
|
|
||||||
|
Since version 0.17.0, `ratatui` requires **rustc version 1.59.0 or greater**.
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
|
||||||
|
The documentation can be found on [docs.rs.](https://docs.rs/ratatui)
|
||||||
|
|
||||||
|
# Demo
|
||||||
|
|
||||||
|
The demo shown in the gif can be run with all available backends.
|
||||||
|
|
||||||
|
```
|
||||||
|
# crossterm
|
||||||
|
cargo run --example demo --release -- --tick-rate 200
|
||||||
|
# termion
|
||||||
|
cargo run --example demo --no-default-features --features=termion --release -- --tick-rate 200
|
||||||
|
```
|
||||||
|
|
||||||
|
where `tick-rate` is the UI refresh rate in ms.
|
||||||
|
|
||||||
|
The UI code is in [examples/demo/ui.rs](https://github.com/tui-rs-revival/ratatui/blob/main/examples/demo/ui.rs) while the
|
||||||
|
application state is in [examples/demo/app.rs](https://github.com/tui-rs-revival/ratatui/blob/main/examples/demo/app.rs).
|
||||||
|
|
||||||
|
If the user interface contains glyphs that are not displayed correctly by your terminal, you may want to run
|
||||||
|
the demo without those symbols:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo run --example demo --release -- --tick-rate 200 --enhanced-graphics false
|
||||||
|
```
|
||||||
|
|
||||||
|
# Widgets
|
||||||
|
|
||||||
|
## Built in
|
||||||
|
|
||||||
|
The library comes with the following list of widgets:
|
||||||
|
|
||||||
|
- [Block](https://github.com/tui-rs-revival/ratatui/blob/main/examples/block.rs)
|
||||||
|
- [Gauge](https://github.com/tui-rs-revival/ratatui/blob/main/examples/gauge.rs)
|
||||||
|
- [Sparkline](https://github.com/tui-rs-revival/ratatui/blob/main/examples/sparkline.rs)
|
||||||
|
- [Chart](https://github.com/tui-rs-revival/ratatui/blob/main/examples/chart.rs)
|
||||||
|
- [BarChart](https://github.com/tui-rs-revival/ratatui/blob/main/examples/barchart.rs)
|
||||||
|
- [List](https://github.com/tui-rs-revival/ratatui/blob/main/examples/list.rs)
|
||||||
|
- [Table](https://github.com/tui-rs-revival/ratatui/blob/main/examples/table.rs)
|
||||||
|
- [Paragraph](https://github.com/tui-rs-revival/ratatui/blob/main/examples/paragraph.rs)
|
||||||
|
- [Canvas (with line, point cloud, map)](https://github.com/tui-rs-revival/ratatui/blob/main/examples/canvas.rs)
|
||||||
|
- [Tabs](https://github.com/tui-rs-revival/ratatui/blob/main/examples/tabs.rs)
|
||||||
|
|
||||||
|
Click on each item to see the source of the example. Run the examples with with
|
||||||
|
cargo (e.g. to run the gauge example `cargo run --example gauge`), and quit by pressing `q`.
|
||||||
|
|
||||||
|
You can run all examples by running `cargo make run-examples` (require
|
||||||
|
`cargo-make` that can be installed with `cargo install cargo-make`).
|
||||||
|
|
||||||
|
### Third-party libraries, bootstrapping templates and widgets
|
||||||
|
|
||||||
|
- [ansi-to-tui](https://github.com/uttarayan21/ansi-to-tui) — Convert ansi colored text to `tui::text::Text`
|
||||||
|
- [color-to-tui](https://github.com/uttarayan21/color-to-tui) — Parse hex colors to `tui::style::Color`
|
||||||
|
- [rust-tui-template](https://github.com/orhun/rust-tui-template) — A template for bootstrapping a Rust TUI application with Tui-rs & crossterm
|
||||||
|
- [simple-tui-rs](https://github.com/pmsanford/simple-tui-rs) — A simple example tui-rs app
|
||||||
|
- [tui-builder](https://github.com/jkelleyrtp/tui-builder) — Batteries-included MVC framework for Tui-rs + Crossterm apps
|
||||||
|
- [tui-clap](https://github.com/kegesch/tui-clap-rs) — Use clap-rs together with Tui-rs
|
||||||
|
- [tui-log](https://github.com/kegesch/tui-log-rs) — Example of how to use logging with Tui-rs
|
||||||
|
- [tui-logger](https://github.com/gin66/tui-logger) — Logger and Widget for Tui-rs
|
||||||
|
- [tui-realm](https://github.com/veeso/tui-realm) — Tui-rs framework to build stateful applications with a React/Elm inspired approach
|
||||||
|
- [tui-realm-treeview](https://github.com/veeso/tui-realm-treeview) — Treeview component for Tui-realm
|
||||||
|
- [tui tree widget](https://github.com/EdJoPaTo/tui-rs-tree-widget) — Tree Widget for Tui-rs
|
||||||
|
- [tui-windows](https://github.com/markatk/tui-windows-rs) — Tui-rs abstraction to handle multiple windows and their rendering
|
||||||
|
- [tui-textarea](https://github.com/rhysd/tui-textarea): Simple yet powerful multi-line text editor widget supporting several key shortcuts, undo/redo, text search, etc.
|
||||||
|
- [tui-rs-tree-widgets](https://github.com/EdJoPaTo/tui-rs-tree-widget): Widget for tree data structures.
|
||||||
|
- [tui-input](https://github.com/sayanarijit/tui-input): TUI input library supporting multiple backends and tui-rs.
|
||||||
|
|
||||||
|
# Apps
|
||||||
|
|
||||||
|
Check out the list of [close to 40 apps](./APPS.md) using `ratatui`!
|
||||||
|
|
||||||
|
# Alternatives
|
||||||
|
|
||||||
|
You might want to checkout [Cursive](https://github.com/gyscos/Cursive) for an
|
||||||
|
alternative solution to build text user interfaces in Rust.
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
|
|
241
src/ratatui/backend/crossterm.rs
Normal file
241
src/ratatui/backend/crossterm.rs
Normal file
|
@ -0,0 +1,241 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
backend::{Backend, ClearType},
|
||||||
|
buffer::Cell,
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Modifier},
|
||||||
|
};
|
||||||
|
use crossterm::{
|
||||||
|
cursor::{Hide, MoveTo, Show},
|
||||||
|
execute, queue,
|
||||||
|
style::{
|
||||||
|
Attribute as CAttribute, Color as CColor, Print, SetAttribute, SetBackgroundColor,
|
||||||
|
SetForegroundColor,
|
||||||
|
},
|
||||||
|
terminal::{self, Clear},
|
||||||
|
};
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
pub struct CrosstermBackend<W: Write> {
|
||||||
|
buffer: W,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W> CrosstermBackend<W>
|
||||||
|
where
|
||||||
|
W: Write,
|
||||||
|
{
|
||||||
|
pub fn new(buffer: W) -> CrosstermBackend<W> {
|
||||||
|
CrosstermBackend { buffer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W> Write for CrosstermBackend<W>
|
||||||
|
where
|
||||||
|
W: Write,
|
||||||
|
{
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
self.buffer.write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
self.buffer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W> Backend for CrosstermBackend<W>
|
||||||
|
where
|
||||||
|
W: Write,
|
||||||
|
{
|
||||||
|
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
|
||||||
|
where
|
||||||
|
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||||
|
{
|
||||||
|
let mut fg = Color::Reset;
|
||||||
|
let mut bg = Color::Reset;
|
||||||
|
let mut modifier = Modifier::empty();
|
||||||
|
let mut last_pos: Option<(u16, u16)> = None;
|
||||||
|
for (x, y, cell) in content {
|
||||||
|
// Move the cursor if the previous location was not (x - 1, y)
|
||||||
|
if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) {
|
||||||
|
map_error(queue!(self.buffer, MoveTo(x, y)))?;
|
||||||
|
}
|
||||||
|
last_pos = Some((x, y));
|
||||||
|
if cell.modifier != modifier {
|
||||||
|
let diff = ModifierDiff {
|
||||||
|
from: modifier,
|
||||||
|
to: cell.modifier,
|
||||||
|
};
|
||||||
|
diff.queue(&mut self.buffer)?;
|
||||||
|
modifier = cell.modifier;
|
||||||
|
}
|
||||||
|
if cell.fg != fg {
|
||||||
|
let color = CColor::from(cell.fg);
|
||||||
|
map_error(queue!(self.buffer, SetForegroundColor(color)))?;
|
||||||
|
fg = cell.fg;
|
||||||
|
}
|
||||||
|
if cell.bg != bg {
|
||||||
|
let color = CColor::from(cell.bg);
|
||||||
|
map_error(queue!(self.buffer, SetBackgroundColor(color)))?;
|
||||||
|
bg = cell.bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
map_error(queue!(self.buffer, Print(&cell.symbol)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
map_error(queue!(
|
||||||
|
self.buffer,
|
||||||
|
SetForegroundColor(CColor::Reset),
|
||||||
|
SetBackgroundColor(CColor::Reset),
|
||||||
|
SetAttribute(CAttribute::Reset)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_cursor(&mut self) -> io::Result<()> {
|
||||||
|
map_error(execute!(self.buffer, Hide))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_cursor(&mut self) -> io::Result<()> {
|
||||||
|
map_error(execute!(self.buffer, Show))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
|
||||||
|
crossterm::cursor::position()
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
|
||||||
|
map_error(execute!(self.buffer, MoveTo(x, y)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear(&mut self) -> io::Result<()> {
|
||||||
|
self.clear_region(ClearType::All)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
|
||||||
|
map_error(execute!(
|
||||||
|
self.buffer,
|
||||||
|
Clear(match clear_type {
|
||||||
|
ClearType::All => crossterm::terminal::ClearType::All,
|
||||||
|
ClearType::AfterCursor => crossterm::terminal::ClearType::FromCursorDown,
|
||||||
|
ClearType::BeforeCursor => crossterm::terminal::ClearType::FromCursorUp,
|
||||||
|
ClearType::CurrentLine => crossterm::terminal::ClearType::CurrentLine,
|
||||||
|
ClearType::UntilNewLine => crossterm::terminal::ClearType::UntilNewLine,
|
||||||
|
})
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_lines(&mut self, n: u16) -> io::Result<()> {
|
||||||
|
for _ in 0..n {
|
||||||
|
map_error(queue!(self.buffer, Print("\n")))?;
|
||||||
|
}
|
||||||
|
self.buffer.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(&self) -> io::Result<Rect> {
|
||||||
|
let (width, height) =
|
||||||
|
terminal::size().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Rect::new(0, 0, width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
self.buffer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_error(error: crossterm::Result<()>) -> io::Result<()> {
|
||||||
|
error.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Color> for CColor {
|
||||||
|
fn from(color: Color) -> Self {
|
||||||
|
match color {
|
||||||
|
Color::Reset => CColor::Reset,
|
||||||
|
Color::Black => CColor::Black,
|
||||||
|
Color::Red => CColor::DarkRed,
|
||||||
|
Color::Green => CColor::DarkGreen,
|
||||||
|
Color::Yellow => CColor::DarkYellow,
|
||||||
|
Color::Blue => CColor::DarkBlue,
|
||||||
|
Color::Magenta => CColor::DarkMagenta,
|
||||||
|
Color::Cyan => CColor::DarkCyan,
|
||||||
|
Color::Gray => CColor::Grey,
|
||||||
|
Color::DarkGray => CColor::DarkGrey,
|
||||||
|
Color::LightRed => CColor::Red,
|
||||||
|
Color::LightGreen => CColor::Green,
|
||||||
|
Color::LightBlue => CColor::Blue,
|
||||||
|
Color::LightYellow => CColor::Yellow,
|
||||||
|
Color::LightMagenta => CColor::Magenta,
|
||||||
|
Color::LightCyan => CColor::Cyan,
|
||||||
|
Color::White => CColor::White,
|
||||||
|
Color::Indexed(i) => CColor::AnsiValue(i),
|
||||||
|
Color::Rgb(r, g, b) => CColor::Rgb { r, g, b },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ModifierDiff {
|
||||||
|
pub from: Modifier,
|
||||||
|
pub to: Modifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModifierDiff {
|
||||||
|
fn queue<W>(&self, mut w: W) -> io::Result<()>
|
||||||
|
where
|
||||||
|
W: io::Write,
|
||||||
|
{
|
||||||
|
//use crossterm::Attribute;
|
||||||
|
let removed = self.from - self.to;
|
||||||
|
if removed.contains(Modifier::REVERSED) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::NoReverse)))?;
|
||||||
|
}
|
||||||
|
if removed.contains(Modifier::BOLD) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?;
|
||||||
|
if self.to.contains(Modifier::DIM) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::Dim)))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removed.contains(Modifier::ITALIC) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::NoItalic)))?;
|
||||||
|
}
|
||||||
|
if removed.contains(Modifier::UNDERLINED) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::NoUnderline)))?;
|
||||||
|
}
|
||||||
|
if removed.contains(Modifier::DIM) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?;
|
||||||
|
}
|
||||||
|
if removed.contains(Modifier::CROSSED_OUT) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::NotCrossedOut)))?;
|
||||||
|
}
|
||||||
|
if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::NoBlink)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let added = self.to - self.from;
|
||||||
|
if added.contains(Modifier::REVERSED) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::Reverse)))?;
|
||||||
|
}
|
||||||
|
if added.contains(Modifier::BOLD) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::Bold)))?;
|
||||||
|
}
|
||||||
|
if added.contains(Modifier::ITALIC) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::Italic)))?;
|
||||||
|
}
|
||||||
|
if added.contains(Modifier::UNDERLINED) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::Underlined)))?;
|
||||||
|
}
|
||||||
|
if added.contains(Modifier::DIM) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::Dim)))?;
|
||||||
|
}
|
||||||
|
if added.contains(Modifier::CROSSED_OUT) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::CrossedOut)))?;
|
||||||
|
}
|
||||||
|
if added.contains(Modifier::SLOW_BLINK) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::SlowBlink)))?;
|
||||||
|
}
|
||||||
|
if added.contains(Modifier::RAPID_BLINK) {
|
||||||
|
map_error(queue!(w, SetAttribute(CAttribute::RapidBlink)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
58
src/ratatui/backend/mod.rs
Normal file
58
src/ratatui/backend/mod.rs
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use crate::ratatui::buffer::Cell;
|
||||||
|
use crate::ratatui::layout::Rect;
|
||||||
|
|
||||||
|
#[cfg(feature = "termion")]
|
||||||
|
mod termion;
|
||||||
|
#[cfg(feature = "termion")]
|
||||||
|
pub use self::termion::TermionBackend;
|
||||||
|
|
||||||
|
mod crossterm;
|
||||||
|
pub use self::crossterm::CrosstermBackend;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum ClearType {
|
||||||
|
All,
|
||||||
|
AfterCursor,
|
||||||
|
BeforeCursor,
|
||||||
|
CurrentLine,
|
||||||
|
UntilNewLine,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Backend {
|
||||||
|
fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error>
|
||||||
|
where
|
||||||
|
I: Iterator<Item = (u16, u16, &'a Cell)>;
|
||||||
|
|
||||||
|
/// Insert `n` line breaks to the terminal screen
|
||||||
|
fn append_lines(&mut self, n: u16) -> io::Result<()> {
|
||||||
|
// to get around the unused warning
|
||||||
|
let _n = n;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_cursor(&mut self) -> Result<(), io::Error>;
|
||||||
|
fn show_cursor(&mut self) -> Result<(), io::Error>;
|
||||||
|
fn get_cursor(&mut self) -> Result<(u16, u16), io::Error>;
|
||||||
|
fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error>;
|
||||||
|
|
||||||
|
/// Clears the whole terminal screen
|
||||||
|
fn clear(&mut self) -> Result<(), io::Error>;
|
||||||
|
|
||||||
|
/// Clears a specific region of the terminal specified by the [`ClearType`] parameter
|
||||||
|
fn clear_region(&mut self, clear_type: ClearType) -> Result<(), io::Error> {
|
||||||
|
match clear_type {
|
||||||
|
ClearType::All => self.clear(),
|
||||||
|
ClearType::AfterCursor
|
||||||
|
| ClearType::BeforeCursor
|
||||||
|
| ClearType::CurrentLine
|
||||||
|
| ClearType::UntilNewLine => Err(io::Error::new(
|
||||||
|
io::ErrorKind::Other,
|
||||||
|
format!("clear_type [{clear_type:?}] not supported with this backend"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn size(&self) -> Result<Rect, io::Error>;
|
||||||
|
fn flush(&mut self) -> Result<(), io::Error>;
|
||||||
|
}
|
275
src/ratatui/backend/termion.rs
Normal file
275
src/ratatui/backend/termion.rs
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
use crate::{
|
||||||
|
backend::{Backend, ClearType},
|
||||||
|
buffer::Cell,
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Modifier},
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
fmt,
|
||||||
|
io::{self, Write},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct TermionBackend<W>
|
||||||
|
where
|
||||||
|
W: Write,
|
||||||
|
{
|
||||||
|
stdout: W,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W> TermionBackend<W>
|
||||||
|
where
|
||||||
|
W: Write,
|
||||||
|
{
|
||||||
|
pub fn new(stdout: W) -> TermionBackend<W> {
|
||||||
|
TermionBackend { stdout }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W> Write for TermionBackend<W>
|
||||||
|
where
|
||||||
|
W: Write,
|
||||||
|
{
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
self.stdout.write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
self.stdout.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W> Backend for TermionBackend<W>
|
||||||
|
where
|
||||||
|
W: Write,
|
||||||
|
{
|
||||||
|
fn clear(&mut self) -> io::Result<()> {
|
||||||
|
self.clear_region(ClearType::All)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
|
||||||
|
match clear_type {
|
||||||
|
ClearType::All => write!(self.stdout, "{}", termion::clear::All)?,
|
||||||
|
ClearType::AfterCursor => write!(self.stdout, "{}", termion::clear::AfterCursor)?,
|
||||||
|
ClearType::BeforeCursor => write!(self.stdout, "{}", termion::clear::BeforeCursor)?,
|
||||||
|
ClearType::CurrentLine => write!(self.stdout, "{}", termion::clear::CurrentLine)?,
|
||||||
|
ClearType::UntilNewLine => write!(self.stdout, "{}", termion::clear::UntilNewline)?,
|
||||||
|
};
|
||||||
|
self.stdout.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_lines(&mut self, n: u16) -> io::Result<()> {
|
||||||
|
for _ in 0..n {
|
||||||
|
writeln!(self.stdout)?;
|
||||||
|
}
|
||||||
|
self.stdout.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides cursor
|
||||||
|
fn hide_cursor(&mut self) -> io::Result<()> {
|
||||||
|
write!(self.stdout, "{}", termion::cursor::Hide)?;
|
||||||
|
self.stdout.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows cursor
|
||||||
|
fn show_cursor(&mut self) -> io::Result<()> {
|
||||||
|
write!(self.stdout, "{}", termion::cursor::Show)?;
|
||||||
|
self.stdout.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets cursor position (0-based index)
|
||||||
|
fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
|
||||||
|
termion::cursor::DetectCursorPos::cursor_pos(&mut self.stdout).map(|(x, y)| (x - 1, y - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets cursor position (0-based index)
|
||||||
|
fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
|
||||||
|
write!(self.stdout, "{}", termion::cursor::Goto(x + 1, y + 1))?;
|
||||||
|
self.stdout.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
|
||||||
|
where
|
||||||
|
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||||
|
{
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
let mut string = String::with_capacity(content.size_hint().0 * 3);
|
||||||
|
let mut fg = Color::Reset;
|
||||||
|
let mut bg = Color::Reset;
|
||||||
|
let mut modifier = Modifier::empty();
|
||||||
|
let mut last_pos: Option<(u16, u16)> = None;
|
||||||
|
for (x, y, cell) in content {
|
||||||
|
// Move the cursor if the previous location was not (x - 1, y)
|
||||||
|
if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) {
|
||||||
|
write!(string, "{}", termion::cursor::Goto(x + 1, y + 1)).unwrap();
|
||||||
|
}
|
||||||
|
last_pos = Some((x, y));
|
||||||
|
if cell.modifier != modifier {
|
||||||
|
write!(
|
||||||
|
string,
|
||||||
|
"{}",
|
||||||
|
ModifierDiff {
|
||||||
|
from: modifier,
|
||||||
|
to: cell.modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
modifier = cell.modifier;
|
||||||
|
}
|
||||||
|
if cell.fg != fg {
|
||||||
|
write!(string, "{}", Fg(cell.fg)).unwrap();
|
||||||
|
fg = cell.fg;
|
||||||
|
}
|
||||||
|
if cell.bg != bg {
|
||||||
|
write!(string, "{}", Bg(cell.bg)).unwrap();
|
||||||
|
bg = cell.bg;
|
||||||
|
}
|
||||||
|
string.push_str(&cell.symbol);
|
||||||
|
}
|
||||||
|
write!(
|
||||||
|
self.stdout,
|
||||||
|
"{}{}{}{}",
|
||||||
|
string,
|
||||||
|
Fg(Color::Reset),
|
||||||
|
Bg(Color::Reset),
|
||||||
|
termion::style::Reset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the size of the terminal
|
||||||
|
fn size(&self) -> io::Result<Rect> {
|
||||||
|
let terminal = termion::terminal_size()?;
|
||||||
|
Ok(Rect::new(0, 0, terminal.0, terminal.1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
self.stdout.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Fg(Color);
|
||||||
|
|
||||||
|
struct Bg(Color);
|
||||||
|
|
||||||
|
struct ModifierDiff {
|
||||||
|
from: Modifier,
|
||||||
|
to: Modifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Fg {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
use termion::color::Color as TermionColor;
|
||||||
|
match self.0 {
|
||||||
|
Color::Reset => termion::color::Reset.write_fg(f),
|
||||||
|
Color::Black => termion::color::Black.write_fg(f),
|
||||||
|
Color::Red => termion::color::Red.write_fg(f),
|
||||||
|
Color::Green => termion::color::Green.write_fg(f),
|
||||||
|
Color::Yellow => termion::color::Yellow.write_fg(f),
|
||||||
|
Color::Blue => termion::color::Blue.write_fg(f),
|
||||||
|
Color::Magenta => termion::color::Magenta.write_fg(f),
|
||||||
|
Color::Cyan => termion::color::Cyan.write_fg(f),
|
||||||
|
Color::Gray => termion::color::White.write_fg(f),
|
||||||
|
Color::DarkGray => termion::color::LightBlack.write_fg(f),
|
||||||
|
Color::LightRed => termion::color::LightRed.write_fg(f),
|
||||||
|
Color::LightGreen => termion::color::LightGreen.write_fg(f),
|
||||||
|
Color::LightBlue => termion::color::LightBlue.write_fg(f),
|
||||||
|
Color::LightYellow => termion::color::LightYellow.write_fg(f),
|
||||||
|
Color::LightMagenta => termion::color::LightMagenta.write_fg(f),
|
||||||
|
Color::LightCyan => termion::color::LightCyan.write_fg(f),
|
||||||
|
Color::White => termion::color::LightWhite.write_fg(f),
|
||||||
|
Color::Indexed(i) => termion::color::AnsiValue(i).write_fg(f),
|
||||||
|
Color::Rgb(r, g, b) => termion::color::Rgb(r, g, b).write_fg(f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl fmt::Display for Bg {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
use termion::color::Color as TermionColor;
|
||||||
|
match self.0 {
|
||||||
|
Color::Reset => termion::color::Reset.write_bg(f),
|
||||||
|
Color::Black => termion::color::Black.write_bg(f),
|
||||||
|
Color::Red => termion::color::Red.write_bg(f),
|
||||||
|
Color::Green => termion::color::Green.write_bg(f),
|
||||||
|
Color::Yellow => termion::color::Yellow.write_bg(f),
|
||||||
|
Color::Blue => termion::color::Blue.write_bg(f),
|
||||||
|
Color::Magenta => termion::color::Magenta.write_bg(f),
|
||||||
|
Color::Cyan => termion::color::Cyan.write_bg(f),
|
||||||
|
Color::Gray => termion::color::White.write_bg(f),
|
||||||
|
Color::DarkGray => termion::color::LightBlack.write_bg(f),
|
||||||
|
Color::LightRed => termion::color::LightRed.write_bg(f),
|
||||||
|
Color::LightGreen => termion::color::LightGreen.write_bg(f),
|
||||||
|
Color::LightBlue => termion::color::LightBlue.write_bg(f),
|
||||||
|
Color::LightYellow => termion::color::LightYellow.write_bg(f),
|
||||||
|
Color::LightMagenta => termion::color::LightMagenta.write_bg(f),
|
||||||
|
Color::LightCyan => termion::color::LightCyan.write_bg(f),
|
||||||
|
Color::White => termion::color::LightWhite.write_bg(f),
|
||||||
|
Color::Indexed(i) => termion::color::AnsiValue(i).write_bg(f),
|
||||||
|
Color::Rgb(r, g, b) => termion::color::Rgb(r, g, b).write_bg(f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ModifierDiff {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
let remove = self.from - self.to;
|
||||||
|
if remove.contains(Modifier::REVERSED) {
|
||||||
|
write!(f, "{}", termion::style::NoInvert)?;
|
||||||
|
}
|
||||||
|
if remove.contains(Modifier::BOLD) {
|
||||||
|
// XXX: the termion NoBold flag actually enables double-underline on ECMA-48 compliant
|
||||||
|
// terminals, and NoFaint additionally disables bold... so we use this trick to get
|
||||||
|
// the right semantics.
|
||||||
|
write!(f, "{}", termion::style::NoFaint)?;
|
||||||
|
|
||||||
|
if self.to.contains(Modifier::DIM) {
|
||||||
|
write!(f, "{}", termion::style::Faint)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if remove.contains(Modifier::ITALIC) {
|
||||||
|
write!(f, "{}", termion::style::NoItalic)?;
|
||||||
|
}
|
||||||
|
if remove.contains(Modifier::UNDERLINED) {
|
||||||
|
write!(f, "{}", termion::style::NoUnderline)?;
|
||||||
|
}
|
||||||
|
if remove.contains(Modifier::DIM) {
|
||||||
|
write!(f, "{}", termion::style::NoFaint)?;
|
||||||
|
|
||||||
|
// XXX: the NoFaint flag additionally disables bold as well, so we need to re-enable it
|
||||||
|
// here if we want it.
|
||||||
|
if self.to.contains(Modifier::BOLD) {
|
||||||
|
write!(f, "{}", termion::style::Bold)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if remove.contains(Modifier::CROSSED_OUT) {
|
||||||
|
write!(f, "{}", termion::style::NoCrossedOut)?;
|
||||||
|
}
|
||||||
|
if remove.contains(Modifier::SLOW_BLINK) || remove.contains(Modifier::RAPID_BLINK) {
|
||||||
|
write!(f, "{}", termion::style::NoBlink)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let add = self.to - self.from;
|
||||||
|
if add.contains(Modifier::REVERSED) {
|
||||||
|
write!(f, "{}", termion::style::Invert)?;
|
||||||
|
}
|
||||||
|
if add.contains(Modifier::BOLD) {
|
||||||
|
write!(f, "{}", termion::style::Bold)?;
|
||||||
|
}
|
||||||
|
if add.contains(Modifier::ITALIC) {
|
||||||
|
write!(f, "{}", termion::style::Italic)?;
|
||||||
|
}
|
||||||
|
if add.contains(Modifier::UNDERLINED) {
|
||||||
|
write!(f, "{}", termion::style::Underline)?;
|
||||||
|
}
|
||||||
|
if add.contains(Modifier::DIM) {
|
||||||
|
write!(f, "{}", termion::style::Faint)?;
|
||||||
|
}
|
||||||
|
if add.contains(Modifier::CROSSED_OUT) {
|
||||||
|
write!(f, "{}", termion::style::CrossedOut)?;
|
||||||
|
}
|
||||||
|
if add.contains(Modifier::SLOW_BLINK) || add.contains(Modifier::RAPID_BLINK) {
|
||||||
|
write!(f, "{}", termion::style::Blink)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
736
src/ratatui/buffer.rs
Normal file
736
src/ratatui/buffer.rs
Normal file
|
@ -0,0 +1,736 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Span, Spans},
|
||||||
|
};
|
||||||
|
use std::cmp::min;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
/// A buffer cell
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Cell {
|
||||||
|
pub symbol: String,
|
||||||
|
pub fg: Color,
|
||||||
|
pub bg: Color,
|
||||||
|
pub modifier: Modifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cell {
|
||||||
|
pub fn set_symbol(&mut self, symbol: &str) -> &mut Cell {
|
||||||
|
self.symbol.clear();
|
||||||
|
self.symbol.push_str(symbol);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_char(&mut self, ch: char) -> &mut Cell {
|
||||||
|
self.symbol.clear();
|
||||||
|
self.symbol.push(ch);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_fg(&mut self, color: Color) -> &mut Cell {
|
||||||
|
self.fg = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_bg(&mut self, color: Color) -> &mut Cell {
|
||||||
|
self.bg = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_style(&mut self, style: Style) -> &mut Cell {
|
||||||
|
if let Some(c) = style.fg {
|
||||||
|
self.fg = c;
|
||||||
|
}
|
||||||
|
if let Some(c) = style.bg {
|
||||||
|
self.bg = c;
|
||||||
|
}
|
||||||
|
self.modifier.insert(style.add_modifier);
|
||||||
|
self.modifier.remove(style.sub_modifier);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(&self) -> Style {
|
||||||
|
Style::default()
|
||||||
|
.fg(self.fg)
|
||||||
|
.bg(self.bg)
|
||||||
|
.add_modifier(self.modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.symbol.clear();
|
||||||
|
self.symbol.push(' ');
|
||||||
|
self.fg = Color::Reset;
|
||||||
|
self.bg = Color::Reset;
|
||||||
|
self.modifier = Modifier::empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Cell {
|
||||||
|
fn default() -> Cell {
|
||||||
|
Cell {
|
||||||
|
symbol: " ".into(),
|
||||||
|
fg: Color::Reset,
|
||||||
|
bg: Color::Reset,
|
||||||
|
modifier: Modifier::empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A buffer that maps to the desired content of the terminal after the draw call
|
||||||
|
///
|
||||||
|
/// No widget in the library interacts directly with the terminal. Instead each of them is required
|
||||||
|
/// to draw their state to an intermediate buffer. It is basically a grid where each cell contains
|
||||||
|
/// a grapheme, a foreground color and a background color. This grid will then be used to output
|
||||||
|
/// the appropriate escape sequences and characters to draw the UI as the user has defined it.
|
||||||
|
///
|
||||||
|
/// # Examples:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use ratatui::buffer::{Buffer, Cell};
|
||||||
|
/// use ratatui::layout::Rect;
|
||||||
|
/// use ratatui::style::{Color, Style, Modifier};
|
||||||
|
///
|
||||||
|
/// let mut buf = Buffer::empty(Rect{x: 0, y: 0, width: 10, height: 5});
|
||||||
|
/// buf.get_mut(0, 2).set_symbol("x");
|
||||||
|
/// assert_eq!(buf.get(0, 2).symbol, "x");
|
||||||
|
/// buf.set_string(3, 0, "string", Style::default().fg(Color::Red).bg(Color::White));
|
||||||
|
/// assert_eq!(buf.get(5, 0), &Cell{
|
||||||
|
/// symbol: String::from("r"),
|
||||||
|
/// fg: Color::Red,
|
||||||
|
/// bg: Color::White,
|
||||||
|
/// modifier: Modifier::empty()
|
||||||
|
/// });
|
||||||
|
/// buf.get_mut(5, 0).set_char('x');
|
||||||
|
/// assert_eq!(buf.get(5, 0).symbol, "x");
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct Buffer {
|
||||||
|
/// The area represented by this buffer
|
||||||
|
pub area: Rect,
|
||||||
|
/// The content of the buffer. The length of this Vec should always be equal to area.width *
|
||||||
|
/// area.height
|
||||||
|
pub content: Vec<Cell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Buffer {
|
||||||
|
/// Returns a Buffer with all cells set to the default one
|
||||||
|
pub fn empty(area: Rect) -> Buffer {
|
||||||
|
let cell: Cell = Default::default();
|
||||||
|
Buffer::filled(area, &cell)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a Buffer with all cells initialized with the attributes of the given Cell
|
||||||
|
pub fn filled(area: Rect, cell: &Cell) -> Buffer {
|
||||||
|
let size = area.area() as usize;
|
||||||
|
let mut content = Vec::with_capacity(size);
|
||||||
|
for _ in 0..size {
|
||||||
|
content.push(cell.clone());
|
||||||
|
}
|
||||||
|
Buffer { area, content }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a Buffer containing the given lines
|
||||||
|
pub fn with_lines<S>(lines: Vec<S>) -> Buffer
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
let height = lines.len() as u16;
|
||||||
|
let width = lines
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.as_ref().width() as u16)
|
||||||
|
.max()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut buffer = Buffer::empty(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
for (y, line) in lines.iter().enumerate() {
|
||||||
|
buffer.set_string(0, y as u16, line, Style::default());
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the content of the buffer as a slice
|
||||||
|
pub fn content(&self) -> &[Cell] {
|
||||||
|
&self.content
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the area covered by this buffer
|
||||||
|
pub fn area(&self) -> &Rect {
|
||||||
|
&self.area
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to Cell at the given coordinates
|
||||||
|
pub fn get(&self, x: u16, y: u16) -> &Cell {
|
||||||
|
let i = self.index_of(x, y);
|
||||||
|
&self.content[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to Cell at the given coordinates
|
||||||
|
pub fn get_mut(&mut self, x: u16, y: u16) -> &mut Cell {
|
||||||
|
let i = self.index_of(x, y);
|
||||||
|
&mut self.content[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the index in the `Vec<Cell>` for the given global (x, y) coordinates.
|
||||||
|
///
|
||||||
|
/// Global coordinates are offset by the Buffer's area offset (`x`/`y`).
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::buffer::Buffer;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// let rect = Rect::new(200, 100, 10, 10);
|
||||||
|
/// let buffer = Buffer::empty(rect);
|
||||||
|
/// // Global coordinates to the top corner of this buffer's area
|
||||||
|
/// assert_eq!(buffer.index_of(200, 100), 0);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics when given an coordinate that is outside of this Buffer's area.
|
||||||
|
///
|
||||||
|
/// ```should_panic
|
||||||
|
/// # use ratatui::buffer::Buffer;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// let rect = Rect::new(200, 100, 10, 10);
|
||||||
|
/// let buffer = Buffer::empty(rect);
|
||||||
|
/// // Top coordinate is outside of the buffer in global coordinate space, as the Buffer's area
|
||||||
|
/// // starts at (200, 100).
|
||||||
|
/// buffer.index_of(0, 0); // Panics
|
||||||
|
/// ```
|
||||||
|
pub fn index_of(&self, x: u16, y: u16) -> usize {
|
||||||
|
debug_assert!(
|
||||||
|
x >= self.area.left()
|
||||||
|
&& x < self.area.right()
|
||||||
|
&& y >= self.area.top()
|
||||||
|
&& y < self.area.bottom(),
|
||||||
|
"Trying to access position outside the buffer: x={}, y={}, area={:?}",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
self.area
|
||||||
|
);
|
||||||
|
((y - self.area.y) * self.area.width + (x - self.area.x)) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the (global) coordinates of a cell given its index
|
||||||
|
///
|
||||||
|
/// Global coordinates are offset by the Buffer's area offset (`x`/`y`).
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::buffer::Buffer;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// let rect = Rect::new(200, 100, 10, 10);
|
||||||
|
/// let buffer = Buffer::empty(rect);
|
||||||
|
/// assert_eq!(buffer.pos_of(0), (200, 100));
|
||||||
|
/// assert_eq!(buffer.pos_of(14), (204, 101));
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics when given an index that is outside the Buffer's content.
|
||||||
|
///
|
||||||
|
/// ```should_panic
|
||||||
|
/// # use ratatui::buffer::Buffer;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// let rect = Rect::new(0, 0, 10, 10); // 100 cells in total
|
||||||
|
/// let buffer = Buffer::empty(rect);
|
||||||
|
/// // Index 100 is the 101th cell, which lies outside of the area of this Buffer.
|
||||||
|
/// buffer.pos_of(100); // Panics
|
||||||
|
/// ```
|
||||||
|
pub fn pos_of(&self, i: usize) -> (u16, u16) {
|
||||||
|
debug_assert!(
|
||||||
|
i < self.content.len(),
|
||||||
|
"Trying to get the coords of a cell outside the buffer: i={} len={}",
|
||||||
|
i,
|
||||||
|
self.content.len()
|
||||||
|
);
|
||||||
|
(
|
||||||
|
self.area.x + i as u16 % self.area.width,
|
||||||
|
self.area.y + i as u16 / self.area.width,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print a string, starting at the position (x, y)
|
||||||
|
pub fn set_string<S>(&mut self, x: u16, y: u16, string: S, style: Style)
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
self.set_stringn(x, y, string, usize::MAX, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print at most the first n characters of a string if enough space is available
|
||||||
|
/// until the end of the line
|
||||||
|
pub fn set_stringn<S>(
|
||||||
|
&mut self,
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
string: S,
|
||||||
|
width: usize,
|
||||||
|
style: Style,
|
||||||
|
) -> (u16, u16)
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
let mut index = self.index_of(x, y);
|
||||||
|
let mut x_offset = x as usize;
|
||||||
|
let graphemes = UnicodeSegmentation::graphemes(string.as_ref(), true);
|
||||||
|
let max_offset = min(self.area.right() as usize, width.saturating_add(x as usize));
|
||||||
|
for s in graphemes {
|
||||||
|
let width = s.width();
|
||||||
|
if width == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// `x_offset + width > max_offset` could be integer overflow on 32-bit machines if we
|
||||||
|
// change dimensions to usize or u32 and someone resizes the terminal to 1x2^32.
|
||||||
|
if width > max_offset.saturating_sub(x_offset) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.content[index].set_symbol(s);
|
||||||
|
self.content[index].set_style(style);
|
||||||
|
// Reset following cells if multi-width (they would be hidden by the grapheme),
|
||||||
|
for i in index + 1..index + width {
|
||||||
|
self.content[i].reset();
|
||||||
|
}
|
||||||
|
index += width;
|
||||||
|
x_offset += width;
|
||||||
|
}
|
||||||
|
(x_offset as u16, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_spans(&mut self, x: u16, y: u16, spans: &Spans<'_>, width: u16) -> (u16, u16) {
|
||||||
|
let mut remaining_width = width;
|
||||||
|
let mut x = x;
|
||||||
|
for span in &spans.0 {
|
||||||
|
if remaining_width == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let pos = self.set_stringn(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
span.content.as_ref(),
|
||||||
|
remaining_width as usize,
|
||||||
|
span.style,
|
||||||
|
);
|
||||||
|
let w = pos.0.saturating_sub(x);
|
||||||
|
x = pos.0;
|
||||||
|
remaining_width = remaining_width.saturating_sub(w);
|
||||||
|
}
|
||||||
|
(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_span(&mut self, x: u16, y: u16, span: &Span<'_>, width: u16) -> (u16, u16) {
|
||||||
|
self.set_stringn(x, y, span.content.as_ref(), width as usize, span.style)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[deprecated(
|
||||||
|
since = "0.10.0",
|
||||||
|
note = "You should use styling capabilities of `Buffer::set_style`"
|
||||||
|
)]
|
||||||
|
pub fn set_background(&mut self, area: Rect, color: Color) {
|
||||||
|
for y in area.top()..area.bottom() {
|
||||||
|
for x in area.left()..area.right() {
|
||||||
|
self.get_mut(x, y).set_bg(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_style(&mut self, area: Rect, style: Style) {
|
||||||
|
for y in area.top()..area.bottom() {
|
||||||
|
for x in area.left()..area.right() {
|
||||||
|
self.get_mut(x, y).set_style(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the buffer so that the mapped area matches the given area and that the buffer
|
||||||
|
/// length is equal to area.width * area.height
|
||||||
|
pub fn resize(&mut self, area: Rect) {
|
||||||
|
let length = area.area() as usize;
|
||||||
|
if self.content.len() > length {
|
||||||
|
self.content.truncate(length);
|
||||||
|
} else {
|
||||||
|
self.content.resize(length, Default::default());
|
||||||
|
}
|
||||||
|
self.area = area;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all cells in the buffer
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
for c in &mut self.content {
|
||||||
|
c.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge an other buffer into this one
|
||||||
|
pub fn merge(&mut self, other: &Buffer) {
|
||||||
|
let area = self.area.union(other.area);
|
||||||
|
let cell: Cell = Default::default();
|
||||||
|
self.content.resize(area.area() as usize, cell.clone());
|
||||||
|
|
||||||
|
// Move original content to the appropriate space
|
||||||
|
let size = self.area.area() as usize;
|
||||||
|
for i in (0..size).rev() {
|
||||||
|
let (x, y) = self.pos_of(i);
|
||||||
|
// New index in content
|
||||||
|
let k = ((y - area.y) * area.width + x - area.x) as usize;
|
||||||
|
if i != k {
|
||||||
|
self.content[k] = self.content[i].clone();
|
||||||
|
self.content[i] = cell.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push content of the other buffer into this one (may erase previous
|
||||||
|
// data)
|
||||||
|
let size = other.area.area() as usize;
|
||||||
|
for i in 0..size {
|
||||||
|
let (x, y) = other.pos_of(i);
|
||||||
|
// New index in content
|
||||||
|
let k = ((y - area.y) * area.width + x - area.x) as usize;
|
||||||
|
self.content[k] = other.content[i].clone();
|
||||||
|
}
|
||||||
|
self.area = area;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a minimal sequence of coordinates and Cells necessary to update the UI from
|
||||||
|
/// self to other.
|
||||||
|
///
|
||||||
|
/// We're assuming that buffers are well-formed, that is no double-width cell is followed by
|
||||||
|
/// a non-blank cell.
|
||||||
|
///
|
||||||
|
/// # Multi-width characters handling:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// (Index:) `01`
|
||||||
|
/// Prev: `コ`
|
||||||
|
/// Next: `aa`
|
||||||
|
/// Updates: `0: a, 1: a'
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// (Index:) `01`
|
||||||
|
/// Prev: `a `
|
||||||
|
/// Next: `コ`
|
||||||
|
/// Updates: `0: コ` (double width symbol at index 0 - skip index 1)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// (Index:) `012`
|
||||||
|
/// Prev: `aaa`
|
||||||
|
/// Next: `aコ`
|
||||||
|
/// Updates: `0: a, 1: コ` (double width symbol at index 1 - skip index 2)
|
||||||
|
/// ```
|
||||||
|
pub fn diff<'a>(&self, other: &'a Buffer) -> Vec<(u16, u16, &'a Cell)> {
|
||||||
|
let previous_buffer = &self.content;
|
||||||
|
let next_buffer = &other.content;
|
||||||
|
|
||||||
|
let mut updates: Vec<(u16, u16, &Cell)> = vec![];
|
||||||
|
// Cells invalidated by drawing/replacing preceding multi-width characters:
|
||||||
|
let mut invalidated: usize = 0;
|
||||||
|
// Cells from the current buffer to skip due to preceding multi-width characters taking their
|
||||||
|
// place (the skipped cells should be blank anyway):
|
||||||
|
let mut to_skip: usize = 0;
|
||||||
|
for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() {
|
||||||
|
if (current != previous || invalidated > 0) && to_skip == 0 {
|
||||||
|
let (x, y) = self.pos_of(i);
|
||||||
|
updates.push((x, y, &next_buffer[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
to_skip = current.symbol.width().saturating_sub(1);
|
||||||
|
|
||||||
|
let affected_width = std::cmp::max(current.symbol.width(), previous.symbol.width());
|
||||||
|
invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1);
|
||||||
|
}
|
||||||
|
updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn cell(s: &str) -> Cell {
|
||||||
|
let mut cell = Cell::default();
|
||||||
|
cell.set_symbol(s);
|
||||||
|
cell
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_translates_to_and_from_coordinates() {
|
||||||
|
let rect = Rect::new(200, 100, 50, 80);
|
||||||
|
let buf = Buffer::empty(rect);
|
||||||
|
|
||||||
|
// First cell is at the upper left corner.
|
||||||
|
assert_eq!(buf.pos_of(0), (200, 100));
|
||||||
|
assert_eq!(buf.index_of(200, 100), 0);
|
||||||
|
|
||||||
|
// Last cell is in the lower right.
|
||||||
|
assert_eq!(buf.pos_of(buf.content.len() - 1), (249, 179));
|
||||||
|
assert_eq!(buf.index_of(249, 179), buf.content.len() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
#[should_panic(expected = "outside the buffer")]
|
||||||
|
fn pos_of_panics_on_out_of_bounds() {
|
||||||
|
let rect = Rect::new(0, 0, 10, 10);
|
||||||
|
let buf = Buffer::empty(rect);
|
||||||
|
|
||||||
|
// There are a total of 100 cells; zero-indexed means that 100 would be the 101st cell.
|
||||||
|
buf.pos_of(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
#[should_panic(expected = "outside the buffer")]
|
||||||
|
fn index_of_panics_on_out_of_bounds() {
|
||||||
|
let rect = Rect::new(0, 0, 10, 10);
|
||||||
|
let buf = Buffer::empty(rect);
|
||||||
|
|
||||||
|
// width is 10; zero-indexed means that 10 would be the 11th cell.
|
||||||
|
buf.index_of(10, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_set_string() {
|
||||||
|
let area = Rect::new(0, 0, 5, 1);
|
||||||
|
let mut buffer = Buffer::empty(area);
|
||||||
|
|
||||||
|
// Zero-width
|
||||||
|
buffer.set_stringn(0, 0, "aaa", 0, Style::default());
|
||||||
|
assert_eq!(buffer, Buffer::with_lines(vec![" "]));
|
||||||
|
|
||||||
|
buffer.set_string(0, 0, "aaa", Style::default());
|
||||||
|
assert_eq!(buffer, Buffer::with_lines(vec!["aaa "]));
|
||||||
|
|
||||||
|
// Width limit:
|
||||||
|
buffer.set_stringn(0, 0, "bbbbbbbbbbbbbb", 4, Style::default());
|
||||||
|
assert_eq!(buffer, Buffer::with_lines(vec!["bbbb "]));
|
||||||
|
|
||||||
|
buffer.set_string(0, 0, "12345", Style::default());
|
||||||
|
assert_eq!(buffer, Buffer::with_lines(vec!["12345"]));
|
||||||
|
|
||||||
|
// Width truncation:
|
||||||
|
buffer.set_string(0, 0, "123456", Style::default());
|
||||||
|
assert_eq!(buffer, Buffer::with_lines(vec!["12345"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_set_string_zero_width() {
|
||||||
|
let area = Rect::new(0, 0, 1, 1);
|
||||||
|
let mut buffer = Buffer::empty(area);
|
||||||
|
|
||||||
|
// Leading grapheme with zero width
|
||||||
|
let s = "\u{1}a";
|
||||||
|
buffer.set_stringn(0, 0, s, 1, Style::default());
|
||||||
|
assert_eq!(buffer, Buffer::with_lines(vec!["a"]));
|
||||||
|
|
||||||
|
// Trailing grapheme with zero with
|
||||||
|
let s = "a\u{1}";
|
||||||
|
buffer.set_stringn(0, 0, s, 1, Style::default());
|
||||||
|
assert_eq!(buffer, Buffer::with_lines(vec!["a"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_set_string_double_width() {
|
||||||
|
let area = Rect::new(0, 0, 5, 1);
|
||||||
|
let mut buffer = Buffer::empty(area);
|
||||||
|
buffer.set_string(0, 0, "コン", Style::default());
|
||||||
|
assert_eq!(buffer, Buffer::with_lines(vec!["コン "]));
|
||||||
|
|
||||||
|
// Only 1 space left.
|
||||||
|
buffer.set_string(0, 0, "コンピ", Style::default());
|
||||||
|
assert_eq!(buffer, Buffer::with_lines(vec!["コン "]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_with_lines() {
|
||||||
|
let buffer =
|
||||||
|
Buffer::with_lines(vec!["┌────────┐", "│コンピュ│", "│ーa 上で│", "└────────┘"]);
|
||||||
|
assert_eq!(buffer.area.x, 0);
|
||||||
|
assert_eq!(buffer.area.y, 0);
|
||||||
|
assert_eq!(buffer.area.width, 10);
|
||||||
|
assert_eq!(buffer.area.height, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_diffing_empty_empty() {
|
||||||
|
let area = Rect::new(0, 0, 40, 40);
|
||||||
|
let prev = Buffer::empty(area);
|
||||||
|
let next = Buffer::empty(area);
|
||||||
|
let diff = prev.diff(&next);
|
||||||
|
assert_eq!(diff, vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_diffing_empty_filled() {
|
||||||
|
let area = Rect::new(0, 0, 40, 40);
|
||||||
|
let prev = Buffer::empty(area);
|
||||||
|
let next = Buffer::filled(area, Cell::default().set_symbol("a"));
|
||||||
|
let diff = prev.diff(&next);
|
||||||
|
assert_eq!(diff.len(), 40 * 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_diffing_filled_filled() {
|
||||||
|
let area = Rect::new(0, 0, 40, 40);
|
||||||
|
let prev = Buffer::filled(area, Cell::default().set_symbol("a"));
|
||||||
|
let next = Buffer::filled(area, Cell::default().set_symbol("a"));
|
||||||
|
let diff = prev.diff(&next);
|
||||||
|
assert_eq!(diff, vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_diffing_single_width() {
|
||||||
|
let prev = Buffer::with_lines(vec![
|
||||||
|
" ",
|
||||||
|
"┌Title─┐ ",
|
||||||
|
"│ │ ",
|
||||||
|
"│ │ ",
|
||||||
|
"└──────┘ ",
|
||||||
|
]);
|
||||||
|
let next = Buffer::with_lines(vec![
|
||||||
|
" ",
|
||||||
|
"┌TITLE─┐ ",
|
||||||
|
"│ │ ",
|
||||||
|
"│ │ ",
|
||||||
|
"└──────┘ ",
|
||||||
|
]);
|
||||||
|
let diff = prev.diff(&next);
|
||||||
|
assert_eq!(
|
||||||
|
diff,
|
||||||
|
vec![
|
||||||
|
(2, 1, &cell("I")),
|
||||||
|
(3, 1, &cell("T")),
|
||||||
|
(4, 1, &cell("L")),
|
||||||
|
(5, 1, &cell("E")),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[rustfmt::skip]
|
||||||
|
fn buffer_diffing_multi_width() {
|
||||||
|
let prev = Buffer::with_lines(vec![
|
||||||
|
"┌Title─┐ ",
|
||||||
|
"└──────┘ ",
|
||||||
|
]);
|
||||||
|
let next = Buffer::with_lines(vec![
|
||||||
|
"┌称号──┐ ",
|
||||||
|
"└──────┘ ",
|
||||||
|
]);
|
||||||
|
let diff = prev.diff(&next);
|
||||||
|
assert_eq!(
|
||||||
|
diff,
|
||||||
|
vec![
|
||||||
|
(1, 0, &cell("称")),
|
||||||
|
// Skipped "i"
|
||||||
|
(3, 0, &cell("号")),
|
||||||
|
// Skipped "l"
|
||||||
|
(5, 0, &cell("─")),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_diffing_multi_width_offset() {
|
||||||
|
let prev = Buffer::with_lines(vec!["┌称号──┐"]);
|
||||||
|
let next = Buffer::with_lines(vec!["┌─称号─┐"]);
|
||||||
|
|
||||||
|
let diff = prev.diff(&next);
|
||||||
|
assert_eq!(
|
||||||
|
diff,
|
||||||
|
vec![(1, 0, &cell("─")), (2, 0, &cell("称")), (4, 0, &cell("号")),]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_merge() {
|
||||||
|
let mut one = Buffer::filled(
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
Cell::default().set_symbol("1"),
|
||||||
|
);
|
||||||
|
let two = Buffer::filled(
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 2,
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
Cell::default().set_symbol("2"),
|
||||||
|
);
|
||||||
|
one.merge(&two);
|
||||||
|
assert_eq!(one, Buffer::with_lines(vec!["11", "11", "22", "22"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_merge2() {
|
||||||
|
let mut one = Buffer::filled(
|
||||||
|
Rect {
|
||||||
|
x: 2,
|
||||||
|
y: 2,
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
Cell::default().set_symbol("1"),
|
||||||
|
);
|
||||||
|
let two = Buffer::filled(
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
Cell::default().set_symbol("2"),
|
||||||
|
);
|
||||||
|
one.merge(&two);
|
||||||
|
assert_eq!(
|
||||||
|
one,
|
||||||
|
Buffer::with_lines(vec!["22 ", "22 ", " 11", " 11"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_merge3() {
|
||||||
|
let mut one = Buffer::filled(
|
||||||
|
Rect {
|
||||||
|
x: 3,
|
||||||
|
y: 3,
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
Cell::default().set_symbol("1"),
|
||||||
|
);
|
||||||
|
let two = Buffer::filled(
|
||||||
|
Rect {
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
width: 3,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
Cell::default().set_symbol("2"),
|
||||||
|
);
|
||||||
|
one.merge(&two);
|
||||||
|
let mut merged = Buffer::with_lines(vec!["222 ", "222 ", "2221", "2221"]);
|
||||||
|
merged.area = Rect {
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
};
|
||||||
|
assert_eq!(one, merged);
|
||||||
|
}
|
||||||
|
}
|
560
src/ratatui/layout.rs
Normal file
560
src/ratatui/layout.rs
Normal file
|
@ -0,0 +1,560 @@
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::cmp::{max, min};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use cassowary::strength::{MEDIUM, REQUIRED, WEAK};
|
||||||
|
use cassowary::WeightedRelation::*;
|
||||||
|
use cassowary::{Constraint as CassowaryConstraint, Expression, Solver, Variable};
|
||||||
|
|
||||||
|
#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Corner {
|
||||||
|
TopLeft,
|
||||||
|
TopRight,
|
||||||
|
BottomRight,
|
||||||
|
BottomLeft,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Hash, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Direction {
|
||||||
|
Horizontal,
|
||||||
|
Vertical,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Constraint {
|
||||||
|
// TODO: enforce range 0 - 100
|
||||||
|
Percentage(u16),
|
||||||
|
Ratio(u32, u32),
|
||||||
|
Length(u16),
|
||||||
|
Max(u16),
|
||||||
|
Min(u16),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Constraint {
|
||||||
|
pub fn apply(&self, length: u16) -> u16 {
|
||||||
|
match *self {
|
||||||
|
Constraint::Percentage(p) => length * p / 100,
|
||||||
|
Constraint::Ratio(num, den) => {
|
||||||
|
let r = num * u32::from(length) / den;
|
||||||
|
r as u16
|
||||||
|
}
|
||||||
|
Constraint::Length(l) => length.min(l),
|
||||||
|
Constraint::Max(m) => length.min(m),
|
||||||
|
Constraint::Min(m) => length.max(m),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Margin {
|
||||||
|
pub vertical: u16,
|
||||||
|
pub horizontal: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Alignment {
|
||||||
|
Left,
|
||||||
|
Center,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Layout {
|
||||||
|
direction: Direction,
|
||||||
|
margin: Margin,
|
||||||
|
constraints: Vec<Constraint>,
|
||||||
|
/// Whether the last chunk of the computed layout should be expanded to fill the available
|
||||||
|
/// space.
|
||||||
|
expand_to_fill: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache = HashMap<(Rect, Layout), Rc<[Rect]>>;
|
||||||
|
thread_local! {
|
||||||
|
static LAYOUT_CACHE: RefCell<Cache> = RefCell::new(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Layout {
|
||||||
|
fn default() -> Layout {
|
||||||
|
Layout {
|
||||||
|
direction: Direction::Vertical,
|
||||||
|
margin: Margin {
|
||||||
|
horizontal: 0,
|
||||||
|
vertical: 0,
|
||||||
|
},
|
||||||
|
constraints: Vec::new(),
|
||||||
|
expand_to_fill: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layout {
|
||||||
|
pub fn constraints<C>(mut self, constraints: C) -> Layout
|
||||||
|
where
|
||||||
|
C: Into<Vec<Constraint>>,
|
||||||
|
{
|
||||||
|
self.constraints = constraints.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn margin(mut self, margin: u16) -> Layout {
|
||||||
|
self.margin = Margin {
|
||||||
|
horizontal: margin,
|
||||||
|
vertical: margin,
|
||||||
|
};
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn horizontal_margin(mut self, horizontal: u16) -> Layout {
|
||||||
|
self.margin.horizontal = horizontal;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vertical_margin(mut self, vertical: u16) -> Layout {
|
||||||
|
self.margin.vertical = vertical;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn direction(mut self, direction: Direction) -> Layout {
|
||||||
|
self.direction = direction;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn expand_to_fill(mut self, expand_to_fill: bool) -> Layout {
|
||||||
|
self.expand_to_fill = expand_to_fill;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper function around the cassowary-rs solver to be able to split a given
|
||||||
|
/// area into smaller ones based on the preferred widths or heights and the direction.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::layout::{Rect, Constraint, Direction, Layout};
|
||||||
|
/// let chunks = Layout::default()
|
||||||
|
/// .direction(Direction::Vertical)
|
||||||
|
/// .constraints([Constraint::Length(5), Constraint::Min(0)].as_ref())
|
||||||
|
/// .split(Rect {
|
||||||
|
/// x: 2,
|
||||||
|
/// y: 2,
|
||||||
|
/// width: 10,
|
||||||
|
/// height: 10,
|
||||||
|
/// });
|
||||||
|
/// assert_eq!(
|
||||||
|
/// chunks[..],
|
||||||
|
/// [
|
||||||
|
/// Rect {
|
||||||
|
/// x: 2,
|
||||||
|
/// y: 2,
|
||||||
|
/// width: 10,
|
||||||
|
/// height: 5
|
||||||
|
/// },
|
||||||
|
/// Rect {
|
||||||
|
/// x: 2,
|
||||||
|
/// y: 7,
|
||||||
|
/// width: 10,
|
||||||
|
/// height: 5
|
||||||
|
/// }
|
||||||
|
/// ]
|
||||||
|
/// );
|
||||||
|
///
|
||||||
|
/// let chunks = Layout::default()
|
||||||
|
/// .direction(Direction::Horizontal)
|
||||||
|
/// .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)].as_ref())
|
||||||
|
/// .split(Rect {
|
||||||
|
/// x: 0,
|
||||||
|
/// y: 0,
|
||||||
|
/// width: 9,
|
||||||
|
/// height: 2,
|
||||||
|
/// });
|
||||||
|
/// assert_eq!(
|
||||||
|
/// chunks[..],
|
||||||
|
/// [
|
||||||
|
/// Rect {
|
||||||
|
/// x: 0,
|
||||||
|
/// y: 0,
|
||||||
|
/// width: 3,
|
||||||
|
/// height: 2
|
||||||
|
/// },
|
||||||
|
/// Rect {
|
||||||
|
/// x: 3,
|
||||||
|
/// y: 0,
|
||||||
|
/// width: 6,
|
||||||
|
/// height: 2
|
||||||
|
/// }
|
||||||
|
/// ]
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
pub fn split(&self, area: Rect) -> Rc<[Rect]> {
|
||||||
|
// TODO: Maybe use a fixed size cache ?
|
||||||
|
LAYOUT_CACHE.with(|c| {
|
||||||
|
c.borrow_mut()
|
||||||
|
.entry((area, self.clone()))
|
||||||
|
.or_insert_with(|| split(area, self))
|
||||||
|
.clone()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split(area: Rect, layout: &Layout) -> Rc<[Rect]> {
|
||||||
|
let mut solver = Solver::new();
|
||||||
|
let mut vars: HashMap<Variable, (usize, usize)> = HashMap::new();
|
||||||
|
let elements = layout
|
||||||
|
.constraints
|
||||||
|
.iter()
|
||||||
|
.map(|_| Element::new())
|
||||||
|
.collect::<Vec<Element>>();
|
||||||
|
let mut res = layout
|
||||||
|
.constraints
|
||||||
|
.iter()
|
||||||
|
.map(|_| Rect::default())
|
||||||
|
.collect::<Rc<[Rect]>>();
|
||||||
|
|
||||||
|
let mut results = Rc::get_mut(&mut res).expect("newly created Rc should have no shared refs");
|
||||||
|
|
||||||
|
let dest_area = area.inner(&layout.margin);
|
||||||
|
for (i, e) in elements.iter().enumerate() {
|
||||||
|
vars.insert(e.x, (i, 0));
|
||||||
|
vars.insert(e.y, (i, 1));
|
||||||
|
vars.insert(e.width, (i, 2));
|
||||||
|
vars.insert(e.height, (i, 3));
|
||||||
|
}
|
||||||
|
let mut ccs: Vec<CassowaryConstraint> =
|
||||||
|
Vec::with_capacity(elements.len() * 4 + layout.constraints.len() * 6);
|
||||||
|
for elt in &elements {
|
||||||
|
ccs.push(elt.width | GE(REQUIRED) | 0f64);
|
||||||
|
ccs.push(elt.height | GE(REQUIRED) | 0f64);
|
||||||
|
ccs.push(elt.left() | GE(REQUIRED) | f64::from(dest_area.left()));
|
||||||
|
ccs.push(elt.top() | GE(REQUIRED) | f64::from(dest_area.top()));
|
||||||
|
ccs.push(elt.right() | LE(REQUIRED) | f64::from(dest_area.right()));
|
||||||
|
ccs.push(elt.bottom() | LE(REQUIRED) | f64::from(dest_area.bottom()));
|
||||||
|
}
|
||||||
|
if let Some(first) = elements.first() {
|
||||||
|
ccs.push(match layout.direction {
|
||||||
|
Direction::Horizontal => first.left() | EQ(REQUIRED) | f64::from(dest_area.left()),
|
||||||
|
Direction::Vertical => first.top() | EQ(REQUIRED) | f64::from(dest_area.top()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if layout.expand_to_fill {
|
||||||
|
if let Some(last) = elements.last() {
|
||||||
|
ccs.push(match layout.direction {
|
||||||
|
Direction::Horizontal => last.right() | EQ(REQUIRED) | f64::from(dest_area.right()),
|
||||||
|
Direction::Vertical => last.bottom() | EQ(REQUIRED) | f64::from(dest_area.bottom()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match layout.direction {
|
||||||
|
Direction::Horizontal => {
|
||||||
|
for pair in elements.windows(2) {
|
||||||
|
ccs.push((pair[0].x + pair[0].width) | EQ(REQUIRED) | pair[1].x);
|
||||||
|
}
|
||||||
|
for (i, size) in layout.constraints.iter().enumerate() {
|
||||||
|
ccs.push(elements[i].y | EQ(REQUIRED) | f64::from(dest_area.y));
|
||||||
|
ccs.push(elements[i].height | EQ(REQUIRED) | f64::from(dest_area.height));
|
||||||
|
ccs.push(match *size {
|
||||||
|
Constraint::Length(v) => elements[i].width | EQ(MEDIUM) | f64::from(v),
|
||||||
|
Constraint::Percentage(v) => {
|
||||||
|
elements[i].width | EQ(MEDIUM) | (f64::from(v * dest_area.width) / 100.0)
|
||||||
|
}
|
||||||
|
Constraint::Ratio(n, d) => {
|
||||||
|
elements[i].width
|
||||||
|
| EQ(MEDIUM)
|
||||||
|
| (f64::from(dest_area.width) * f64::from(n) / f64::from(d))
|
||||||
|
}
|
||||||
|
Constraint::Min(v) => elements[i].width | GE(MEDIUM) | f64::from(v),
|
||||||
|
Constraint::Max(v) => elements[i].width | LE(MEDIUM) | f64::from(v),
|
||||||
|
});
|
||||||
|
|
||||||
|
match *size {
|
||||||
|
Constraint::Min(v) => {
|
||||||
|
ccs.push(elements[i].width | EQ(WEAK) | f64::from(v));
|
||||||
|
}
|
||||||
|
Constraint::Max(v) => {
|
||||||
|
ccs.push(elements[i].width | EQ(WEAK) | f64::from(v));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Direction::Vertical => {
|
||||||
|
for pair in elements.windows(2) {
|
||||||
|
ccs.push((pair[0].y + pair[0].height) | EQ(REQUIRED) | pair[1].y);
|
||||||
|
}
|
||||||
|
for (i, size) in layout.constraints.iter().enumerate() {
|
||||||
|
ccs.push(elements[i].x | EQ(REQUIRED) | f64::from(dest_area.x));
|
||||||
|
ccs.push(elements[i].width | EQ(REQUIRED) | f64::from(dest_area.width));
|
||||||
|
ccs.push(match *size {
|
||||||
|
Constraint::Length(v) => elements[i].height | EQ(MEDIUM) | f64::from(v),
|
||||||
|
Constraint::Percentage(v) => {
|
||||||
|
elements[i].height | EQ(MEDIUM) | (f64::from(v * dest_area.height) / 100.0)
|
||||||
|
}
|
||||||
|
Constraint::Ratio(n, d) => {
|
||||||
|
elements[i].height
|
||||||
|
| EQ(MEDIUM)
|
||||||
|
| (f64::from(dest_area.height) * f64::from(n) / f64::from(d))
|
||||||
|
}
|
||||||
|
Constraint::Min(v) => elements[i].height | GE(MEDIUM) | f64::from(v),
|
||||||
|
Constraint::Max(v) => elements[i].height | LE(MEDIUM) | f64::from(v),
|
||||||
|
});
|
||||||
|
|
||||||
|
match *size {
|
||||||
|
Constraint::Min(v) => {
|
||||||
|
ccs.push(elements[i].height | EQ(WEAK) | f64::from(v));
|
||||||
|
}
|
||||||
|
Constraint::Max(v) => {
|
||||||
|
ccs.push(elements[i].height | EQ(WEAK) | f64::from(v));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
solver.add_constraints(&ccs).unwrap();
|
||||||
|
for &(var, value) in solver.fetch_changes() {
|
||||||
|
let (index, attr) = vars[&var];
|
||||||
|
let value = if value.is_sign_negative() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
value as u16
|
||||||
|
};
|
||||||
|
match attr {
|
||||||
|
0 => {
|
||||||
|
results[index].x = value;
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
results[index].y = value;
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
results[index].width = value;
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
results[index].height = value;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if layout.expand_to_fill {
|
||||||
|
// Fix imprecision by extending the last item a bit if necessary
|
||||||
|
if let Some(last) = results.last_mut() {
|
||||||
|
match layout.direction {
|
||||||
|
Direction::Vertical => {
|
||||||
|
last.height = dest_area.bottom() - last.y;
|
||||||
|
}
|
||||||
|
Direction::Horizontal => {
|
||||||
|
last.width = dest_area.right() - last.x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A container used by the solver inside split
|
||||||
|
struct Element {
|
||||||
|
x: Variable,
|
||||||
|
y: Variable,
|
||||||
|
width: Variable,
|
||||||
|
height: Variable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element {
|
||||||
|
fn new() -> Element {
|
||||||
|
Element {
|
||||||
|
x: Variable::new(),
|
||||||
|
y: Variable::new(),
|
||||||
|
width: Variable::new(),
|
||||||
|
height: Variable::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn left(&self) -> Variable {
|
||||||
|
self.x
|
||||||
|
}
|
||||||
|
|
||||||
|
fn top(&self) -> Variable {
|
||||||
|
self.y
|
||||||
|
}
|
||||||
|
|
||||||
|
fn right(&self) -> Expression {
|
||||||
|
self.x + self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bottom(&self) -> Expression {
|
||||||
|
self.y + self.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A simple rectangle used in the computation of the layout and to give widgets a hint about the
|
||||||
|
/// area they are supposed to render to.
|
||||||
|
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)]
|
||||||
|
pub struct Rect {
|
||||||
|
pub x: u16,
|
||||||
|
pub y: u16,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rect {
|
||||||
|
/// Creates a new rect, with width and height limited to keep the area under max u16.
|
||||||
|
/// If clipped, aspect ratio will be preserved.
|
||||||
|
pub fn new(x: u16, y: u16, width: u16, height: u16) -> Rect {
|
||||||
|
let max_area = u16::max_value();
|
||||||
|
let (clipped_width, clipped_height) =
|
||||||
|
if u32::from(width) * u32::from(height) > u32::from(max_area) {
|
||||||
|
let aspect_ratio = f64::from(width) / f64::from(height);
|
||||||
|
let max_area_f = f64::from(max_area);
|
||||||
|
let height_f = (max_area_f / aspect_ratio).sqrt();
|
||||||
|
let width_f = height_f * aspect_ratio;
|
||||||
|
(width_f as u16, height_f as u16)
|
||||||
|
} else {
|
||||||
|
(width, height)
|
||||||
|
};
|
||||||
|
Rect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: clipped_width,
|
||||||
|
height: clipped_height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn area(self) -> u16 {
|
||||||
|
self.width * self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn left(self) -> u16 {
|
||||||
|
self.x
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn right(self) -> u16 {
|
||||||
|
self.x.saturating_add(self.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn top(self) -> u16 {
|
||||||
|
self.y
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bottom(self) -> u16 {
|
||||||
|
self.y.saturating_add(self.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inner(self, margin: &Margin) -> Rect {
|
||||||
|
if self.width < 2 * margin.horizontal || self.height < 2 * margin.vertical {
|
||||||
|
Rect::default()
|
||||||
|
} else {
|
||||||
|
Rect {
|
||||||
|
x: self.x + margin.horizontal,
|
||||||
|
y: self.y + margin.vertical,
|
||||||
|
width: self.width - 2 * margin.horizontal,
|
||||||
|
height: self.height - 2 * margin.vertical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn union(self, other: Rect) -> Rect {
|
||||||
|
let x1 = min(self.x, other.x);
|
||||||
|
let y1 = min(self.y, other.y);
|
||||||
|
let x2 = max(self.x + self.width, other.x + other.width);
|
||||||
|
let y2 = max(self.y + self.height, other.y + other.height);
|
||||||
|
Rect {
|
||||||
|
x: x1,
|
||||||
|
y: y1,
|
||||||
|
width: x2 - x1,
|
||||||
|
height: y2 - y1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn intersection(self, other: Rect) -> Rect {
|
||||||
|
let x1 = max(self.x, other.x);
|
||||||
|
let y1 = max(self.y, other.y);
|
||||||
|
let x2 = min(self.x + self.width, other.x + other.width);
|
||||||
|
let y2 = min(self.y + self.height, other.y + other.height);
|
||||||
|
Rect {
|
||||||
|
x: x1,
|
||||||
|
y: y1,
|
||||||
|
width: x2 - x1,
|
||||||
|
height: y2 - y1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn intersects(self, other: Rect) -> bool {
|
||||||
|
self.x < other.x + other.width
|
||||||
|
&& self.x + self.width > other.x
|
||||||
|
&& self.y < other.y + other.height
|
||||||
|
&& self.y + self.height > other.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vertical_split_by_height() {
|
||||||
|
let target = Rect {
|
||||||
|
x: 2,
|
||||||
|
y: 2,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints(
|
||||||
|
[
|
||||||
|
Constraint::Percentage(10),
|
||||||
|
Constraint::Max(5),
|
||||||
|
Constraint::Min(1),
|
||||||
|
]
|
||||||
|
.as_ref(),
|
||||||
|
)
|
||||||
|
.split(target);
|
||||||
|
|
||||||
|
assert_eq!(target.height, chunks.iter().map(|r| r.height).sum::<u16>());
|
||||||
|
chunks.windows(2).for_each(|w| assert!(w[0].y <= w[1].y));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rect_size_truncation() {
|
||||||
|
for width in 256u16..300u16 {
|
||||||
|
for height in 256u16..300u16 {
|
||||||
|
let rect = Rect::new(0, 0, width, height);
|
||||||
|
rect.area(); // Should not panic.
|
||||||
|
assert!(rect.width < width || rect.height < height);
|
||||||
|
// The target dimensions are rounded down so the math will not be too precise
|
||||||
|
// but let's make sure the ratios don't diverge crazily.
|
||||||
|
assert!(
|
||||||
|
(f64::from(rect.width) / f64::from(rect.height)
|
||||||
|
- f64::from(width) / f64::from(height))
|
||||||
|
.abs()
|
||||||
|
< 1.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One dimension below 255, one above. Area above max u16.
|
||||||
|
let width = 900;
|
||||||
|
let height = 100;
|
||||||
|
let rect = Rect::new(0, 0, width, height);
|
||||||
|
assert_ne!(rect.width, 900);
|
||||||
|
assert_ne!(rect.height, 100);
|
||||||
|
assert!(rect.width < width || rect.height < height);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rect_size_preservation() {
|
||||||
|
for width in 0..256u16 {
|
||||||
|
for height in 0..256u16 {
|
||||||
|
let rect = Rect::new(0, 0, width, height);
|
||||||
|
rect.area(); // Should not panic.
|
||||||
|
assert_eq!(rect.width, width);
|
||||||
|
assert_eq!(rect.height, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One dimension below 255, one above. Area below max u16.
|
||||||
|
let rect = Rect::new(0, 0, 300, 100);
|
||||||
|
assert_eq!(rect.width, 300);
|
||||||
|
assert_eq!(rect.height, 100);
|
||||||
|
}
|
||||||
|
}
|
177
src/ratatui/mod.rs
Normal file
177
src/ratatui/mod.rs
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
#![allow(clippy::all)]
|
||||||
|
#![allow(warnings)]
|
||||||
|
|
||||||
|
//! [ratatui](https://github.com/tui-rs-revival/ratatui) is a library used to build rich
|
||||||
|
//! terminal users interfaces and dashboards.
|
||||||
|
//!
|
||||||
|
//! ![](https://raw.githubusercontent.com/tui-rs-revival/ratatui/master/assets/demo.gif)
|
||||||
|
//!
|
||||||
|
//! # Get started
|
||||||
|
//!
|
||||||
|
//! ## Adding `ratatui` as a dependency
|
||||||
|
//!
|
||||||
|
//! Add the following to your `Cargo.toml`:
|
||||||
|
//! ```toml
|
||||||
|
//! [dependencies]
|
||||||
|
//! crossterm = "0.26"
|
||||||
|
//! ratatui = "0.20"
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The crate is using the `crossterm` backend by default that works on most platforms. But if for
|
||||||
|
//! example you want to use the `termion` backend instead. This can be done by changing your
|
||||||
|
//! dependencies specification to the following:
|
||||||
|
//!
|
||||||
|
//! ```toml
|
||||||
|
//! [dependencies]
|
||||||
|
//! termion = "1.5"
|
||||||
|
//! ratatui = { version = "0.20", default-features = false, features = ['termion'] }
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The same logic applies for all other available backends.
|
||||||
|
//!
|
||||||
|
//! ## Creating a `Terminal`
|
||||||
|
//!
|
||||||
|
//! Every application using `ratatui` should start by instantiating a `Terminal`. It is a light
|
||||||
|
//! abstraction over available backends that provides basic functionalities such as clearing the
|
||||||
|
//! screen, hiding the cursor, etc.
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use std::io;
|
||||||
|
//! use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
//!
|
||||||
|
//! fn main() -> Result<(), io::Error> {
|
||||||
|
//! let stdout = io::stdout();
|
||||||
|
//! let backend = CrosstermBackend::new(stdout);
|
||||||
|
//! let mut terminal = Terminal::new(backend)?;
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! If you had previously chosen `termion` as a backend, the terminal can be created in a similar
|
||||||
|
//! way:
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use std::io;
|
||||||
|
//! use ratatui::{backend::TermionBackend, Terminal};
|
||||||
|
//! use termion::raw::IntoRawMode;
|
||||||
|
//!
|
||||||
|
//! fn main() -> Result<(), io::Error> {
|
||||||
|
//! let stdout = io::stdout().into_raw_mode()?;
|
||||||
|
//! let backend = TermionBackend::new(stdout);
|
||||||
|
//! let mut terminal = Terminal::new(backend)?;
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! You may also refer to the examples to find out how to create a `Terminal` for each available
|
||||||
|
//! backend.
|
||||||
|
//!
|
||||||
|
//! ## Building a User Interface (UI)
|
||||||
|
//!
|
||||||
|
//! Every component of your interface will be implementing the `Widget` trait. The library comes
|
||||||
|
//! with a predefined set of widgets that should meet most of your use cases. You are also free to
|
||||||
|
//! implement your own.
|
||||||
|
//!
|
||||||
|
//! Each widget follows a builder pattern API providing a default configuration along with methods
|
||||||
|
//! to customize them. The widget is then rendered using [`Frame::render_widget`] which takes
|
||||||
|
//! your widget instance and an area to draw to.
|
||||||
|
//!
|
||||||
|
//! The following example renders a block of the size of the terminal:
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use std::{io, thread, time::Duration};
|
||||||
|
//! use ratatui::{
|
||||||
|
//! backend::CrosstermBackend,
|
||||||
|
//! widgets::{Widget, Block, Borders},
|
||||||
|
//! layout::{Layout, Constraint, Direction},
|
||||||
|
//! Terminal
|
||||||
|
//! };
|
||||||
|
//! use crossterm::{
|
||||||
|
//! event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
|
||||||
|
//! execute,
|
||||||
|
//! terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
//! };
|
||||||
|
//!
|
||||||
|
//! fn main() -> Result<(), io::Error> {
|
||||||
|
//! // setup terminal
|
||||||
|
//! enable_raw_mode()?;
|
||||||
|
//! let mut stdout = io::stdout();
|
||||||
|
//! execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
//! let backend = CrosstermBackend::new(stdout);
|
||||||
|
//! let mut terminal = Terminal::new(backend)?;
|
||||||
|
//!
|
||||||
|
//! terminal.draw(|f| {
|
||||||
|
//! let size = f.size();
|
||||||
|
//! let block = Block::default()
|
||||||
|
//! .title("Block")
|
||||||
|
//! .borders(Borders::ALL);
|
||||||
|
//! f.render_widget(block, size);
|
||||||
|
//! })?;
|
||||||
|
//!
|
||||||
|
//! thread::sleep(Duration::from_millis(5000));
|
||||||
|
//!
|
||||||
|
//! // restore terminal
|
||||||
|
//! disable_raw_mode()?;
|
||||||
|
//! execute!(
|
||||||
|
//! terminal.backend_mut(),
|
||||||
|
//! LeaveAlternateScreen,
|
||||||
|
//! DisableMouseCapture
|
||||||
|
//! )?;
|
||||||
|
//! terminal.show_cursor()?;
|
||||||
|
//!
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Layout
|
||||||
|
//!
|
||||||
|
//! The library comes with a basic yet useful layout management object called `Layout`. As you may
|
||||||
|
//! see below and in the examples, the library makes heavy use of the builder pattern to provide
|
||||||
|
//! full customization. And `Layout` is no exception:
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use ratatui::{
|
||||||
|
//! backend::Backend,
|
||||||
|
//! layout::{Constraint, Direction, Layout},
|
||||||
|
//! widgets::{Block, Borders},
|
||||||
|
//! Frame,
|
||||||
|
//! };
|
||||||
|
//! fn ui<B: Backend>(f: &mut Frame<B>) {
|
||||||
|
//! let chunks = Layout::default()
|
||||||
|
//! .direction(Direction::Vertical)
|
||||||
|
//! .margin(1)
|
||||||
|
//! .constraints(
|
||||||
|
//! [
|
||||||
|
//! Constraint::Percentage(10),
|
||||||
|
//! Constraint::Percentage(80),
|
||||||
|
//! Constraint::Percentage(10)
|
||||||
|
//! ].as_ref()
|
||||||
|
//! )
|
||||||
|
//! .split(f.size());
|
||||||
|
//! let block = Block::default()
|
||||||
|
//! .title("Block")
|
||||||
|
//! .borders(Borders::ALL);
|
||||||
|
//! f.render_widget(block, chunks[0]);
|
||||||
|
//! let block = Block::default()
|
||||||
|
//! .title("Block 2")
|
||||||
|
//! .borders(Borders::ALL);
|
||||||
|
//! f.render_widget(block, chunks[1]);
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This let you describe responsive terminal UI by nesting layouts. You should note that by
|
||||||
|
//! default the computed layout tries to fill the available space completely. So if for any reason
|
||||||
|
//! you might need a blank space somewhere, try to pass an additional constraint and don't use the
|
||||||
|
//! corresponding area.
|
||||||
|
|
||||||
|
pub mod backend;
|
||||||
|
pub mod buffer;
|
||||||
|
pub mod layout;
|
||||||
|
pub mod style;
|
||||||
|
pub mod symbols;
|
||||||
|
pub mod terminal;
|
||||||
|
pub mod text;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
|
pub use self::terminal::{Frame, Terminal, TerminalOptions, Viewport};
|
310
src/ratatui/style.rs
Normal file
310
src/ratatui/style.rs
Normal file
|
@ -0,0 +1,310 @@
|
||||||
|
//! `style` contains the primitives used to control how your user interface will look.
|
||||||
|
|
||||||
|
use bitflags::bitflags;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||||
|
pub enum Color {
|
||||||
|
Reset,
|
||||||
|
Black,
|
||||||
|
Red,
|
||||||
|
Green,
|
||||||
|
Yellow,
|
||||||
|
Blue,
|
||||||
|
Magenta,
|
||||||
|
Cyan,
|
||||||
|
Gray,
|
||||||
|
DarkGray,
|
||||||
|
LightRed,
|
||||||
|
LightGreen,
|
||||||
|
LightYellow,
|
||||||
|
LightBlue,
|
||||||
|
LightMagenta,
|
||||||
|
LightCyan,
|
||||||
|
White,
|
||||||
|
Rgb(u8, u8, u8),
|
||||||
|
Indexed(u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
bitflags! {
|
||||||
|
/// Modifier changes the way a piece of text is displayed.
|
||||||
|
///
|
||||||
|
/// They are bitflags so they can easily be composed.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::style::Modifier;
|
||||||
|
///
|
||||||
|
/// let m = Modifier::BOLD | Modifier::ITALIC;
|
||||||
|
/// ```
|
||||||
|
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||||
|
pub struct Modifier: u16 {
|
||||||
|
const BOLD = 0b0000_0000_0001;
|
||||||
|
const DIM = 0b0000_0000_0010;
|
||||||
|
const ITALIC = 0b0000_0000_0100;
|
||||||
|
const UNDERLINED = 0b0000_0000_1000;
|
||||||
|
const SLOW_BLINK = 0b0000_0001_0000;
|
||||||
|
const RAPID_BLINK = 0b0000_0010_0000;
|
||||||
|
const REVERSED = 0b0000_0100_0000;
|
||||||
|
const HIDDEN = 0b0000_1000_0000;
|
||||||
|
const CROSSED_OUT = 0b0001_0000_0000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style let you control the main characteristics of the displayed elements.
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::style::{Color, Modifier, Style};
|
||||||
|
/// Style::default()
|
||||||
|
/// .fg(Color::Black)
|
||||||
|
/// .bg(Color::Green)
|
||||||
|
/// .add_modifier(Modifier::ITALIC | Modifier::BOLD);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// It represents an incremental change. If you apply the styles S1, S2, S3 to a cell of the
|
||||||
|
/// terminal buffer, the style of this cell will be the result of the merge of S1, S2 and S3, not
|
||||||
|
/// just S3.
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::style::{Color, Modifier, Style};
|
||||||
|
/// # use ratatui::buffer::Buffer;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// let styles = [
|
||||||
|
/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC),
|
||||||
|
/// Style::default().bg(Color::Red),
|
||||||
|
/// Style::default().fg(Color::Yellow).remove_modifier(Modifier::ITALIC),
|
||||||
|
/// ];
|
||||||
|
/// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
|
||||||
|
/// for style in &styles {
|
||||||
|
/// buffer.get_mut(0, 0).set_style(*style);
|
||||||
|
/// }
|
||||||
|
/// assert_eq!(
|
||||||
|
/// Style {
|
||||||
|
/// fg: Some(Color::Yellow),
|
||||||
|
/// bg: Some(Color::Red),
|
||||||
|
/// add_modifier: Modifier::BOLD,
|
||||||
|
/// sub_modifier: Modifier::empty(),
|
||||||
|
/// },
|
||||||
|
/// buffer.get(0, 0).style(),
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The default implementation returns a `Style` that does not modify anything. If you wish to
|
||||||
|
/// reset all properties until that point use [`Style::reset`].
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::style::{Color, Modifier, Style};
|
||||||
|
/// # use ratatui::buffer::Buffer;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// let styles = [
|
||||||
|
/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC),
|
||||||
|
/// Style::reset().fg(Color::Yellow),
|
||||||
|
/// ];
|
||||||
|
/// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
|
||||||
|
/// for style in &styles {
|
||||||
|
/// buffer.get_mut(0, 0).set_style(*style);
|
||||||
|
/// }
|
||||||
|
/// assert_eq!(
|
||||||
|
/// Style {
|
||||||
|
/// fg: Some(Color::Yellow),
|
||||||
|
/// bg: Some(Color::Reset),
|
||||||
|
/// add_modifier: Modifier::empty(),
|
||||||
|
/// sub_modifier: Modifier::empty(),
|
||||||
|
/// },
|
||||||
|
/// buffer.get(0, 0).style(),
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||||
|
pub struct Style {
|
||||||
|
pub fg: Option<Color>,
|
||||||
|
pub bg: Option<Color>,
|
||||||
|
pub add_modifier: Modifier,
|
||||||
|
pub sub_modifier: Modifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Style {
|
||||||
|
fn default() -> Style {
|
||||||
|
Style {
|
||||||
|
fg: None,
|
||||||
|
bg: None,
|
||||||
|
add_modifier: Modifier::empty(),
|
||||||
|
sub_modifier: Modifier::empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Style {
|
||||||
|
/// Returns a `Style` resetting all properties.
|
||||||
|
pub fn reset() -> Style {
|
||||||
|
Style {
|
||||||
|
fg: Some(Color::Reset),
|
||||||
|
bg: Some(Color::Reset),
|
||||||
|
add_modifier: Modifier::empty(),
|
||||||
|
sub_modifier: Modifier::all(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes the foreground color.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::style::{Color, Style};
|
||||||
|
/// let style = Style::default().fg(Color::Blue);
|
||||||
|
/// let diff = Style::default().fg(Color::Red);
|
||||||
|
/// assert_eq!(style.patch(diff), Style::default().fg(Color::Red));
|
||||||
|
/// ```
|
||||||
|
pub fn fg(mut self, color: Color) -> Style {
|
||||||
|
self.fg = Some(color);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes the background color.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::style::{Color, Style};
|
||||||
|
/// let style = Style::default().bg(Color::Blue);
|
||||||
|
/// let diff = Style::default().bg(Color::Red);
|
||||||
|
/// assert_eq!(style.patch(diff), Style::default().bg(Color::Red));
|
||||||
|
/// ```
|
||||||
|
pub fn bg(mut self, color: Color) -> Style {
|
||||||
|
self.bg = Some(color);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes the text emphasis.
|
||||||
|
///
|
||||||
|
/// When applied, it adds the given modifier to the `Style` modifiers.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::style::{Color, Modifier, Style};
|
||||||
|
/// let style = Style::default().add_modifier(Modifier::BOLD);
|
||||||
|
/// let diff = Style::default().add_modifier(Modifier::ITALIC);
|
||||||
|
/// let patched = style.patch(diff);
|
||||||
|
/// assert_eq!(patched.add_modifier, Modifier::BOLD | Modifier::ITALIC);
|
||||||
|
/// assert_eq!(patched.sub_modifier, Modifier::empty());
|
||||||
|
/// ```
|
||||||
|
pub fn add_modifier(mut self, modifier: Modifier) -> Style {
|
||||||
|
self.sub_modifier.remove(modifier);
|
||||||
|
self.add_modifier.insert(modifier);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes the text emphasis.
|
||||||
|
///
|
||||||
|
/// When applied, it removes the given modifier from the `Style` modifiers.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::style::{Color, Modifier, Style};
|
||||||
|
/// let style = Style::default().add_modifier(Modifier::BOLD | Modifier::ITALIC);
|
||||||
|
/// let diff = Style::default().remove_modifier(Modifier::ITALIC);
|
||||||
|
/// let patched = style.patch(diff);
|
||||||
|
/// assert_eq!(patched.add_modifier, Modifier::BOLD);
|
||||||
|
/// assert_eq!(patched.sub_modifier, Modifier::ITALIC);
|
||||||
|
/// ```
|
||||||
|
pub fn remove_modifier(mut self, modifier: Modifier) -> Style {
|
||||||
|
self.add_modifier.remove(modifier);
|
||||||
|
self.sub_modifier.insert(modifier);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Results in a combined style that is equivalent to applying the two individual styles to
|
||||||
|
/// a style one after the other.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::style::{Color, Modifier, Style};
|
||||||
|
/// let style_1 = Style::default().fg(Color::Yellow);
|
||||||
|
/// let style_2 = Style::default().bg(Color::Red);
|
||||||
|
/// let combined = style_1.patch(style_2);
|
||||||
|
/// assert_eq!(
|
||||||
|
/// Style::default().patch(style_1).patch(style_2),
|
||||||
|
/// Style::default().patch(combined));
|
||||||
|
/// ```
|
||||||
|
pub fn patch(mut self, other: Style) -> Style {
|
||||||
|
self.fg = other.fg.or(self.fg);
|
||||||
|
self.bg = other.bg.or(self.bg);
|
||||||
|
|
||||||
|
self.add_modifier.remove(other.sub_modifier);
|
||||||
|
self.add_modifier.insert(other.add_modifier);
|
||||||
|
self.sub_modifier.remove(other.add_modifier);
|
||||||
|
self.sub_modifier.insert(other.sub_modifier);
|
||||||
|
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn styles() -> Vec<Style> {
|
||||||
|
vec![
|
||||||
|
Style::default(),
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
Style::default().bg(Color::Yellow),
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
Style::default().remove_modifier(Modifier::BOLD),
|
||||||
|
Style::default().add_modifier(Modifier::ITALIC),
|
||||||
|
Style::default().remove_modifier(Modifier::ITALIC),
|
||||||
|
Style::default().add_modifier(Modifier::ITALIC | Modifier::BOLD),
|
||||||
|
Style::default().remove_modifier(Modifier::ITALIC | Modifier::BOLD),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combined_patch_gives_same_result_as_individual_patch() {
|
||||||
|
let styles = styles();
|
||||||
|
for &a in &styles {
|
||||||
|
for &b in &styles {
|
||||||
|
for &c in &styles {
|
||||||
|
for &d in &styles {
|
||||||
|
let combined = a.patch(b.patch(c.patch(d)));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Style::default().patch(a).patch(b).patch(c).patch(d),
|
||||||
|
Style::default().patch(combined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combine_individual_modifiers() {
|
||||||
|
use crate::ratatui::{buffer::Buffer, layout::Rect};
|
||||||
|
|
||||||
|
let mods = vec![
|
||||||
|
Modifier::BOLD,
|
||||||
|
Modifier::DIM,
|
||||||
|
Modifier::ITALIC,
|
||||||
|
Modifier::UNDERLINED,
|
||||||
|
Modifier::SLOW_BLINK,
|
||||||
|
Modifier::RAPID_BLINK,
|
||||||
|
Modifier::REVERSED,
|
||||||
|
Modifier::HIDDEN,
|
||||||
|
Modifier::CROSSED_OUT,
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
|
||||||
|
|
||||||
|
for m in &mods {
|
||||||
|
buffer.get_mut(0, 0).set_style(Style::reset());
|
||||||
|
buffer
|
||||||
|
.get_mut(0, 0)
|
||||||
|
.set_style(Style::default().add_modifier(*m));
|
||||||
|
let style = buffer.get(0, 0).style();
|
||||||
|
assert!(style.add_modifier.contains(*m));
|
||||||
|
assert!(!style.sub_modifier.contains(*m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
233
src/ratatui/symbols.rs
Normal file
233
src/ratatui/symbols.rs
Normal file
|
@ -0,0 +1,233 @@
|
||||||
|
pub mod block {
|
||||||
|
pub const FULL: &str = "█";
|
||||||
|
pub const SEVEN_EIGHTHS: &str = "▉";
|
||||||
|
pub const THREE_QUARTERS: &str = "▊";
|
||||||
|
pub const FIVE_EIGHTHS: &str = "▋";
|
||||||
|
pub const HALF: &str = "▌";
|
||||||
|
pub const THREE_EIGHTHS: &str = "▍";
|
||||||
|
pub const ONE_QUARTER: &str = "▎";
|
||||||
|
pub const ONE_EIGHTH: &str = "▏";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Set {
|
||||||
|
pub full: &'static str,
|
||||||
|
pub seven_eighths: &'static str,
|
||||||
|
pub three_quarters: &'static str,
|
||||||
|
pub five_eighths: &'static str,
|
||||||
|
pub half: &'static str,
|
||||||
|
pub three_eighths: &'static str,
|
||||||
|
pub one_quarter: &'static str,
|
||||||
|
pub one_eighth: &'static str,
|
||||||
|
pub empty: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const THREE_LEVELS: Set = Set {
|
||||||
|
full: FULL,
|
||||||
|
seven_eighths: FULL,
|
||||||
|
three_quarters: HALF,
|
||||||
|
five_eighths: HALF,
|
||||||
|
half: HALF,
|
||||||
|
three_eighths: HALF,
|
||||||
|
one_quarter: HALF,
|
||||||
|
one_eighth: " ",
|
||||||
|
empty: " ",
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const NINE_LEVELS: Set = Set {
|
||||||
|
full: FULL,
|
||||||
|
seven_eighths: SEVEN_EIGHTHS,
|
||||||
|
three_quarters: THREE_QUARTERS,
|
||||||
|
five_eighths: FIVE_EIGHTHS,
|
||||||
|
half: HALF,
|
||||||
|
three_eighths: THREE_EIGHTHS,
|
||||||
|
one_quarter: ONE_QUARTER,
|
||||||
|
one_eighth: ONE_EIGHTH,
|
||||||
|
empty: " ",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod bar {
|
||||||
|
pub const FULL: &str = "█";
|
||||||
|
pub const SEVEN_EIGHTHS: &str = "▇";
|
||||||
|
pub const THREE_QUARTERS: &str = "▆";
|
||||||
|
pub const FIVE_EIGHTHS: &str = "▅";
|
||||||
|
pub const HALF: &str = "▄";
|
||||||
|
pub const THREE_EIGHTHS: &str = "▃";
|
||||||
|
pub const ONE_QUARTER: &str = "▂";
|
||||||
|
pub const ONE_EIGHTH: &str = "▁";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Set {
|
||||||
|
pub full: &'static str,
|
||||||
|
pub seven_eighths: &'static str,
|
||||||
|
pub three_quarters: &'static str,
|
||||||
|
pub five_eighths: &'static str,
|
||||||
|
pub half: &'static str,
|
||||||
|
pub three_eighths: &'static str,
|
||||||
|
pub one_quarter: &'static str,
|
||||||
|
pub one_eighth: &'static str,
|
||||||
|
pub empty: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const THREE_LEVELS: Set = Set {
|
||||||
|
full: FULL,
|
||||||
|
seven_eighths: FULL,
|
||||||
|
three_quarters: HALF,
|
||||||
|
five_eighths: HALF,
|
||||||
|
half: HALF,
|
||||||
|
three_eighths: HALF,
|
||||||
|
one_quarter: HALF,
|
||||||
|
one_eighth: " ",
|
||||||
|
empty: " ",
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const NINE_LEVELS: Set = Set {
|
||||||
|
full: FULL,
|
||||||
|
seven_eighths: SEVEN_EIGHTHS,
|
||||||
|
three_quarters: THREE_QUARTERS,
|
||||||
|
five_eighths: FIVE_EIGHTHS,
|
||||||
|
half: HALF,
|
||||||
|
three_eighths: THREE_EIGHTHS,
|
||||||
|
one_quarter: ONE_QUARTER,
|
||||||
|
one_eighth: ONE_EIGHTH,
|
||||||
|
empty: " ",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod line {
|
||||||
|
pub const VERTICAL: &str = "│";
|
||||||
|
pub const DOUBLE_VERTICAL: &str = "║";
|
||||||
|
pub const THICK_VERTICAL: &str = "┃";
|
||||||
|
|
||||||
|
pub const HORIZONTAL: &str = "─";
|
||||||
|
pub const DOUBLE_HORIZONTAL: &str = "═";
|
||||||
|
pub const THICK_HORIZONTAL: &str = "━";
|
||||||
|
|
||||||
|
pub const TOP_RIGHT: &str = "┐";
|
||||||
|
pub const ROUNDED_TOP_RIGHT: &str = "╮";
|
||||||
|
pub const DOUBLE_TOP_RIGHT: &str = "╗";
|
||||||
|
pub const THICK_TOP_RIGHT: &str = "┓";
|
||||||
|
|
||||||
|
pub const TOP_LEFT: &str = "┌";
|
||||||
|
pub const ROUNDED_TOP_LEFT: &str = "╭";
|
||||||
|
pub const DOUBLE_TOP_LEFT: &str = "╔";
|
||||||
|
pub const THICK_TOP_LEFT: &str = "┏";
|
||||||
|
|
||||||
|
pub const BOTTOM_RIGHT: &str = "┘";
|
||||||
|
pub const ROUNDED_BOTTOM_RIGHT: &str = "╯";
|
||||||
|
pub const DOUBLE_BOTTOM_RIGHT: &str = "╝";
|
||||||
|
pub const THICK_BOTTOM_RIGHT: &str = "┛";
|
||||||
|
|
||||||
|
pub const BOTTOM_LEFT: &str = "└";
|
||||||
|
pub const ROUNDED_BOTTOM_LEFT: &str = "╰";
|
||||||
|
pub const DOUBLE_BOTTOM_LEFT: &str = "╚";
|
||||||
|
pub const THICK_BOTTOM_LEFT: &str = "┗";
|
||||||
|
|
||||||
|
pub const VERTICAL_LEFT: &str = "┤";
|
||||||
|
pub const DOUBLE_VERTICAL_LEFT: &str = "╣";
|
||||||
|
pub const THICK_VERTICAL_LEFT: &str = "┫";
|
||||||
|
|
||||||
|
pub const VERTICAL_RIGHT: &str = "├";
|
||||||
|
pub const DOUBLE_VERTICAL_RIGHT: &str = "╠";
|
||||||
|
pub const THICK_VERTICAL_RIGHT: &str = "┣";
|
||||||
|
|
||||||
|
pub const HORIZONTAL_DOWN: &str = "┬";
|
||||||
|
pub const DOUBLE_HORIZONTAL_DOWN: &str = "╦";
|
||||||
|
pub const THICK_HORIZONTAL_DOWN: &str = "┳";
|
||||||
|
|
||||||
|
pub const HORIZONTAL_UP: &str = "┴";
|
||||||
|
pub const DOUBLE_HORIZONTAL_UP: &str = "╩";
|
||||||
|
pub const THICK_HORIZONTAL_UP: &str = "┻";
|
||||||
|
|
||||||
|
pub const CROSS: &str = "┼";
|
||||||
|
pub const DOUBLE_CROSS: &str = "╬";
|
||||||
|
pub const THICK_CROSS: &str = "╋";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Set {
|
||||||
|
pub vertical: &'static str,
|
||||||
|
pub horizontal: &'static str,
|
||||||
|
pub top_right: &'static str,
|
||||||
|
pub top_left: &'static str,
|
||||||
|
pub bottom_right: &'static str,
|
||||||
|
pub bottom_left: &'static str,
|
||||||
|
pub vertical_left: &'static str,
|
||||||
|
pub vertical_right: &'static str,
|
||||||
|
pub horizontal_down: &'static str,
|
||||||
|
pub horizontal_up: &'static str,
|
||||||
|
pub cross: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NORMAL: Set = Set {
|
||||||
|
vertical: VERTICAL,
|
||||||
|
horizontal: HORIZONTAL,
|
||||||
|
top_right: TOP_RIGHT,
|
||||||
|
top_left: TOP_LEFT,
|
||||||
|
bottom_right: BOTTOM_RIGHT,
|
||||||
|
bottom_left: BOTTOM_LEFT,
|
||||||
|
vertical_left: VERTICAL_LEFT,
|
||||||
|
vertical_right: VERTICAL_RIGHT,
|
||||||
|
horizontal_down: HORIZONTAL_DOWN,
|
||||||
|
horizontal_up: HORIZONTAL_UP,
|
||||||
|
cross: CROSS,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ROUNDED: Set = Set {
|
||||||
|
top_right: ROUNDED_TOP_RIGHT,
|
||||||
|
top_left: ROUNDED_TOP_LEFT,
|
||||||
|
bottom_right: ROUNDED_BOTTOM_RIGHT,
|
||||||
|
bottom_left: ROUNDED_BOTTOM_LEFT,
|
||||||
|
..NORMAL
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const DOUBLE: Set = Set {
|
||||||
|
vertical: DOUBLE_VERTICAL,
|
||||||
|
horizontal: DOUBLE_HORIZONTAL,
|
||||||
|
top_right: DOUBLE_TOP_RIGHT,
|
||||||
|
top_left: DOUBLE_TOP_LEFT,
|
||||||
|
bottom_right: DOUBLE_BOTTOM_RIGHT,
|
||||||
|
bottom_left: DOUBLE_BOTTOM_LEFT,
|
||||||
|
vertical_left: DOUBLE_VERTICAL_LEFT,
|
||||||
|
vertical_right: DOUBLE_VERTICAL_RIGHT,
|
||||||
|
horizontal_down: DOUBLE_HORIZONTAL_DOWN,
|
||||||
|
horizontal_up: DOUBLE_HORIZONTAL_UP,
|
||||||
|
cross: DOUBLE_CROSS,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const THICK: Set = Set {
|
||||||
|
vertical: THICK_VERTICAL,
|
||||||
|
horizontal: THICK_HORIZONTAL,
|
||||||
|
top_right: THICK_TOP_RIGHT,
|
||||||
|
top_left: THICK_TOP_LEFT,
|
||||||
|
bottom_right: THICK_BOTTOM_RIGHT,
|
||||||
|
bottom_left: THICK_BOTTOM_LEFT,
|
||||||
|
vertical_left: THICK_VERTICAL_LEFT,
|
||||||
|
vertical_right: THICK_VERTICAL_RIGHT,
|
||||||
|
horizontal_down: THICK_HORIZONTAL_DOWN,
|
||||||
|
horizontal_up: THICK_HORIZONTAL_UP,
|
||||||
|
cross: THICK_CROSS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const DOT: &str = "•";
|
||||||
|
|
||||||
|
pub mod braille {
|
||||||
|
pub const BLANK: u16 = 0x2800;
|
||||||
|
pub const DOTS: [[u16; 2]; 4] = [
|
||||||
|
[0x0001, 0x0008],
|
||||||
|
[0x0002, 0x0010],
|
||||||
|
[0x0004, 0x0020],
|
||||||
|
[0x0040, 0x0080],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker to use when plotting data points
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Marker {
|
||||||
|
/// One point per cell in shape of dot
|
||||||
|
Dot,
|
||||||
|
/// One point per cell in shape of a block
|
||||||
|
Block,
|
||||||
|
/// Up to 8 points per cell
|
||||||
|
Braille,
|
||||||
|
}
|
487
src/ratatui/terminal.rs
Normal file
487
src/ratatui/terminal.rs
Normal file
|
@ -0,0 +1,487 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
backend::{Backend, ClearType},
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::Rect,
|
||||||
|
widgets::{StatefulWidget, Widget},
|
||||||
|
};
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Viewport {
|
||||||
|
Fullscreen,
|
||||||
|
Inline(u16),
|
||||||
|
Fixed(Rect),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewport {
|
||||||
|
pub fn fixed(area: Rect) -> Viewport {
|
||||||
|
Self::Fixed(area)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
/// Options to pass to [`Terminal::with_options`]
|
||||||
|
pub struct TerminalOptions {
|
||||||
|
/// Viewport used to draw to the terminal
|
||||||
|
pub viewport: Viewport,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interface to the terminal backed by Termion
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Terminal<B>
|
||||||
|
where
|
||||||
|
B: Backend,
|
||||||
|
{
|
||||||
|
backend: B,
|
||||||
|
/// Holds the results of the current and previous draw calls. The two are compared at the end
|
||||||
|
/// of each draw pass to output the necessary updates to the terminal
|
||||||
|
buffers: [Buffer; 2],
|
||||||
|
/// Index of the current buffer in the previous array
|
||||||
|
current: usize,
|
||||||
|
/// Whether the cursor is currently hidden
|
||||||
|
hidden_cursor: bool,
|
||||||
|
/// Viewport
|
||||||
|
viewport: Viewport,
|
||||||
|
viewport_area: Rect,
|
||||||
|
/// Last known size of the terminal. Used to detect if the internal buffers have to be resized.
|
||||||
|
last_known_size: Rect,
|
||||||
|
/// Last known position of the cursor. Used to find the new area when the viewport is inlined
|
||||||
|
/// and the terminal resized.
|
||||||
|
last_known_cursor_pos: (u16, u16),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a consistent terminal interface for rendering.
|
||||||
|
pub struct Frame<'a, B: 'a>
|
||||||
|
where
|
||||||
|
B: Backend,
|
||||||
|
{
|
||||||
|
terminal: &'a mut Terminal<B>,
|
||||||
|
|
||||||
|
/// Where should the cursor be after drawing this frame?
|
||||||
|
///
|
||||||
|
/// If `None`, the cursor is hidden and its position is controlled by the backend. If `Some((x,
|
||||||
|
/// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`.
|
||||||
|
cursor_position: Option<(u16, u16)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, B> Frame<'a, B>
|
||||||
|
where
|
||||||
|
B: Backend,
|
||||||
|
{
|
||||||
|
/// Frame size, guaranteed not to change when rendering.
|
||||||
|
pub fn size(&self) -> Rect {
|
||||||
|
self.terminal.viewport_area
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a [`Widget`] to the current buffer using [`Widget::render`].
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::Terminal;
|
||||||
|
/// # use ratatui::backend::TestBackend;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// # use ratatui::widgets::Block;
|
||||||
|
/// # let backend = TestBackend::new(5, 5);
|
||||||
|
/// # let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
/// let block = Block::default();
|
||||||
|
/// let area = Rect::new(0, 0, 5, 5);
|
||||||
|
/// let mut frame = terminal.get_frame();
|
||||||
|
/// frame.render_widget(block, area);
|
||||||
|
/// ```
|
||||||
|
pub fn render_widget<W>(&mut self, widget: W, area: Rect)
|
||||||
|
where
|
||||||
|
W: Widget,
|
||||||
|
{
|
||||||
|
widget.render(area, self.terminal.current_buffer_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a [`StatefulWidget`] to the current buffer using [`StatefulWidget::render`].
|
||||||
|
///
|
||||||
|
/// The last argument should be an instance of the [`StatefulWidget::State`] associated to the
|
||||||
|
/// given [`StatefulWidget`].
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::Terminal;
|
||||||
|
/// # use ratatui::backend::TestBackend;
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// # use ratatui::widgets::{List, ListItem, ListState};
|
||||||
|
/// # let backend = TestBackend::new(5, 5);
|
||||||
|
/// # let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
/// let mut state = ListState::default();
|
||||||
|
/// state.select(Some(1));
|
||||||
|
/// let items = vec![
|
||||||
|
/// ListItem::new("Item 1"),
|
||||||
|
/// ListItem::new("Item 2"),
|
||||||
|
/// ];
|
||||||
|
/// let list = List::new(items);
|
||||||
|
/// let area = Rect::new(0, 0, 5, 5);
|
||||||
|
/// let mut frame = terminal.get_frame();
|
||||||
|
/// frame.render_stateful_widget(list, area, &mut state);
|
||||||
|
/// ```
|
||||||
|
pub fn render_stateful_widget<W>(&mut self, widget: W, area: Rect, state: &mut W::State)
|
||||||
|
where
|
||||||
|
W: StatefulWidget,
|
||||||
|
{
|
||||||
|
widget.render(area, self.terminal.current_buffer_mut(), state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After drawing this frame, make the cursor visible and put it at the specified (x, y)
|
||||||
|
/// coordinates. If this method is not called, the cursor will be hidden.
|
||||||
|
///
|
||||||
|
/// Note that this will interfere with calls to `Terminal::hide_cursor()`,
|
||||||
|
/// `Terminal::show_cursor()`, and `Terminal::set_cursor()`. Pick one of the APIs and stick
|
||||||
|
/// with it.
|
||||||
|
pub fn set_cursor(&mut self, x: u16, y: u16) {
|
||||||
|
self.cursor_position = Some((x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CompletedFrame represents the state of the terminal after all changes performed in the last
|
||||||
|
/// [`Terminal::draw`] call have been applied. Therefore, it is only valid until the next call to
|
||||||
|
/// [`Terminal::draw`].
|
||||||
|
pub struct CompletedFrame<'a> {
|
||||||
|
pub buffer: &'a Buffer,
|
||||||
|
pub area: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B> Drop for Terminal<B>
|
||||||
|
where
|
||||||
|
B: Backend,
|
||||||
|
{
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Attempt to restore the cursor state
|
||||||
|
if self.hidden_cursor {
|
||||||
|
if let Err(err) = self.show_cursor() {
|
||||||
|
eprintln!("Failed to show the cursor: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B> Terminal<B>
|
||||||
|
where
|
||||||
|
B: Backend,
|
||||||
|
{
|
||||||
|
/// Wrapper around Terminal initialization. Each buffer is initialized with a blank string and
|
||||||
|
/// default colors for the foreground and the background
|
||||||
|
pub fn new(backend: B) -> io::Result<Terminal<B>> {
|
||||||
|
Terminal::with_options(
|
||||||
|
backend,
|
||||||
|
TerminalOptions {
|
||||||
|
viewport: Viewport::Fullscreen,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_options(mut backend: B, options: TerminalOptions) -> io::Result<Terminal<B>> {
|
||||||
|
let size = match options.viewport {
|
||||||
|
Viewport::Fullscreen | Viewport::Inline(_) => backend.size()?,
|
||||||
|
Viewport::Fixed(area) => area,
|
||||||
|
};
|
||||||
|
let (viewport_area, cursor_pos) = match options.viewport {
|
||||||
|
Viewport::Fullscreen => (size, (0, 0)),
|
||||||
|
Viewport::Inline(height) => compute_inline_size(&mut backend, height, size, 0)?,
|
||||||
|
Viewport::Fixed(area) => (area, (area.left(), area.top())),
|
||||||
|
};
|
||||||
|
Ok(Terminal {
|
||||||
|
backend,
|
||||||
|
buffers: [Buffer::empty(viewport_area), Buffer::empty(viewport_area)],
|
||||||
|
current: 0,
|
||||||
|
hidden_cursor: false,
|
||||||
|
viewport: options.viewport,
|
||||||
|
viewport_area,
|
||||||
|
last_known_size: size,
|
||||||
|
last_known_cursor_pos: cursor_pos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a Frame object which provides a consistent view into the terminal state for rendering.
|
||||||
|
pub fn get_frame(&mut self) -> Frame<B> {
|
||||||
|
Frame {
|
||||||
|
terminal: self,
|
||||||
|
cursor_position: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_buffer_mut(&mut self) -> &mut Buffer {
|
||||||
|
&mut self.buffers[self.current]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backend(&self) -> &B {
|
||||||
|
&self.backend
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backend_mut(&mut self) -> &mut B {
|
||||||
|
&mut self.backend
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtains a difference between the previous and the current buffer and passes it to the
|
||||||
|
/// current backend for drawing.
|
||||||
|
pub fn flush(&mut self) -> io::Result<()> {
|
||||||
|
let previous_buffer = &self.buffers[1 - self.current];
|
||||||
|
let current_buffer = &self.buffers[self.current];
|
||||||
|
let updates = previous_buffer.diff(current_buffer);
|
||||||
|
if let Some((col, row, _)) = updates.last() {
|
||||||
|
self.last_known_cursor_pos = (*col, *row);
|
||||||
|
}
|
||||||
|
self.backend.draw(updates.into_iter())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the Terminal so that internal buffers match the requested size. Requested size will
|
||||||
|
/// be saved so the size can remain consistent when rendering.
|
||||||
|
/// This leads to a full clear of the screen.
|
||||||
|
pub fn resize(&mut self, size: Rect) -> io::Result<()> {
|
||||||
|
let next_area = match self.viewport {
|
||||||
|
Viewport::Fullscreen => size,
|
||||||
|
Viewport::Inline(height) => {
|
||||||
|
let offset_in_previous_viewport = self
|
||||||
|
.last_known_cursor_pos
|
||||||
|
.1
|
||||||
|
.saturating_sub(self.viewport_area.top());
|
||||||
|
compute_inline_size(&mut self.backend, height, size, offset_in_previous_viewport)?.0
|
||||||
|
}
|
||||||
|
Viewport::Fixed(area) => area,
|
||||||
|
};
|
||||||
|
self.set_viewport_area(next_area);
|
||||||
|
self.clear()?;
|
||||||
|
|
||||||
|
self.last_known_size = size;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_viewport_area(&mut self, area: Rect) {
|
||||||
|
self.buffers[self.current].resize(area);
|
||||||
|
self.buffers[1 - self.current].resize(area);
|
||||||
|
self.viewport_area = area;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queries the backend for size and resizes if it doesn't match the previous size.
|
||||||
|
pub fn autoresize(&mut self) -> io::Result<()> {
|
||||||
|
// fixed viewports do not get autoresized
|
||||||
|
if matches!(self.viewport, Viewport::Fullscreen | Viewport::Inline(_)) {
|
||||||
|
let size = self.size()?;
|
||||||
|
if size != self.last_known_size {
|
||||||
|
self.resize(size)?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronizes terminal size, calls the rendering closure, flushes the current internal state
|
||||||
|
/// and prepares for the next draw call.
|
||||||
|
pub fn draw<F>(&mut self, f: F) -> io::Result<CompletedFrame>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Frame<B>),
|
||||||
|
{
|
||||||
|
// Autoresize - otherwise we get glitches if shrinking or potential desync between widgets
|
||||||
|
// and the terminal (if growing), which may OOB.
|
||||||
|
self.autoresize()?;
|
||||||
|
|
||||||
|
let mut frame = self.get_frame();
|
||||||
|
f(&mut frame);
|
||||||
|
// We can't change the cursor position right away because we have to flush the frame to
|
||||||
|
// stdout first. But we also can't keep the frame around, since it holds a &mut to
|
||||||
|
// Terminal. Thus, we're taking the important data out of the Frame and dropping it.
|
||||||
|
let cursor_position = frame.cursor_position;
|
||||||
|
|
||||||
|
// Draw to stdout
|
||||||
|
self.flush()?;
|
||||||
|
|
||||||
|
match cursor_position {
|
||||||
|
None => self.hide_cursor()?,
|
||||||
|
Some((x, y)) => {
|
||||||
|
self.show_cursor()?;
|
||||||
|
self.set_cursor(x, y)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap buffers
|
||||||
|
self.buffers[1 - self.current].reset();
|
||||||
|
self.current = 1 - self.current;
|
||||||
|
|
||||||
|
// Flush
|
||||||
|
self.backend.flush()?;
|
||||||
|
|
||||||
|
Ok(CompletedFrame {
|
||||||
|
buffer: &self.buffers[1 - self.current],
|
||||||
|
area: self.last_known_size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hide_cursor(&mut self) -> io::Result<()> {
|
||||||
|
self.backend.hide_cursor()?;
|
||||||
|
self.hidden_cursor = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_cursor(&mut self) -> io::Result<()> {
|
||||||
|
self.backend.show_cursor()?;
|
||||||
|
self.hidden_cursor = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
|
||||||
|
self.backend.get_cursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
|
||||||
|
self.backend.set_cursor(x, y)?;
|
||||||
|
self.last_known_cursor_pos = (x, y);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the terminal and force a full redraw on the next draw call.
|
||||||
|
pub fn clear(&mut self) -> io::Result<()> {
|
||||||
|
match self.viewport {
|
||||||
|
Viewport::Fullscreen => self.backend.clear_region(ClearType::All)?,
|
||||||
|
Viewport::Inline(_) => {
|
||||||
|
self.backend
|
||||||
|
.set_cursor(self.viewport_area.left(), self.viewport_area.top())?;
|
||||||
|
self.backend.clear_region(ClearType::AfterCursor)?;
|
||||||
|
}
|
||||||
|
Viewport::Fixed(area) => {
|
||||||
|
for row in area.top()..area.bottom() {
|
||||||
|
self.backend.set_cursor(0, row)?;
|
||||||
|
self.backend.clear_region(ClearType::AfterCursor)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset the back buffer to make sure the next update will redraw everything.
|
||||||
|
self.buffers[1 - self.current].reset();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queries the real size of the backend.
|
||||||
|
pub fn size(&self) -> io::Result<Rect> {
|
||||||
|
self.backend.size()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert some content before the current inline viewport. This has no effect when the
|
||||||
|
/// viewport is fullscreen.
|
||||||
|
///
|
||||||
|
/// This function scrolls down the current viewport by the given height. The newly freed space is
|
||||||
|
/// then made available to the `draw_fn` closure through a writable `Buffer`.
|
||||||
|
///
|
||||||
|
/// Before:
|
||||||
|
/// ```ignore
|
||||||
|
/// +-------------------+
|
||||||
|
/// | |
|
||||||
|
/// | viewport |
|
||||||
|
/// | |
|
||||||
|
/// +-------------------+
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// After:
|
||||||
|
/// ```ignore
|
||||||
|
/// +-------------------+
|
||||||
|
/// | buffer |
|
||||||
|
/// +-------------------+
|
||||||
|
/// +-------------------+
|
||||||
|
/// | |
|
||||||
|
/// | viewport |
|
||||||
|
/// | |
|
||||||
|
/// +-------------------+
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ## Insert a single line before the current viewport
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::widgets::{Paragraph, Widget};
|
||||||
|
/// # use ratatui::text::{Spans, Span};
|
||||||
|
/// # use ratatui::style::{Color, Style};
|
||||||
|
/// # use ratatui::{Terminal};
|
||||||
|
/// # use ratatui::backend::TestBackend;
|
||||||
|
/// # let backend = TestBackend::new(10, 10);
|
||||||
|
/// # let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
/// terminal.insert_before(1, |buf| {
|
||||||
|
/// Paragraph::new(Spans::from(vec![
|
||||||
|
/// Span::raw("This line will be added "),
|
||||||
|
/// Span::styled("before", Style::default().fg(Color::Blue)),
|
||||||
|
/// Span::raw(" the current viewport")
|
||||||
|
/// ])).render(buf.area, buf);
|
||||||
|
/// });
|
||||||
|
/// ```
|
||||||
|
pub fn insert_before<F>(&mut self, height: u16, draw_fn: F) -> io::Result<()>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Buffer),
|
||||||
|
{
|
||||||
|
if !matches!(self.viewport, Viewport::Inline(_)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.clear()?;
|
||||||
|
let height = height.min(self.last_known_size.height);
|
||||||
|
self.backend.append_lines(height)?;
|
||||||
|
let missing_lines =
|
||||||
|
height.saturating_sub(self.last_known_size.bottom() - self.viewport_area.top());
|
||||||
|
let area = Rect {
|
||||||
|
x: self.viewport_area.left(),
|
||||||
|
y: self.viewport_area.top().saturating_sub(missing_lines),
|
||||||
|
width: self.viewport_area.width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
let mut buffer = Buffer::empty(area);
|
||||||
|
|
||||||
|
draw_fn(&mut buffer);
|
||||||
|
|
||||||
|
let iter = buffer.content.iter().enumerate().map(|(i, c)| {
|
||||||
|
let (x, y) = buffer.pos_of(i);
|
||||||
|
(x, y, c)
|
||||||
|
});
|
||||||
|
self.backend.draw(iter)?;
|
||||||
|
self.backend.flush()?;
|
||||||
|
|
||||||
|
let remaining_lines = self.last_known_size.height - area.bottom();
|
||||||
|
let missing_lines = self.viewport_area.height.saturating_sub(remaining_lines);
|
||||||
|
self.backend.append_lines(self.viewport_area.height)?;
|
||||||
|
|
||||||
|
self.set_viewport_area(Rect {
|
||||||
|
x: area.left(),
|
||||||
|
y: area.bottom().saturating_sub(missing_lines),
|
||||||
|
width: area.width,
|
||||||
|
height: self.viewport_area.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_inline_size<B: Backend>(
|
||||||
|
backend: &mut B,
|
||||||
|
height: u16,
|
||||||
|
size: Rect,
|
||||||
|
offset_in_previous_viewport: u16,
|
||||||
|
) -> io::Result<(Rect, (u16, u16))> {
|
||||||
|
let pos = backend.get_cursor()?;
|
||||||
|
let mut row = pos.1;
|
||||||
|
|
||||||
|
let max_height = size.height.min(height);
|
||||||
|
|
||||||
|
let lines_after_cursor = height
|
||||||
|
.saturating_sub(offset_in_previous_viewport)
|
||||||
|
.saturating_sub(1);
|
||||||
|
|
||||||
|
backend.append_lines(lines_after_cursor)?;
|
||||||
|
|
||||||
|
let available_lines = size.height.saturating_sub(row).saturating_sub(1);
|
||||||
|
let missing_lines = lines_after_cursor.saturating_sub(available_lines);
|
||||||
|
if missing_lines > 0 {
|
||||||
|
row = row.saturating_sub(missing_lines);
|
||||||
|
}
|
||||||
|
row = row.saturating_sub(offset_in_previous_viewport);
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: row,
|
||||||
|
width: size.width,
|
||||||
|
height: max_height,
|
||||||
|
},
|
||||||
|
pos,
|
||||||
|
))
|
||||||
|
}
|
430
src/ratatui/text.rs
Normal file
430
src/ratatui/text.rs
Normal file
|
@ -0,0 +1,430 @@
|
||||||
|
//! Primitives for styled text.
|
||||||
|
//!
|
||||||
|
//! A terminal UI is at its root a lot of strings. In order to make it accessible and stylish,
|
||||||
|
//! those strings may be associated to a set of styles. `ratatui` has three ways to represent them:
|
||||||
|
//! - A single line string where all graphemes have the same style is represented by a [`Span`].
|
||||||
|
//! - A single line string where each grapheme may have its own style is represented by [`Spans`].
|
||||||
|
//! - A multiple line string where each grapheme may have its own style is represented by a
|
||||||
|
//! [`Text`].
|
||||||
|
//!
|
||||||
|
//! These types form a hierarchy: [`Spans`] is a collection of [`Span`] and each line of [`Text`]
|
||||||
|
//! is a [`Spans`].
|
||||||
|
//!
|
||||||
|
//! Keep it mind that a lot of widgets will use those types to advertise what kind of string is
|
||||||
|
//! supported for their properties. Moreover, `ratatui` provides convenient `From` implementations so
|
||||||
|
//! that you can start by using simple `String` or `&str` and then promote them to the previous
|
||||||
|
//! primitives when you need additional styling capabilities.
|
||||||
|
//!
|
||||||
|
//! For example, for the [`crate::widgets::Block`] widget, all the following calls are valid to set
|
||||||
|
//! its `title` property (which is a [`Spans`] under the hood):
|
||||||
|
//!
|
||||||
|
//! ```rust
|
||||||
|
//! # use ratatui::widgets::Block;
|
||||||
|
//! # use ratatui::text::{Span, Spans};
|
||||||
|
//! # use ratatui::style::{Color, Style};
|
||||||
|
//! // A simple string with no styling.
|
||||||
|
//! // Converted to Spans(vec![
|
||||||
|
//! // Span { content: Cow::Borrowed("My title"), style: Style { .. } }
|
||||||
|
//! // ])
|
||||||
|
//! let block = Block::default().title("My title");
|
||||||
|
//!
|
||||||
|
//! // A simple string with a unique style.
|
||||||
|
//! // Converted to Spans(vec![
|
||||||
|
//! // Span { content: Cow::Borrowed("My title"), style: Style { fg: Some(Color::Yellow), .. }
|
||||||
|
//! // ])
|
||||||
|
//! let block = Block::default().title(
|
||||||
|
//! Span::styled("My title", Style::default().fg(Color::Yellow))
|
||||||
|
//! );
|
||||||
|
//!
|
||||||
|
//! // A string with multiple styles.
|
||||||
|
//! // Converted to Spans(vec![
|
||||||
|
//! // Span { content: Cow::Borrowed("My"), style: Style { fg: Some(Color::Yellow), .. } },
|
||||||
|
//! // Span { content: Cow::Borrowed(" title"), .. }
|
||||||
|
//! // ])
|
||||||
|
//! let block = Block::default().title(vec![
|
||||||
|
//! Span::styled("My", Style::default().fg(Color::Yellow)),
|
||||||
|
//! Span::raw(" title"),
|
||||||
|
//! ]);
|
||||||
|
//! ```
|
||||||
|
use crate::ratatui::style::Style;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
/// A grapheme associated to a style.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct StyledGrapheme<'a> {
|
||||||
|
pub symbol: &'a str,
|
||||||
|
pub style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A string where all graphemes have the same style.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Span<'a> {
|
||||||
|
pub content: Cow<'a, str>,
|
||||||
|
pub style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Span<'a> {
|
||||||
|
/// Create a span with no style.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::text::Span;
|
||||||
|
/// Span::raw("My text");
|
||||||
|
/// Span::raw(String::from("My text"));
|
||||||
|
/// ```
|
||||||
|
pub fn raw<T>(content: T) -> Span<'a>
|
||||||
|
where
|
||||||
|
T: Into<Cow<'a, str>>,
|
||||||
|
{
|
||||||
|
Span {
|
||||||
|
content: content.into(),
|
||||||
|
style: Style::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a span with a style.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::text::Span;
|
||||||
|
/// # use ratatui::style::{Color, Modifier, Style};
|
||||||
|
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
|
||||||
|
/// Span::styled("My text", style);
|
||||||
|
/// Span::styled(String::from("My text"), style);
|
||||||
|
/// ```
|
||||||
|
pub fn styled<T>(content: T, style: Style) -> Span<'a>
|
||||||
|
where
|
||||||
|
T: Into<Cow<'a, str>>,
|
||||||
|
{
|
||||||
|
Span {
|
||||||
|
content: content.into(),
|
||||||
|
style,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the width of the content held by this span.
|
||||||
|
pub fn width(&self) -> usize {
|
||||||
|
self.content.width()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an iterator over the graphemes held by this span.
|
||||||
|
///
|
||||||
|
/// `base_style` is the [`Style`] that will be patched with each grapheme [`Style`] to get
|
||||||
|
/// the resulting [`Style`].
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::text::{Span, StyledGrapheme};
|
||||||
|
/// # use ratatui::style::{Color, Modifier, Style};
|
||||||
|
/// # use std::iter::Iterator;
|
||||||
|
/// let style = Style::default().fg(Color::Yellow);
|
||||||
|
/// let span = Span::styled("Text", style);
|
||||||
|
/// let style = Style::default().fg(Color::Green).bg(Color::Black);
|
||||||
|
/// let styled_graphemes = span.styled_graphemes(style);
|
||||||
|
/// assert_eq!(
|
||||||
|
/// vec![
|
||||||
|
/// StyledGrapheme {
|
||||||
|
/// symbol: "T",
|
||||||
|
/// style: Style {
|
||||||
|
/// fg: Some(Color::Yellow),
|
||||||
|
/// bg: Some(Color::Black),
|
||||||
|
/// add_modifier: Modifier::empty(),
|
||||||
|
/// sub_modifier: Modifier::empty(),
|
||||||
|
/// },
|
||||||
|
/// },
|
||||||
|
/// StyledGrapheme {
|
||||||
|
/// symbol: "e",
|
||||||
|
/// style: Style {
|
||||||
|
/// fg: Some(Color::Yellow),
|
||||||
|
/// bg: Some(Color::Black),
|
||||||
|
/// add_modifier: Modifier::empty(),
|
||||||
|
/// sub_modifier: Modifier::empty(),
|
||||||
|
/// },
|
||||||
|
/// },
|
||||||
|
/// StyledGrapheme {
|
||||||
|
/// symbol: "x",
|
||||||
|
/// style: Style {
|
||||||
|
/// fg: Some(Color::Yellow),
|
||||||
|
/// bg: Some(Color::Black),
|
||||||
|
/// add_modifier: Modifier::empty(),
|
||||||
|
/// sub_modifier: Modifier::empty(),
|
||||||
|
/// },
|
||||||
|
/// },
|
||||||
|
/// StyledGrapheme {
|
||||||
|
/// symbol: "t",
|
||||||
|
/// style: Style {
|
||||||
|
/// fg: Some(Color::Yellow),
|
||||||
|
/// bg: Some(Color::Black),
|
||||||
|
/// add_modifier: Modifier::empty(),
|
||||||
|
/// sub_modifier: Modifier::empty(),
|
||||||
|
/// },
|
||||||
|
/// },
|
||||||
|
/// ],
|
||||||
|
/// styled_graphemes.collect::<Vec<StyledGrapheme>>()
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
pub fn styled_graphemes(
|
||||||
|
&'a self,
|
||||||
|
base_style: Style,
|
||||||
|
) -> impl Iterator<Item = StyledGrapheme<'a>> {
|
||||||
|
UnicodeSegmentation::graphemes(self.content.as_ref(), true)
|
||||||
|
.map(move |g| StyledGrapheme {
|
||||||
|
symbol: g,
|
||||||
|
style: base_style.patch(self.style),
|
||||||
|
})
|
||||||
|
.filter(|s| s.symbol != "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<String> for Span<'a> {
|
||||||
|
fn from(s: String) -> Span<'a> {
|
||||||
|
Span::raw(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a str> for Span<'a> {
|
||||||
|
fn from(s: &'a str) -> Span<'a> {
|
||||||
|
Span::raw(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A string composed of clusters of graphemes, each with their own style.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default, Eq)]
|
||||||
|
pub struct Spans<'a>(pub Vec<Span<'a>>);
|
||||||
|
|
||||||
|
impl<'a> Spans<'a> {
|
||||||
|
/// Returns the width of the underlying string.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::text::{Span, Spans};
|
||||||
|
/// # use ratatui::style::{Color, Style};
|
||||||
|
/// let spans = Spans::from(vec![
|
||||||
|
/// Span::styled("My", Style::default().fg(Color::Yellow)),
|
||||||
|
/// Span::raw(" text"),
|
||||||
|
/// ]);
|
||||||
|
/// assert_eq!(7, spans.width());
|
||||||
|
/// ```
|
||||||
|
pub fn width(&self) -> usize {
|
||||||
|
self.0.iter().map(Span::width).sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<String> for Spans<'a> {
|
||||||
|
fn from(s: String) -> Spans<'a> {
|
||||||
|
Spans(vec![Span::from(s)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a str> for Spans<'a> {
|
||||||
|
fn from(s: &'a str) -> Spans<'a> {
|
||||||
|
Spans(vec![Span::from(s)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<Vec<Span<'a>>> for Spans<'a> {
|
||||||
|
fn from(spans: Vec<Span<'a>>) -> Spans<'a> {
|
||||||
|
Spans(spans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<Span<'a>> for Spans<'a> {
|
||||||
|
fn from(span: Span<'a>) -> Spans<'a> {
|
||||||
|
Spans(vec![span])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<Spans<'a>> for String {
|
||||||
|
fn from(line: Spans<'a>) -> String {
|
||||||
|
line.0.iter().fold(String::new(), |mut acc, s| {
|
||||||
|
acc.push_str(s.content.as_ref());
|
||||||
|
acc
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A string split over multiple lines where each line is composed of several clusters, each with
|
||||||
|
/// their own style.
|
||||||
|
///
|
||||||
|
/// A [`Text`], like a [`Span`], can be constructed using one of the many `From` implementations
|
||||||
|
/// or via the [`Text::raw`] and [`Text::styled`] methods. Helpfully, [`Text`] also implements
|
||||||
|
/// [`core::iter::Extend`] which enables the concatenation of several [`Text`] blocks.
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::text::Text;
|
||||||
|
/// # use ratatui::style::{Color, Modifier, Style};
|
||||||
|
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
|
||||||
|
///
|
||||||
|
/// // An initial two lines of `Text` built from a `&str`
|
||||||
|
/// let mut text = Text::from("The first line\nThe second line");
|
||||||
|
/// assert_eq!(2, text.height());
|
||||||
|
///
|
||||||
|
/// // Adding two more unstyled lines
|
||||||
|
/// text.extend(Text::raw("These are two\nmore lines!"));
|
||||||
|
/// assert_eq!(4, text.height());
|
||||||
|
///
|
||||||
|
/// // Adding a final two styled lines
|
||||||
|
/// text.extend(Text::styled("Some more lines\nnow with more style!", style));
|
||||||
|
/// assert_eq!(6, text.height());
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default, Eq)]
|
||||||
|
pub struct Text<'a> {
|
||||||
|
pub lines: Vec<Spans<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Text<'a> {
|
||||||
|
/// Create some text (potentially multiple lines) with no style.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::text::Text;
|
||||||
|
/// Text::raw("The first line\nThe second line");
|
||||||
|
/// Text::raw(String::from("The first line\nThe second line"));
|
||||||
|
/// ```
|
||||||
|
pub fn raw<T>(content: T) -> Text<'a>
|
||||||
|
where
|
||||||
|
T: Into<Cow<'a, str>>,
|
||||||
|
{
|
||||||
|
let lines: Vec<_> = match content.into() {
|
||||||
|
Cow::Borrowed("") => vec![Spans::from("")],
|
||||||
|
Cow::Borrowed(s) => s.lines().map(Spans::from).collect(),
|
||||||
|
Cow::Owned(s) if s.is_empty() => vec![Spans::from("")],
|
||||||
|
Cow::Owned(s) => s.lines().map(|l| Spans::from(l.to_owned())).collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Text { lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create some text (potentially multiple lines) with a style.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::text::Text;
|
||||||
|
/// # use ratatui::style::{Color, Modifier, Style};
|
||||||
|
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
|
||||||
|
/// Text::styled("The first line\nThe second line", style);
|
||||||
|
/// Text::styled(String::from("The first line\nThe second line"), style);
|
||||||
|
/// ```
|
||||||
|
pub fn styled<T>(content: T, style: Style) -> Text<'a>
|
||||||
|
where
|
||||||
|
T: Into<Cow<'a, str>>,
|
||||||
|
{
|
||||||
|
let mut text = Text::raw(content);
|
||||||
|
text.patch_style(style);
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the max width of all the lines.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use ratatui::text::Text;
|
||||||
|
/// let text = Text::from("The first line\nThe second line");
|
||||||
|
/// assert_eq!(15, text.width());
|
||||||
|
/// ```
|
||||||
|
pub fn width(&self) -> usize {
|
||||||
|
self.lines
|
||||||
|
.iter()
|
||||||
|
.map(Spans::width)
|
||||||
|
.max()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the height.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use ratatui::text::Text;
|
||||||
|
/// let text = Text::from("The first line\nThe second line");
|
||||||
|
/// assert_eq!(2, text.height());
|
||||||
|
/// ```
|
||||||
|
pub fn height(&self) -> usize {
|
||||||
|
self.lines.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a new style to existing text.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::text::Text;
|
||||||
|
/// # use ratatui::style::{Color, Modifier, Style};
|
||||||
|
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
|
||||||
|
/// let mut raw_text = Text::raw("The first line\nThe second line");
|
||||||
|
/// let styled_text = Text::styled(String::from("The first line\nThe second line"), style);
|
||||||
|
/// assert_ne!(raw_text, styled_text);
|
||||||
|
///
|
||||||
|
/// raw_text.patch_style(style);
|
||||||
|
/// assert_eq!(raw_text, styled_text);
|
||||||
|
/// ```
|
||||||
|
pub fn patch_style(&mut self, style: Style) {
|
||||||
|
for line in &mut self.lines {
|
||||||
|
for span in &mut line.0 {
|
||||||
|
span.style = span.style.patch(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<String> for Text<'a> {
|
||||||
|
fn from(s: String) -> Text<'a> {
|
||||||
|
Text::raw(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a str> for Text<'a> {
|
||||||
|
fn from(s: &'a str) -> Text<'a> {
|
||||||
|
Text::raw(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<Cow<'a, str>> for Text<'a> {
|
||||||
|
fn from(s: Cow<'a, str>) -> Text<'a> {
|
||||||
|
Text::raw(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<Span<'a>> for Text<'a> {
|
||||||
|
fn from(span: Span<'a>) -> Text<'a> {
|
||||||
|
Text {
|
||||||
|
lines: vec![Spans::from(span)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<Spans<'a>> for Text<'a> {
|
||||||
|
fn from(spans: Spans<'a>) -> Text<'a> {
|
||||||
|
Text { lines: vec![spans] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<Vec<Spans<'a>>> for Text<'a> {
|
||||||
|
fn from(lines: Vec<Spans<'a>>) -> Text<'a> {
|
||||||
|
Text { lines }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> IntoIterator for Text<'a> {
|
||||||
|
type Item = Spans<'a>;
|
||||||
|
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||||
|
|
||||||
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
|
self.lines.into_iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Extend<Spans<'a>> for Text<'a> {
|
||||||
|
fn extend<T: IntoIterator<Item = Spans<'a>>>(&mut self, iter: T) {
|
||||||
|
self.lines.extend(iter);
|
||||||
|
}
|
||||||
|
}
|
219
src/ratatui/widgets/barchart.rs
Normal file
219
src/ratatui/widgets/barchart.rs
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::Rect,
|
||||||
|
style::Style,
|
||||||
|
symbols,
|
||||||
|
widgets::{Block, Widget},
|
||||||
|
};
|
||||||
|
use std::cmp::min;
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
/// Display multiple bars in a single widgets
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::{Block, Borders, BarChart};
|
||||||
|
/// # use ratatui::style::{Style, Color, Modifier};
|
||||||
|
/// BarChart::default()
|
||||||
|
/// .block(Block::default().title("BarChart").borders(Borders::ALL))
|
||||||
|
/// .bar_width(3)
|
||||||
|
/// .bar_gap(1)
|
||||||
|
/// .bar_style(Style::default().fg(Color::Yellow).bg(Color::Red))
|
||||||
|
/// .value_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
|
||||||
|
/// .label_style(Style::default().fg(Color::White))
|
||||||
|
/// .data(&[("B0", 0), ("B1", 2), ("B2", 4), ("B3", 3)])
|
||||||
|
/// .max(4);
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BarChart<'a> {
|
||||||
|
/// Block to wrap the widget in
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
/// The width of each bar
|
||||||
|
bar_width: u16,
|
||||||
|
/// The gap between each bar
|
||||||
|
bar_gap: u16,
|
||||||
|
/// Set of symbols used to display the data
|
||||||
|
bar_set: symbols::bar::Set,
|
||||||
|
/// Style of the bars
|
||||||
|
bar_style: Style,
|
||||||
|
/// Style of the values printed at the bottom of each bar
|
||||||
|
value_style: Style,
|
||||||
|
/// Style of the labels printed under each bar
|
||||||
|
label_style: Style,
|
||||||
|
/// Style for the widget
|
||||||
|
style: Style,
|
||||||
|
/// Slice of (label, value) pair to plot on the chart
|
||||||
|
data: &'a [(&'a str, u64)],
|
||||||
|
/// Value necessary for a bar to reach the maximum height (if no value is specified,
|
||||||
|
/// the maximum value in the data is taken as reference)
|
||||||
|
max: Option<u64>,
|
||||||
|
/// Values to display on the bar (computed when the data is passed to the widget)
|
||||||
|
values: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Default for BarChart<'a> {
|
||||||
|
fn default() -> BarChart<'a> {
|
||||||
|
BarChart {
|
||||||
|
block: None,
|
||||||
|
max: None,
|
||||||
|
data: &[],
|
||||||
|
values: Vec::new(),
|
||||||
|
bar_style: Style::default(),
|
||||||
|
bar_width: 1,
|
||||||
|
bar_gap: 1,
|
||||||
|
bar_set: symbols::bar::NINE_LEVELS,
|
||||||
|
value_style: Default::default(),
|
||||||
|
label_style: Default::default(),
|
||||||
|
style: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> BarChart<'a> {
|
||||||
|
pub fn data(mut self, data: &'a [(&'a str, u64)]) -> BarChart<'a> {
|
||||||
|
self.data = data;
|
||||||
|
self.values = Vec::with_capacity(self.data.len());
|
||||||
|
for &(_, v) in self.data {
|
||||||
|
self.values.push(format!("{}", v));
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn block(mut self, block: Block<'a>) -> BarChart<'a> {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max(mut self, max: u64) -> BarChart<'a> {
|
||||||
|
self.max = Some(max);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bar_style(mut self, style: Style) -> BarChart<'a> {
|
||||||
|
self.bar_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bar_width(mut self, width: u16) -> BarChart<'a> {
|
||||||
|
self.bar_width = width;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bar_gap(mut self, gap: u16) -> BarChart<'a> {
|
||||||
|
self.bar_gap = gap;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bar_set(mut self, bar_set: symbols::bar::Set) -> BarChart<'a> {
|
||||||
|
self.bar_set = bar_set;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value_style(mut self, style: Style) -> BarChart<'a> {
|
||||||
|
self.value_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label_style(mut self, style: Style) -> BarChart<'a> {
|
||||||
|
self.label_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> BarChart<'a> {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for BarChart<'a> {
|
||||||
|
fn render(mut self, area: Rect, buf: &mut Buffer) {
|
||||||
|
buf.set_style(area, self.style);
|
||||||
|
|
||||||
|
let chart_area = match self.block.take() {
|
||||||
|
Some(b) => {
|
||||||
|
let inner_area = b.inner(area);
|
||||||
|
b.render(area, buf);
|
||||||
|
inner_area
|
||||||
|
}
|
||||||
|
None => area,
|
||||||
|
};
|
||||||
|
|
||||||
|
if chart_area.height < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max = self
|
||||||
|
.max
|
||||||
|
.unwrap_or_else(|| self.data.iter().map(|t| t.1).max().unwrap_or_default());
|
||||||
|
let max_index = min(
|
||||||
|
(chart_area.width / (self.bar_width + self.bar_gap)) as usize,
|
||||||
|
self.data.len(),
|
||||||
|
);
|
||||||
|
let mut data = self
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.take(max_index)
|
||||||
|
.map(|&(l, v)| {
|
||||||
|
(
|
||||||
|
l,
|
||||||
|
v * u64::from(chart_area.height - 1) * 8 / std::cmp::max(max, 1),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<(&str, u64)>>();
|
||||||
|
for j in (0..chart_area.height - 1).rev() {
|
||||||
|
for (i, d) in data.iter_mut().enumerate() {
|
||||||
|
let symbol = match d.1 {
|
||||||
|
0 => self.bar_set.empty,
|
||||||
|
1 => self.bar_set.one_eighth,
|
||||||
|
2 => self.bar_set.one_quarter,
|
||||||
|
3 => self.bar_set.three_eighths,
|
||||||
|
4 => self.bar_set.half,
|
||||||
|
5 => self.bar_set.five_eighths,
|
||||||
|
6 => self.bar_set.three_quarters,
|
||||||
|
7 => self.bar_set.seven_eighths,
|
||||||
|
_ => self.bar_set.full,
|
||||||
|
};
|
||||||
|
|
||||||
|
for x in 0..self.bar_width {
|
||||||
|
buf.get_mut(
|
||||||
|
chart_area.left() + i as u16 * (self.bar_width + self.bar_gap) + x,
|
||||||
|
chart_area.top() + j,
|
||||||
|
)
|
||||||
|
.set_symbol(symbol)
|
||||||
|
.set_style(self.bar_style);
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.1 > 8 {
|
||||||
|
d.1 -= 8;
|
||||||
|
} else {
|
||||||
|
d.1 = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, &(label, value)) in self.data.iter().take(max_index).enumerate() {
|
||||||
|
if value != 0 {
|
||||||
|
let value_label = &self.values[i];
|
||||||
|
let width = value_label.width() as u16;
|
||||||
|
if width < self.bar_width {
|
||||||
|
buf.set_string(
|
||||||
|
chart_area.left()
|
||||||
|
+ i as u16 * (self.bar_width + self.bar_gap)
|
||||||
|
+ (self.bar_width - width) / 2,
|
||||||
|
chart_area.bottom() - 2,
|
||||||
|
value_label,
|
||||||
|
self.value_style,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.set_stringn(
|
||||||
|
chart_area.left() + i as u16 * (self.bar_width + self.bar_gap),
|
||||||
|
chart_area.bottom() - 1,
|
||||||
|
label,
|
||||||
|
self.bar_width as usize,
|
||||||
|
self.label_style,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
573
src/ratatui/widgets/block.rs
Normal file
573
src/ratatui/widgets/block.rs
Normal file
|
@ -0,0 +1,573 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::Style,
|
||||||
|
symbols::line,
|
||||||
|
text::{Span, Spans},
|
||||||
|
widgets::{Borders, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BorderType {
|
||||||
|
Plain,
|
||||||
|
Rounded,
|
||||||
|
Double,
|
||||||
|
Thick,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BorderType {
|
||||||
|
pub fn line_symbols(border_type: BorderType) -> line::Set {
|
||||||
|
match border_type {
|
||||||
|
BorderType::Plain => line::NORMAL,
|
||||||
|
BorderType::Rounded => line::ROUNDED,
|
||||||
|
BorderType::Double => line::DOUBLE,
|
||||||
|
BorderType::Thick => line::THICK,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base widget to be used with all upper level ones. It may be used to display a box border around
|
||||||
|
/// the widget and/or add a title.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::{Block, BorderType, Borders};
|
||||||
|
/// # use ratatui::style::{Style, Color};
|
||||||
|
/// Block::default()
|
||||||
|
/// .title("Block")
|
||||||
|
/// .borders(Borders::LEFT | Borders::RIGHT)
|
||||||
|
/// .border_style(Style::default().fg(Color::White))
|
||||||
|
/// .border_type(BorderType::Rounded)
|
||||||
|
/// .style(Style::default().bg(Color::Black));
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Block<'a> {
|
||||||
|
/// Optional title place on the upper left of the block
|
||||||
|
title: Option<Spans<'a>>,
|
||||||
|
/// Title alignment. The default is top left of the block, but one can choose to place
|
||||||
|
/// title in the top middle, or top right of the block
|
||||||
|
title_alignment: Alignment,
|
||||||
|
/// Visible borders
|
||||||
|
borders: Borders,
|
||||||
|
/// Border style
|
||||||
|
border_style: Style,
|
||||||
|
/// Type of the border. The default is plain lines but one can choose to have rounded corners
|
||||||
|
/// or doubled lines instead.
|
||||||
|
border_type: BorderType,
|
||||||
|
/// Widget style
|
||||||
|
style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Default for Block<'a> {
|
||||||
|
fn default() -> Block<'a> {
|
||||||
|
Block {
|
||||||
|
title: None,
|
||||||
|
title_alignment: Alignment::Left,
|
||||||
|
borders: Borders::NONE,
|
||||||
|
border_style: Default::default(),
|
||||||
|
border_type: BorderType::Plain,
|
||||||
|
style: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Block<'a> {
|
||||||
|
pub fn title<T>(mut self, title: T) -> Block<'a>
|
||||||
|
where
|
||||||
|
T: Into<Spans<'a>>,
|
||||||
|
{
|
||||||
|
self.title = Some(title.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[deprecated(
|
||||||
|
since = "0.10.0",
|
||||||
|
note = "You should use styling capabilities of `text::Spans` given as argument of the `title` method to apply styling to the title."
|
||||||
|
)]
|
||||||
|
pub fn title_style(mut self, style: Style) -> Block<'a> {
|
||||||
|
if let Some(t) = self.title {
|
||||||
|
let title = String::from(t);
|
||||||
|
self.title = Some(Spans::from(Span::styled(title, style)));
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title_alignment(mut self, alignment: Alignment) -> Block<'a> {
|
||||||
|
self.title_alignment = alignment;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn border_style(mut self, style: Style) -> Block<'a> {
|
||||||
|
self.border_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> Block<'a> {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn borders(mut self, flag: Borders) -> Block<'a> {
|
||||||
|
self.borders = flag;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn border_type(mut self, border_type: BorderType) -> Block<'a> {
|
||||||
|
self.border_type = border_type;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the inner area of a block based on its border visibility rules.
|
||||||
|
pub fn inner(&self, area: Rect) -> Rect {
|
||||||
|
let mut inner = area;
|
||||||
|
if self.borders.intersects(Borders::LEFT) {
|
||||||
|
inner.x = inner.x.saturating_add(1).min(inner.right());
|
||||||
|
inner.width = inner.width.saturating_sub(1);
|
||||||
|
}
|
||||||
|
if self.borders.intersects(Borders::TOP) || self.title.is_some() {
|
||||||
|
inner.y = inner.y.saturating_add(1).min(inner.bottom());
|
||||||
|
inner.height = inner.height.saturating_sub(1);
|
||||||
|
}
|
||||||
|
if self.borders.intersects(Borders::RIGHT) {
|
||||||
|
inner.width = inner.width.saturating_sub(1);
|
||||||
|
}
|
||||||
|
if self.borders.intersects(Borders::BOTTOM) {
|
||||||
|
inner.height = inner.height.saturating_sub(1);
|
||||||
|
}
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Block<'a> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
if area.area() == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buf.set_style(area, self.style);
|
||||||
|
let symbols = BorderType::line_symbols(self.border_type);
|
||||||
|
|
||||||
|
// Sides
|
||||||
|
if self.borders.intersects(Borders::LEFT) {
|
||||||
|
for y in area.top()..area.bottom() {
|
||||||
|
buf.get_mut(area.left(), y)
|
||||||
|
.set_symbol(symbols.vertical)
|
||||||
|
.set_style(self.border_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.borders.intersects(Borders::TOP) {
|
||||||
|
for x in area.left()..area.right() {
|
||||||
|
buf.get_mut(x, area.top())
|
||||||
|
.set_symbol(symbols.horizontal)
|
||||||
|
.set_style(self.border_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.borders.intersects(Borders::RIGHT) {
|
||||||
|
let x = area.right() - 1;
|
||||||
|
for y in area.top()..area.bottom() {
|
||||||
|
buf.get_mut(x, y)
|
||||||
|
.set_symbol(symbols.vertical)
|
||||||
|
.set_style(self.border_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.borders.intersects(Borders::BOTTOM) {
|
||||||
|
let y = area.bottom() - 1;
|
||||||
|
for x in area.left()..area.right() {
|
||||||
|
buf.get_mut(x, y)
|
||||||
|
.set_symbol(symbols.horizontal)
|
||||||
|
.set_style(self.border_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corners
|
||||||
|
if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
|
||||||
|
buf.get_mut(area.right() - 1, area.bottom() - 1)
|
||||||
|
.set_symbol(symbols.bottom_right)
|
||||||
|
.set_style(self.border_style);
|
||||||
|
}
|
||||||
|
if self.borders.contains(Borders::RIGHT | Borders::TOP) {
|
||||||
|
buf.get_mut(area.right() - 1, area.top())
|
||||||
|
.set_symbol(symbols.top_right)
|
||||||
|
.set_style(self.border_style);
|
||||||
|
}
|
||||||
|
if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
|
||||||
|
buf.get_mut(area.left(), area.bottom() - 1)
|
||||||
|
.set_symbol(symbols.bottom_left)
|
||||||
|
.set_style(self.border_style);
|
||||||
|
}
|
||||||
|
if self.borders.contains(Borders::LEFT | Borders::TOP) {
|
||||||
|
buf.get_mut(area.left(), area.top())
|
||||||
|
.set_symbol(symbols.top_left)
|
||||||
|
.set_style(self.border_style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
if let Some(title) = self.title {
|
||||||
|
let left_border_dx = if self.borders.intersects(Borders::LEFT) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let right_border_dx = if self.borders.intersects(Borders::RIGHT) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let title_area_width = area
|
||||||
|
.width
|
||||||
|
.saturating_sub(left_border_dx)
|
||||||
|
.saturating_sub(right_border_dx);
|
||||||
|
|
||||||
|
let title_dx = match self.title_alignment {
|
||||||
|
Alignment::Left => left_border_dx,
|
||||||
|
Alignment::Center => area.width.saturating_sub(title.width() as u16) / 2,
|
||||||
|
Alignment::Right => area
|
||||||
|
.width
|
||||||
|
.saturating_sub(title.width() as u16)
|
||||||
|
.saturating_sub(right_border_dx),
|
||||||
|
};
|
||||||
|
|
||||||
|
let title_x = area.left() + title_dx;
|
||||||
|
let title_y = area.top();
|
||||||
|
|
||||||
|
buf.set_spans(title_x, title_y, &title, title_area_width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::ratatui::layout::Rect;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inner_takes_into_account_the_borders() {
|
||||||
|
// No borders
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().inner(Rect::default()),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
"no borders, width=0, height=0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
"no borders, width=1, height=1"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Left border
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::LEFT).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 1
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
"left, width=0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::LEFT).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 1,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
"left, width=1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::LEFT).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 2,
|
||||||
|
height: 1
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 1,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
"left, width=2"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Top border
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::TOP).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 0
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
"top, height=0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::TOP).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
"top, height=1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::TOP).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 2
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
"top, height=2"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Right border
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::RIGHT).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 1
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
"right, width=0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::RIGHT).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
"right, width=1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::RIGHT).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 2,
|
||||||
|
height: 1
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
"right, width=2"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bottom border
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::BOTTOM).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 0
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
"bottom, height=0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::BOTTOM).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
"bottom, height=1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::BOTTOM).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 2
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
},
|
||||||
|
"bottom, height=2"
|
||||||
|
);
|
||||||
|
|
||||||
|
// All borders
|
||||||
|
assert_eq!(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.inner(Rect::default()),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
"all borders, width=0, height=0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::ALL).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
"all borders, width=1, height=1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::ALL).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
"all borders, width=2, height=2"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().borders(Borders::ALL).inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
"all borders, width=3, height=3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inner_takes_into_account_the_title() {
|
||||||
|
assert_eq!(
|
||||||
|
Block::default().title("Test").inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 1,
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 1,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default()
|
||||||
|
.title("Test")
|
||||||
|
.title_alignment(Alignment::Center)
|
||||||
|
.inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 1,
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 1,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Block::default()
|
||||||
|
.title("Test")
|
||||||
|
.title_alignment(Alignment::Right)
|
||||||
|
.inner(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 1,
|
||||||
|
}),
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 1,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
95
src/ratatui/widgets/canvas/line.rs
Normal file
95
src/ratatui/widgets/canvas/line.rs
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
style::Color,
|
||||||
|
widgets::canvas::{Painter, Shape},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Shape to draw a line from (x1, y1) to (x2, y2) with the given color
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Line {
|
||||||
|
pub x1: f64,
|
||||||
|
pub y1: f64,
|
||||||
|
pub x2: f64,
|
||||||
|
pub y2: f64,
|
||||||
|
pub color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shape for Line {
|
||||||
|
fn draw(&self, painter: &mut Painter) {
|
||||||
|
let (x1, y1) = match painter.get_point(self.x1, self.y1) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let (x2, y2) = match painter.get_point(self.x2, self.y2) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let (dx, x_range) = if x2 >= x1 {
|
||||||
|
(x2 - x1, x1..=x2)
|
||||||
|
} else {
|
||||||
|
(x1 - x2, x2..=x1)
|
||||||
|
};
|
||||||
|
let (dy, y_range) = if y2 >= y1 {
|
||||||
|
(y2 - y1, y1..=y2)
|
||||||
|
} else {
|
||||||
|
(y1 - y2, y2..=y1)
|
||||||
|
};
|
||||||
|
|
||||||
|
if dx == 0 {
|
||||||
|
for y in y_range {
|
||||||
|
painter.paint(x1, y, self.color);
|
||||||
|
}
|
||||||
|
} else if dy == 0 {
|
||||||
|
for x in x_range {
|
||||||
|
painter.paint(x, y1, self.color);
|
||||||
|
}
|
||||||
|
} else if dy < dx {
|
||||||
|
if x1 > x2 {
|
||||||
|
draw_line_low(painter, x2, y2, x1, y1, self.color);
|
||||||
|
} else {
|
||||||
|
draw_line_low(painter, x1, y1, x2, y2, self.color);
|
||||||
|
}
|
||||||
|
} else if y1 > y2 {
|
||||||
|
draw_line_high(painter, x2, y2, x1, y1, self.color);
|
||||||
|
} else {
|
||||||
|
draw_line_high(painter, x1, y1, x2, y2, self.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_line_low(painter: &mut Painter, x1: usize, y1: usize, x2: usize, y2: usize, color: Color) {
|
||||||
|
let dx = (x2 - x1) as isize;
|
||||||
|
let dy = (y2 as isize - y1 as isize).abs();
|
||||||
|
let mut d = 2 * dy - dx;
|
||||||
|
let mut y = y1;
|
||||||
|
for x in x1..=x2 {
|
||||||
|
painter.paint(x, y, color);
|
||||||
|
if d > 0 {
|
||||||
|
y = if y1 > y2 {
|
||||||
|
y.saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
y.saturating_add(1)
|
||||||
|
};
|
||||||
|
d -= 2 * dx;
|
||||||
|
}
|
||||||
|
d += 2 * dy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_line_high(painter: &mut Painter, x1: usize, y1: usize, x2: usize, y2: usize, color: Color) {
|
||||||
|
let dx = (x2 as isize - x1 as isize).abs();
|
||||||
|
let dy = (y2 - y1) as isize;
|
||||||
|
let mut d = 2 * dx - dy;
|
||||||
|
let mut x = x1;
|
||||||
|
for y in y1..=y2 {
|
||||||
|
painter.paint(x, y, color);
|
||||||
|
if d > 0 {
|
||||||
|
x = if x1 > x2 {
|
||||||
|
x.saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
x.saturating_add(1)
|
||||||
|
};
|
||||||
|
d -= 2 * dy;
|
||||||
|
}
|
||||||
|
d += 2 * dx;
|
||||||
|
}
|
||||||
|
}
|
48
src/ratatui/widgets/canvas/map.rs
Normal file
48
src/ratatui/widgets/canvas/map.rs
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
style::Color,
|
||||||
|
widgets::canvas::{
|
||||||
|
world::{WORLD_HIGH_RESOLUTION, WORLD_LOW_RESOLUTION},
|
||||||
|
Painter, Shape,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum MapResolution {
|
||||||
|
Low,
|
||||||
|
High,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MapResolution {
|
||||||
|
fn data(self) -> &'static [(f64, f64)] {
|
||||||
|
match self {
|
||||||
|
MapResolution::Low => &WORLD_LOW_RESOLUTION,
|
||||||
|
MapResolution::High => &WORLD_HIGH_RESOLUTION,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shape to draw a world map with the given resolution and color
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Map {
|
||||||
|
pub resolution: MapResolution,
|
||||||
|
pub color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Map {
|
||||||
|
fn default() -> Map {
|
||||||
|
Map {
|
||||||
|
resolution: MapResolution::Low,
|
||||||
|
color: Color::Reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shape for Map {
|
||||||
|
fn draw(&self, painter: &mut Painter) {
|
||||||
|
for (x, y) in self.resolution.data() {
|
||||||
|
if let Some((x, y)) = painter.get_point(*x, *y) {
|
||||||
|
painter.paint(x, y, self.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
510
src/ratatui/widgets/canvas/mod.rs
Normal file
510
src/ratatui/widgets/canvas/mod.rs
Normal file
|
@ -0,0 +1,510 @@
|
||||||
|
mod line;
|
||||||
|
mod map;
|
||||||
|
mod points;
|
||||||
|
mod rectangle;
|
||||||
|
mod world;
|
||||||
|
|
||||||
|
pub use self::line::Line;
|
||||||
|
pub use self::map::{Map, MapResolution};
|
||||||
|
pub use self::points::Points;
|
||||||
|
pub use self::rectangle::Rectangle;
|
||||||
|
|
||||||
|
use crate::ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Style},
|
||||||
|
symbols,
|
||||||
|
text::Spans,
|
||||||
|
widgets::{Block, Widget},
|
||||||
|
};
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
|
/// Interface for all shapes that may be drawn on a Canvas widget.
|
||||||
|
pub trait Shape {
|
||||||
|
fn draw(&self, painter: &mut Painter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Label to draw some text on the canvas
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Label<'a> {
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
spans: Spans<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Layer {
|
||||||
|
string: String,
|
||||||
|
colors: Vec<Color>,
|
||||||
|
}
|
||||||
|
|
||||||
|
trait Grid: Debug {
|
||||||
|
fn width(&self) -> u16;
|
||||||
|
fn height(&self) -> u16;
|
||||||
|
fn resolution(&self) -> (f64, f64);
|
||||||
|
fn paint(&mut self, x: usize, y: usize, color: Color);
|
||||||
|
fn save(&self) -> Layer;
|
||||||
|
fn reset(&mut self);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct BrailleGrid {
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
cells: Vec<u16>,
|
||||||
|
colors: Vec<Color>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrailleGrid {
|
||||||
|
fn new(width: u16, height: u16) -> BrailleGrid {
|
||||||
|
let length = usize::from(width * height);
|
||||||
|
BrailleGrid {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
cells: vec![symbols::braille::BLANK; length],
|
||||||
|
colors: vec![Color::Reset; length],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Grid for BrailleGrid {
|
||||||
|
fn width(&self) -> u16 {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
fn height(&self) -> u16 {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolution(&self) -> (f64, f64) {
|
||||||
|
(
|
||||||
|
f64::from(self.width) * 2.0 - 1.0,
|
||||||
|
f64::from(self.height) * 4.0 - 1.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&self) -> Layer {
|
||||||
|
Layer {
|
||||||
|
string: String::from_utf16(&self.cells).unwrap(),
|
||||||
|
colors: self.colors.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
for c in &mut self.cells {
|
||||||
|
*c = symbols::braille::BLANK;
|
||||||
|
}
|
||||||
|
for c in &mut self.colors {
|
||||||
|
*c = Color::Reset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&mut self, x: usize, y: usize, color: Color) {
|
||||||
|
let index = y / 4 * self.width as usize + x / 2;
|
||||||
|
if let Some(c) = self.cells.get_mut(index) {
|
||||||
|
*c |= symbols::braille::DOTS[y % 4][x % 2];
|
||||||
|
}
|
||||||
|
if let Some(c) = self.colors.get_mut(index) {
|
||||||
|
*c = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct CharGrid {
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
cells: Vec<char>,
|
||||||
|
colors: Vec<Color>,
|
||||||
|
cell_char: char,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CharGrid {
|
||||||
|
fn new(width: u16, height: u16, cell_char: char) -> CharGrid {
|
||||||
|
let length = usize::from(width * height);
|
||||||
|
CharGrid {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
cells: vec![' '; length],
|
||||||
|
colors: vec![Color::Reset; length],
|
||||||
|
cell_char,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Grid for CharGrid {
|
||||||
|
fn width(&self) -> u16 {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
fn height(&self) -> u16 {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolution(&self) -> (f64, f64) {
|
||||||
|
(f64::from(self.width) - 1.0, f64::from(self.height) - 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&self) -> Layer {
|
||||||
|
Layer {
|
||||||
|
string: self.cells.iter().collect(),
|
||||||
|
colors: self.colors.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
for c in &mut self.cells {
|
||||||
|
*c = ' ';
|
||||||
|
}
|
||||||
|
for c in &mut self.colors {
|
||||||
|
*c = Color::Reset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&mut self, x: usize, y: usize, color: Color) {
|
||||||
|
let index = y * self.width as usize + x;
|
||||||
|
if let Some(c) = self.cells.get_mut(index) {
|
||||||
|
*c = self.cell_char;
|
||||||
|
}
|
||||||
|
if let Some(c) = self.colors.get_mut(index) {
|
||||||
|
*c = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Painter<'a, 'b> {
|
||||||
|
context: &'a mut Context<'b>,
|
||||||
|
resolution: (f64, f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b> Painter<'a, 'b> {
|
||||||
|
/// Convert the (x, y) coordinates to location of a point on the grid
|
||||||
|
///
|
||||||
|
/// # Examples:
|
||||||
|
/// ```
|
||||||
|
/// use ratatui::{symbols, widgets::canvas::{Painter, Context}};
|
||||||
|
///
|
||||||
|
/// let mut ctx = Context::new(2, 2, [1.0, 2.0], [0.0, 2.0], symbols::Marker::Braille);
|
||||||
|
/// let mut painter = Painter::from(&mut ctx);
|
||||||
|
/// let point = painter.get_point(1.0, 0.0);
|
||||||
|
/// assert_eq!(point, Some((0, 7)));
|
||||||
|
/// let point = painter.get_point(1.5, 1.0);
|
||||||
|
/// assert_eq!(point, Some((1, 3)));
|
||||||
|
/// let point = painter.get_point(0.0, 0.0);
|
||||||
|
/// assert_eq!(point, None);
|
||||||
|
/// let point = painter.get_point(2.0, 2.0);
|
||||||
|
/// assert_eq!(point, Some((3, 0)));
|
||||||
|
/// let point = painter.get_point(1.0, 2.0);
|
||||||
|
/// assert_eq!(point, Some((0, 0)));
|
||||||
|
/// ```
|
||||||
|
pub fn get_point(&self, x: f64, y: f64) -> Option<(usize, usize)> {
|
||||||
|
let left = self.context.x_bounds[0];
|
||||||
|
let right = self.context.x_bounds[1];
|
||||||
|
let top = self.context.y_bounds[1];
|
||||||
|
let bottom = self.context.y_bounds[0];
|
||||||
|
if x < left || x > right || y < bottom || y > top {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let width = (self.context.x_bounds[1] - self.context.x_bounds[0]).abs();
|
||||||
|
let height = (self.context.y_bounds[1] - self.context.y_bounds[0]).abs();
|
||||||
|
if width == 0.0 || height == 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let x = ((x - left) * self.resolution.0 / width) as usize;
|
||||||
|
let y = ((top - y) * self.resolution.1 / height) as usize;
|
||||||
|
Some((x, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint a point of the grid
|
||||||
|
///
|
||||||
|
/// # Examples:
|
||||||
|
/// ```
|
||||||
|
/// use ratatui::{style::Color, symbols, widgets::canvas::{Painter, Context}};
|
||||||
|
///
|
||||||
|
/// let mut ctx = Context::new(1, 1, [0.0, 2.0], [0.0, 2.0], symbols::Marker::Braille);
|
||||||
|
/// let mut painter = Painter::from(&mut ctx);
|
||||||
|
/// let cell = painter.paint(1, 3, Color::Red);
|
||||||
|
/// ```
|
||||||
|
pub fn paint(&mut self, x: usize, y: usize, color: Color) {
|
||||||
|
self.context.grid.paint(x, y, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b> From<&'a mut Context<'b>> for Painter<'a, 'b> {
|
||||||
|
fn from(context: &'a mut Context<'b>) -> Painter<'a, 'b> {
|
||||||
|
let resolution = context.grid.resolution();
|
||||||
|
Painter {
|
||||||
|
context,
|
||||||
|
resolution,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the state of the Canvas when painting to it.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Context<'a> {
|
||||||
|
x_bounds: [f64; 2],
|
||||||
|
y_bounds: [f64; 2],
|
||||||
|
grid: Box<dyn Grid>,
|
||||||
|
dirty: bool,
|
||||||
|
layers: Vec<Layer>,
|
||||||
|
labels: Vec<Label<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Context<'a> {
|
||||||
|
pub fn new(
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
x_bounds: [f64; 2],
|
||||||
|
y_bounds: [f64; 2],
|
||||||
|
marker: symbols::Marker,
|
||||||
|
) -> Context<'a> {
|
||||||
|
let grid: Box<dyn Grid> = match marker {
|
||||||
|
symbols::Marker::Dot => Box::new(CharGrid::new(width, height, '•')),
|
||||||
|
symbols::Marker::Block => Box::new(CharGrid::new(width, height, '▄')),
|
||||||
|
symbols::Marker::Braille => Box::new(BrailleGrid::new(width, height)),
|
||||||
|
};
|
||||||
|
Context {
|
||||||
|
x_bounds,
|
||||||
|
y_bounds,
|
||||||
|
grid,
|
||||||
|
dirty: false,
|
||||||
|
layers: Vec::new(),
|
||||||
|
labels: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw any object that may implement the Shape trait
|
||||||
|
pub fn draw<S>(&mut self, shape: &S)
|
||||||
|
where
|
||||||
|
S: Shape,
|
||||||
|
{
|
||||||
|
self.dirty = true;
|
||||||
|
let mut painter = Painter::from(self);
|
||||||
|
shape.draw(&mut painter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Go one layer above in the canvas.
|
||||||
|
pub fn layer(&mut self) {
|
||||||
|
self.layers.push(self.grid.save());
|
||||||
|
self.grid.reset();
|
||||||
|
self.dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print a string on the canvas at the given position
|
||||||
|
pub fn print<T>(&mut self, x: f64, y: f64, spans: T)
|
||||||
|
where
|
||||||
|
T: Into<Spans<'a>>,
|
||||||
|
{
|
||||||
|
self.labels.push(Label {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
spans: spans.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push the last layer if necessary
|
||||||
|
fn finish(&mut self) {
|
||||||
|
if self.dirty {
|
||||||
|
self.layer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Canvas widget may be used to draw more detailed figures using braille patterns (each
|
||||||
|
/// cell can have a braille character in 8 different positions).
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::{Block, Borders};
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// # use ratatui::widgets::canvas::{Canvas, Shape, Line, Rectangle, Map, MapResolution};
|
||||||
|
/// # use ratatui::style::Color;
|
||||||
|
/// Canvas::default()
|
||||||
|
/// .block(Block::default().title("Canvas").borders(Borders::ALL))
|
||||||
|
/// .x_bounds([-180.0, 180.0])
|
||||||
|
/// .y_bounds([-90.0, 90.0])
|
||||||
|
/// .paint(|ctx| {
|
||||||
|
/// ctx.draw(&Map {
|
||||||
|
/// resolution: MapResolution::High,
|
||||||
|
/// color: Color::White
|
||||||
|
/// });
|
||||||
|
/// ctx.layer();
|
||||||
|
/// ctx.draw(&Line {
|
||||||
|
/// x1: 0.0,
|
||||||
|
/// y1: 10.0,
|
||||||
|
/// x2: 10.0,
|
||||||
|
/// y2: 10.0,
|
||||||
|
/// color: Color::White,
|
||||||
|
/// });
|
||||||
|
/// ctx.draw(&Rectangle {
|
||||||
|
/// x: 10.0,
|
||||||
|
/// y: 20.0,
|
||||||
|
/// width: 10.0,
|
||||||
|
/// height: 10.0,
|
||||||
|
/// color: Color::Red
|
||||||
|
/// });
|
||||||
|
/// });
|
||||||
|
/// ```
|
||||||
|
pub struct Canvas<'a, F>
|
||||||
|
where
|
||||||
|
F: Fn(&mut Context),
|
||||||
|
{
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
x_bounds: [f64; 2],
|
||||||
|
y_bounds: [f64; 2],
|
||||||
|
painter: Option<F>,
|
||||||
|
background_color: Color,
|
||||||
|
marker: symbols::Marker,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, F> Default for Canvas<'a, F>
|
||||||
|
where
|
||||||
|
F: Fn(&mut Context),
|
||||||
|
{
|
||||||
|
fn default() -> Canvas<'a, F> {
|
||||||
|
Canvas {
|
||||||
|
block: None,
|
||||||
|
x_bounds: [0.0, 0.0],
|
||||||
|
y_bounds: [0.0, 0.0],
|
||||||
|
painter: None,
|
||||||
|
background_color: Color::Reset,
|
||||||
|
marker: symbols::Marker::Braille,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, F> Canvas<'a, F>
|
||||||
|
where
|
||||||
|
F: Fn(&mut Context),
|
||||||
|
{
|
||||||
|
pub fn block(mut self, block: Block<'a>) -> Canvas<'a, F> {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define the viewport of the canvas.
|
||||||
|
/// If you were to "zoom" to a certain part of the world you may want to choose different
|
||||||
|
/// bounds.
|
||||||
|
pub fn x_bounds(mut self, bounds: [f64; 2]) -> Canvas<'a, F> {
|
||||||
|
self.x_bounds = bounds;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define the viewport of the canvas.
|
||||||
|
///
|
||||||
|
/// If you were to "zoom" to a certain part of the world you may want to choose different
|
||||||
|
/// bounds.
|
||||||
|
pub fn y_bounds(mut self, bounds: [f64; 2]) -> Canvas<'a, F> {
|
||||||
|
self.y_bounds = bounds;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store the closure that will be used to draw to the Canvas
|
||||||
|
pub fn paint(mut self, f: F) -> Canvas<'a, F> {
|
||||||
|
self.painter = Some(f);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn background_color(mut self, color: Color) -> Canvas<'a, F> {
|
||||||
|
self.background_color = color;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change the type of points used to draw the shapes. By default the braille patterns are used
|
||||||
|
/// as they provide a more fine grained result but you might want to use the simple dot or
|
||||||
|
/// block instead if the targeted terminal does not support those symbols.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::canvas::Canvas;
|
||||||
|
/// # use ratatui::symbols;
|
||||||
|
/// Canvas::default().marker(symbols::Marker::Braille).paint(|ctx| {});
|
||||||
|
///
|
||||||
|
/// Canvas::default().marker(symbols::Marker::Dot).paint(|ctx| {});
|
||||||
|
///
|
||||||
|
/// Canvas::default().marker(symbols::Marker::Block).paint(|ctx| {});
|
||||||
|
/// ```
|
||||||
|
pub fn marker(mut self, marker: symbols::Marker) -> Canvas<'a, F> {
|
||||||
|
self.marker = marker;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, F> Widget for Canvas<'a, F>
|
||||||
|
where
|
||||||
|
F: Fn(&mut Context),
|
||||||
|
{
|
||||||
|
fn render(mut self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let canvas_area = match self.block.take() {
|
||||||
|
Some(b) => {
|
||||||
|
let inner_area = b.inner(area);
|
||||||
|
b.render(area, buf);
|
||||||
|
inner_area
|
||||||
|
}
|
||||||
|
None => area,
|
||||||
|
};
|
||||||
|
|
||||||
|
buf.set_style(canvas_area, Style::default().bg(self.background_color));
|
||||||
|
|
||||||
|
let width = canvas_area.width as usize;
|
||||||
|
|
||||||
|
let painter = match self.painter {
|
||||||
|
Some(ref p) => p,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a blank context that match the size of the canvas
|
||||||
|
let mut ctx = Context::new(
|
||||||
|
canvas_area.width,
|
||||||
|
canvas_area.height,
|
||||||
|
self.x_bounds,
|
||||||
|
self.y_bounds,
|
||||||
|
self.marker,
|
||||||
|
);
|
||||||
|
// Paint to this context
|
||||||
|
painter(&mut ctx);
|
||||||
|
ctx.finish();
|
||||||
|
|
||||||
|
// Retrieve painted points for each layer
|
||||||
|
for layer in ctx.layers {
|
||||||
|
for (i, (ch, color)) in layer
|
||||||
|
.string
|
||||||
|
.chars()
|
||||||
|
.zip(layer.colors.into_iter())
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
if ch != ' ' && ch != '\u{2800}' {
|
||||||
|
let (x, y) = (i % width, i / width);
|
||||||
|
buf.get_mut(x as u16 + canvas_area.left(), y as u16 + canvas_area.top())
|
||||||
|
.set_char(ch)
|
||||||
|
.set_fg(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally draw the labels
|
||||||
|
let left = self.x_bounds[0];
|
||||||
|
let right = self.x_bounds[1];
|
||||||
|
let top = self.y_bounds[1];
|
||||||
|
let bottom = self.y_bounds[0];
|
||||||
|
let width = (self.x_bounds[1] - self.x_bounds[0]).abs();
|
||||||
|
let height = (self.y_bounds[1] - self.y_bounds[0]).abs();
|
||||||
|
let resolution = {
|
||||||
|
let width = f64::from(canvas_area.width - 1);
|
||||||
|
let height = f64::from(canvas_area.height - 1);
|
||||||
|
(width, height)
|
||||||
|
};
|
||||||
|
for label in ctx
|
||||||
|
.labels
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.x >= left && l.x <= right && l.y <= top && l.y >= bottom)
|
||||||
|
{
|
||||||
|
let x = ((label.x - left) * resolution.0 / width) as u16 + canvas_area.left();
|
||||||
|
let y = ((top - label.y) * resolution.1 / height) as u16 + canvas_area.top();
|
||||||
|
buf.set_spans(x, y, &label.spans, canvas_area.right() - x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
30
src/ratatui/widgets/canvas/points.rs
Normal file
30
src/ratatui/widgets/canvas/points.rs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
style::Color,
|
||||||
|
widgets::canvas::{Painter, Shape},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A shape to draw a group of points with the given color
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Points<'a> {
|
||||||
|
pub coords: &'a [(f64, f64)],
|
||||||
|
pub color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Shape for Points<'a> {
|
||||||
|
fn draw(&self, painter: &mut Painter) {
|
||||||
|
for (x, y) in self.coords {
|
||||||
|
if let Some((x, y)) = painter.get_point(*x, *y) {
|
||||||
|
painter.paint(x, y, self.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Default for Points<'a> {
|
||||||
|
fn default() -> Points<'a> {
|
||||||
|
Points {
|
||||||
|
coords: &[],
|
||||||
|
color: Color::Reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
52
src/ratatui/widgets/canvas/rectangle.rs
Normal file
52
src/ratatui/widgets/canvas/rectangle.rs
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
style::Color,
|
||||||
|
widgets::canvas::{Line, Painter, Shape},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Shape to draw a rectangle from a `Rect` with the given color
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Rectangle {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub width: f64,
|
||||||
|
pub height: f64,
|
||||||
|
pub color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shape for Rectangle {
|
||||||
|
fn draw(&self, painter: &mut Painter) {
|
||||||
|
let lines: [Line; 4] = [
|
||||||
|
Line {
|
||||||
|
x1: self.x,
|
||||||
|
y1: self.y,
|
||||||
|
x2: self.x,
|
||||||
|
y2: self.y + self.height,
|
||||||
|
color: self.color,
|
||||||
|
},
|
||||||
|
Line {
|
||||||
|
x1: self.x,
|
||||||
|
y1: self.y + self.height,
|
||||||
|
x2: self.x + self.width,
|
||||||
|
y2: self.y + self.height,
|
||||||
|
color: self.color,
|
||||||
|
},
|
||||||
|
Line {
|
||||||
|
x1: self.x + self.width,
|
||||||
|
y1: self.y,
|
||||||
|
x2: self.x + self.width,
|
||||||
|
y2: self.y + self.height,
|
||||||
|
color: self.color,
|
||||||
|
},
|
||||||
|
Line {
|
||||||
|
x1: self.x,
|
||||||
|
y1: self.y,
|
||||||
|
x2: self.x + self.width,
|
||||||
|
y2: self.y,
|
||||||
|
color: self.color,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for line in &lines {
|
||||||
|
line.draw(painter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
6299
src/ratatui/widgets/canvas/world.rs
Normal file
6299
src/ratatui/widgets/canvas/world.rs
Normal file
File diff suppressed because it is too large
Load diff
660
src/ratatui/widgets/chart.rs
Normal file
660
src/ratatui/widgets/chart.rs
Normal file
|
@ -0,0 +1,660 @@
|
||||||
|
use std::{borrow::Cow, cmp::max};
|
||||||
|
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
use crate::ratatui::layout::Alignment;
|
||||||
|
use crate::ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Constraint, Rect},
|
||||||
|
style::{Color, Style},
|
||||||
|
symbols,
|
||||||
|
text::{Span, Spans},
|
||||||
|
widgets::{
|
||||||
|
canvas::{Canvas, Line, Points},
|
||||||
|
Block, Borders, Widget,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// An X or Y axis for the chart widget
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Axis<'a> {
|
||||||
|
/// Title displayed next to axis end
|
||||||
|
title: Option<Spans<'a>>,
|
||||||
|
/// Bounds for the axis (all data points outside these limits will not be represented)
|
||||||
|
bounds: [f64; 2],
|
||||||
|
/// A list of labels to put to the left or below the axis
|
||||||
|
labels: Option<Vec<Span<'a>>>,
|
||||||
|
/// The style used to draw the axis itself
|
||||||
|
style: Style,
|
||||||
|
/// The alignment of the labels of the Axis
|
||||||
|
labels_alignment: Alignment,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Default for Axis<'a> {
|
||||||
|
fn default() -> Axis<'a> {
|
||||||
|
Axis {
|
||||||
|
title: None,
|
||||||
|
bounds: [0.0, 0.0],
|
||||||
|
labels: None,
|
||||||
|
style: Default::default(),
|
||||||
|
labels_alignment: Alignment::Left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Axis<'a> {
|
||||||
|
pub fn title<T>(mut self, title: T) -> Axis<'a>
|
||||||
|
where
|
||||||
|
T: Into<Spans<'a>>,
|
||||||
|
{
|
||||||
|
self.title = Some(title.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[deprecated(
|
||||||
|
since = "0.10.0",
|
||||||
|
note = "You should use styling capabilities of `text::Spans` given as argument of the `title` method to apply styling to the title."
|
||||||
|
)]
|
||||||
|
pub fn title_style(mut self, style: Style) -> Axis<'a> {
|
||||||
|
if let Some(t) = self.title {
|
||||||
|
let title = String::from(t);
|
||||||
|
self.title = Some(Spans::from(Span::styled(title, style)));
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bounds(mut self, bounds: [f64; 2]) -> Axis<'a> {
|
||||||
|
self.bounds = bounds;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn labels(mut self, labels: Vec<Span<'a>>) -> Axis<'a> {
|
||||||
|
self.labels = Some(labels);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> Axis<'a> {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Defines the alignment of the labels of the axis.
|
||||||
|
/// The alignment behaves differently based on the axis:
|
||||||
|
/// - Y-Axis: The labels are aligned within the area on the left of the axis
|
||||||
|
/// - X-Axis: The first X-axis label is aligned relative to the Y-axis
|
||||||
|
pub fn labels_alignment(mut self, alignment: Alignment) -> Axis<'a> {
|
||||||
|
self.labels_alignment = alignment;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used to determine which style of graphing to use
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum GraphType {
|
||||||
|
/// Draw each point
|
||||||
|
Scatter,
|
||||||
|
/// Draw each point and lines between each point using the same marker
|
||||||
|
Line,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group of data points
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Dataset<'a> {
|
||||||
|
/// Name of the dataset (used in the legend if shown)
|
||||||
|
name: Cow<'a, str>,
|
||||||
|
/// A reference to the actual data
|
||||||
|
data: &'a [(f64, f64)],
|
||||||
|
/// Symbol used for each points of this dataset
|
||||||
|
marker: symbols::Marker,
|
||||||
|
/// Determines graph type used for drawing points
|
||||||
|
graph_type: GraphType,
|
||||||
|
/// Style used to plot this dataset
|
||||||
|
style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Default for Dataset<'a> {
|
||||||
|
fn default() -> Dataset<'a> {
|
||||||
|
Dataset {
|
||||||
|
name: Cow::from(""),
|
||||||
|
data: &[],
|
||||||
|
marker: symbols::Marker::Dot,
|
||||||
|
graph_type: GraphType::Scatter,
|
||||||
|
style: Style::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Dataset<'a> {
|
||||||
|
pub fn name<S>(mut self, name: S) -> Dataset<'a>
|
||||||
|
where
|
||||||
|
S: Into<Cow<'a, str>>,
|
||||||
|
{
|
||||||
|
self.name = name.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data(mut self, data: &'a [(f64, f64)]) -> Dataset<'a> {
|
||||||
|
self.data = data;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn marker(mut self, marker: symbols::Marker) -> Dataset<'a> {
|
||||||
|
self.marker = marker;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn graph_type(mut self, graph_type: GraphType) -> Dataset<'a> {
|
||||||
|
self.graph_type = graph_type;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> Dataset<'a> {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A container that holds all the infos about where to display each elements of the chart (axis,
|
||||||
|
/// labels, legend, ...).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
struct ChartLayout {
|
||||||
|
/// Location of the title of the x axis
|
||||||
|
title_x: Option<(u16, u16)>,
|
||||||
|
/// Location of the title of the y axis
|
||||||
|
title_y: Option<(u16, u16)>,
|
||||||
|
/// Location of the first label of the x axis
|
||||||
|
label_x: Option<u16>,
|
||||||
|
/// Location of the first label of the y axis
|
||||||
|
label_y: Option<u16>,
|
||||||
|
/// Y coordinate of the horizontal axis
|
||||||
|
axis_x: Option<u16>,
|
||||||
|
/// X coordinate of the vertical axis
|
||||||
|
axis_y: Option<u16>,
|
||||||
|
/// Area of the legend
|
||||||
|
legend_area: Option<Rect>,
|
||||||
|
/// Area of the graph
|
||||||
|
graph_area: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A widget to plot one or more dataset in a cartesian coordinate system
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::symbols;
|
||||||
|
/// # use ratatui::widgets::{Block, Borders, Chart, Axis, Dataset, GraphType};
|
||||||
|
/// # use ratatui::style::{Style, Color};
|
||||||
|
/// # use ratatui::text::Span;
|
||||||
|
/// let datasets = vec![
|
||||||
|
/// Dataset::default()
|
||||||
|
/// .name("data1")
|
||||||
|
/// .marker(symbols::Marker::Dot)
|
||||||
|
/// .graph_type(GraphType::Scatter)
|
||||||
|
/// .style(Style::default().fg(Color::Cyan))
|
||||||
|
/// .data(&[(0.0, 5.0), (1.0, 6.0), (1.5, 6.434)]),
|
||||||
|
/// Dataset::default()
|
||||||
|
/// .name("data2")
|
||||||
|
/// .marker(symbols::Marker::Braille)
|
||||||
|
/// .graph_type(GraphType::Line)
|
||||||
|
/// .style(Style::default().fg(Color::Magenta))
|
||||||
|
/// .data(&[(4.0, 5.0), (5.0, 8.0), (7.66, 13.5)]),
|
||||||
|
/// ];
|
||||||
|
/// Chart::new(datasets)
|
||||||
|
/// .block(Block::default().title("Chart"))
|
||||||
|
/// .x_axis(Axis::default()
|
||||||
|
/// .title(Span::styled("X Axis", Style::default().fg(Color::Red)))
|
||||||
|
/// .style(Style::default().fg(Color::White))
|
||||||
|
/// .bounds([0.0, 10.0])
|
||||||
|
/// .labels(["0.0", "5.0", "10.0"].iter().cloned().map(Span::from).collect()))
|
||||||
|
/// .y_axis(Axis::default()
|
||||||
|
/// .title(Span::styled("Y Axis", Style::default().fg(Color::Red)))
|
||||||
|
/// .style(Style::default().fg(Color::White))
|
||||||
|
/// .bounds([0.0, 10.0])
|
||||||
|
/// .labels(["0.0", "5.0", "10.0"].iter().cloned().map(Span::from).collect()));
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Chart<'a> {
|
||||||
|
/// A block to display around the widget eventually
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
/// The horizontal axis
|
||||||
|
x_axis: Axis<'a>,
|
||||||
|
/// The vertical axis
|
||||||
|
y_axis: Axis<'a>,
|
||||||
|
/// A reference to the datasets
|
||||||
|
datasets: Vec<Dataset<'a>>,
|
||||||
|
/// The widget base style
|
||||||
|
style: Style,
|
||||||
|
/// Constraints used to determine whether the legend should be shown or not
|
||||||
|
hidden_legend_constraints: (Constraint, Constraint),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Chart<'a> {
|
||||||
|
pub fn new(datasets: Vec<Dataset<'a>>) -> Chart<'a> {
|
||||||
|
Chart {
|
||||||
|
block: None,
|
||||||
|
x_axis: Axis::default(),
|
||||||
|
y_axis: Axis::default(),
|
||||||
|
style: Default::default(),
|
||||||
|
datasets,
|
||||||
|
hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn block(mut self, block: Block<'a>) -> Chart<'a> {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> Chart<'a> {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn x_axis(mut self, axis: Axis<'a>) -> Chart<'a> {
|
||||||
|
self.x_axis = axis;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn y_axis(mut self, axis: Axis<'a>) -> Chart<'a> {
|
||||||
|
self.y_axis = axis;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the constraints used to determine whether the legend should be shown or not.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::Chart;
|
||||||
|
/// # use ratatui::layout::Constraint;
|
||||||
|
/// let constraints = (
|
||||||
|
/// Constraint::Ratio(1, 3),
|
||||||
|
/// Constraint::Ratio(1, 4)
|
||||||
|
/// );
|
||||||
|
/// // Hide the legend when either its width is greater than 33% of the total widget width
|
||||||
|
/// // or if its height is greater than 25% of the total widget height.
|
||||||
|
/// let _chart: Chart = Chart::new(vec![])
|
||||||
|
/// .hidden_legend_constraints(constraints);
|
||||||
|
/// ```
|
||||||
|
pub fn hidden_legend_constraints(mut self, constraints: (Constraint, Constraint)) -> Chart<'a> {
|
||||||
|
self.hidden_legend_constraints = constraints;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the internal layout of the chart given the area. If the area is too small some
|
||||||
|
/// elements may be automatically hidden
|
||||||
|
fn layout(&self, area: Rect) -> ChartLayout {
|
||||||
|
let mut layout = ChartLayout::default();
|
||||||
|
if area.height == 0 || area.width == 0 {
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
let mut x = area.left();
|
||||||
|
let mut y = area.bottom() - 1;
|
||||||
|
|
||||||
|
if self.x_axis.labels.is_some() && y > area.top() {
|
||||||
|
layout.label_x = Some(y);
|
||||||
|
y -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
layout.label_y = self.y_axis.labels.as_ref().and(Some(x));
|
||||||
|
x += self.max_width_of_labels_left_of_y_axis(area, self.y_axis.labels.is_some());
|
||||||
|
|
||||||
|
if self.x_axis.labels.is_some() && y > area.top() {
|
||||||
|
layout.axis_x = Some(y);
|
||||||
|
y -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.y_axis.labels.is_some() && x + 1 < area.right() {
|
||||||
|
layout.axis_y = Some(x);
|
||||||
|
x += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if x < area.right() && y > 1 {
|
||||||
|
layout.graph_area = Rect::new(x, area.top(), area.right() - x, y - area.top() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref title) = self.x_axis.title {
|
||||||
|
let w = title.width() as u16;
|
||||||
|
if w < layout.graph_area.width && layout.graph_area.height > 2 {
|
||||||
|
layout.title_x = Some((x + layout.graph_area.width - w, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref title) = self.y_axis.title {
|
||||||
|
let w = title.width() as u16;
|
||||||
|
if w + 1 < layout.graph_area.width && layout.graph_area.height > 2 {
|
||||||
|
layout.title_y = Some((x, area.top()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(inner_width) = self.datasets.iter().map(|d| d.name.width() as u16).max() {
|
||||||
|
let legend_width = inner_width + 2;
|
||||||
|
let legend_height = self.datasets.len() as u16 + 2;
|
||||||
|
let max_legend_width = self
|
||||||
|
.hidden_legend_constraints
|
||||||
|
.0
|
||||||
|
.apply(layout.graph_area.width);
|
||||||
|
let max_legend_height = self
|
||||||
|
.hidden_legend_constraints
|
||||||
|
.1
|
||||||
|
.apply(layout.graph_area.height);
|
||||||
|
if inner_width > 0
|
||||||
|
&& legend_width < max_legend_width
|
||||||
|
&& legend_height < max_legend_height
|
||||||
|
{
|
||||||
|
layout.legend_area = Some(Rect::new(
|
||||||
|
layout.graph_area.right() - legend_width,
|
||||||
|
layout.graph_area.top(),
|
||||||
|
legend_width,
|
||||||
|
legend_height,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_width_of_labels_left_of_y_axis(&self, area: Rect, has_y_axis: bool) -> u16 {
|
||||||
|
let mut max_width = self
|
||||||
|
.y_axis
|
||||||
|
.labels
|
||||||
|
.as_ref()
|
||||||
|
.map(|l| l.iter().map(Span::width).max().unwrap_or_default() as u16)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if let Some(first_x_label) = self.x_axis.labels.as_ref().and_then(|labels| labels.get(0)) {
|
||||||
|
let first_label_width = first_x_label.content.width() as u16;
|
||||||
|
let width_left_of_y_axis = match self.x_axis.labels_alignment {
|
||||||
|
Alignment::Left => {
|
||||||
|
// The last character of the label should be below the Y-Axis when it exists, not on its left
|
||||||
|
let y_axis_offset = if has_y_axis { 1 } else { 0 };
|
||||||
|
first_label_width.saturating_sub(y_axis_offset)
|
||||||
|
}
|
||||||
|
Alignment::Center => first_label_width / 2,
|
||||||
|
Alignment::Right => 0,
|
||||||
|
};
|
||||||
|
max_width = max(max_width, width_left_of_y_axis);
|
||||||
|
}
|
||||||
|
// labels of y axis and first label of x axis can take at most 1/3rd of the total width
|
||||||
|
max_width.min(area.width / 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_x_labels(
|
||||||
|
&mut self,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
layout: &ChartLayout,
|
||||||
|
chart_area: Rect,
|
||||||
|
graph_area: Rect,
|
||||||
|
) {
|
||||||
|
let y = match layout.label_x {
|
||||||
|
Some(y) => y,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let labels = self.x_axis.labels.as_ref().unwrap();
|
||||||
|
let labels_len = labels.len() as u16;
|
||||||
|
if labels_len < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let width_between_ticks = graph_area.width / labels_len;
|
||||||
|
|
||||||
|
let label_area = self.first_x_label_area(
|
||||||
|
y,
|
||||||
|
labels.first().unwrap().width() as u16,
|
||||||
|
width_between_ticks,
|
||||||
|
chart_area,
|
||||||
|
graph_area,
|
||||||
|
);
|
||||||
|
|
||||||
|
let label_alignment = match self.x_axis.labels_alignment {
|
||||||
|
Alignment::Left => Alignment::Right,
|
||||||
|
Alignment::Center => Alignment::Center,
|
||||||
|
Alignment::Right => Alignment::Left,
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::render_label(buf, labels.first().unwrap(), label_area, label_alignment);
|
||||||
|
|
||||||
|
for (i, label) in labels[1..labels.len() - 1].iter().enumerate() {
|
||||||
|
// We add 1 to x (and width-1 below) to leave at least one space before each intermediate labels
|
||||||
|
let x = graph_area.left() + (i + 1) as u16 * width_between_ticks + 1;
|
||||||
|
let label_area = Rect::new(x, y, width_between_ticks.saturating_sub(1), 1);
|
||||||
|
|
||||||
|
Self::render_label(buf, label, label_area, Alignment::Center);
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = graph_area.right() - width_between_ticks;
|
||||||
|
let label_area = Rect::new(x, y, width_between_ticks, 1);
|
||||||
|
// The last label should be aligned Right to be at the edge of the graph area
|
||||||
|
Self::render_label(buf, labels.last().unwrap(), label_area, Alignment::Right);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_x_label_area(
|
||||||
|
&self,
|
||||||
|
y: u16,
|
||||||
|
label_width: u16,
|
||||||
|
max_width_after_y_axis: u16,
|
||||||
|
chart_area: Rect,
|
||||||
|
graph_area: Rect,
|
||||||
|
) -> Rect {
|
||||||
|
let (min_x, max_x) = match self.x_axis.labels_alignment {
|
||||||
|
Alignment::Left => (chart_area.left(), graph_area.left()),
|
||||||
|
Alignment::Center => (
|
||||||
|
chart_area.left(),
|
||||||
|
graph_area.left() + max_width_after_y_axis.min(label_width),
|
||||||
|
),
|
||||||
|
Alignment::Right => (
|
||||||
|
graph_area.left().saturating_sub(1),
|
||||||
|
graph_area.left() + max_width_after_y_axis,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
Rect::new(min_x, y, max_x - min_x, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_label(buf: &mut Buffer, label: &Span, label_area: Rect, alignment: Alignment) {
|
||||||
|
let label_width = label.width() as u16;
|
||||||
|
let bounded_label_width = label_area.width.min(label_width);
|
||||||
|
|
||||||
|
let x = match alignment {
|
||||||
|
Alignment::Left => label_area.left(),
|
||||||
|
Alignment::Center => label_area.left() + label_area.width / 2 - bounded_label_width / 2,
|
||||||
|
Alignment::Right => label_area.right() - bounded_label_width,
|
||||||
|
};
|
||||||
|
|
||||||
|
buf.set_span(x, label_area.top(), label, bounded_label_width);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_y_labels(
|
||||||
|
&mut self,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
layout: &ChartLayout,
|
||||||
|
chart_area: Rect,
|
||||||
|
graph_area: Rect,
|
||||||
|
) {
|
||||||
|
let x = match layout.label_y {
|
||||||
|
Some(x) => x,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let labels = self.y_axis.labels.as_ref().unwrap();
|
||||||
|
let labels_len = labels.len() as u16;
|
||||||
|
for (i, label) in labels.iter().enumerate() {
|
||||||
|
let dy = i as u16 * (graph_area.height - 1) / (labels_len - 1);
|
||||||
|
if dy < graph_area.bottom() {
|
||||||
|
let label_area = Rect::new(
|
||||||
|
x,
|
||||||
|
graph_area.bottom().saturating_sub(1) - dy,
|
||||||
|
(graph_area.left() - chart_area.left()).saturating_sub(1),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
Self::render_label(buf, label, label_area, self.y_axis.labels_alignment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Chart<'a> {
|
||||||
|
fn render(mut self, area: Rect, buf: &mut Buffer) {
|
||||||
|
if area.area() == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buf.set_style(area, self.style);
|
||||||
|
// Sample the style of the entire widget. This sample will be used to reset the style of
|
||||||
|
// the cells that are part of the components put on top of the grah area (i.e legend and
|
||||||
|
// axis names).
|
||||||
|
let original_style = buf.get(area.left(), area.top()).style();
|
||||||
|
|
||||||
|
let chart_area = match self.block.take() {
|
||||||
|
Some(b) => {
|
||||||
|
let inner_area = b.inner(area);
|
||||||
|
b.render(area, buf);
|
||||||
|
inner_area
|
||||||
|
}
|
||||||
|
None => area,
|
||||||
|
};
|
||||||
|
|
||||||
|
let layout = self.layout(chart_area);
|
||||||
|
let graph_area = layout.graph_area;
|
||||||
|
if graph_area.width < 1 || graph_area.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.render_x_labels(buf, &layout, chart_area, graph_area);
|
||||||
|
self.render_y_labels(buf, &layout, chart_area, graph_area);
|
||||||
|
|
||||||
|
if let Some(y) = layout.axis_x {
|
||||||
|
for x in graph_area.left()..graph_area.right() {
|
||||||
|
buf.get_mut(x, y)
|
||||||
|
.set_symbol(symbols::line::HORIZONTAL)
|
||||||
|
.set_style(self.x_axis.style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(x) = layout.axis_y {
|
||||||
|
for y in graph_area.top()..graph_area.bottom() {
|
||||||
|
buf.get_mut(x, y)
|
||||||
|
.set_symbol(symbols::line::VERTICAL)
|
||||||
|
.set_style(self.y_axis.style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(y) = layout.axis_x {
|
||||||
|
if let Some(x) = layout.axis_y {
|
||||||
|
buf.get_mut(x, y)
|
||||||
|
.set_symbol(symbols::line::BOTTOM_LEFT)
|
||||||
|
.set_style(self.x_axis.style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for dataset in &self.datasets {
|
||||||
|
Canvas::default()
|
||||||
|
.background_color(self.style.bg.unwrap_or(Color::Reset))
|
||||||
|
.x_bounds(self.x_axis.bounds)
|
||||||
|
.y_bounds(self.y_axis.bounds)
|
||||||
|
.marker(dataset.marker)
|
||||||
|
.paint(|ctx| {
|
||||||
|
ctx.draw(&Points {
|
||||||
|
coords: dataset.data,
|
||||||
|
color: dataset.style.fg.unwrap_or(Color::Reset),
|
||||||
|
});
|
||||||
|
if let GraphType::Line = dataset.graph_type {
|
||||||
|
for data in dataset.data.windows(2) {
|
||||||
|
ctx.draw(&Line {
|
||||||
|
x1: data[0].0,
|
||||||
|
y1: data[0].1,
|
||||||
|
x2: data[1].0,
|
||||||
|
y2: data[1].1,
|
||||||
|
color: dataset.style.fg.unwrap_or(Color::Reset),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.render(graph_area, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(legend_area) = layout.legend_area {
|
||||||
|
buf.set_style(legend_area, original_style);
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.render(legend_area, buf);
|
||||||
|
for (i, dataset) in self.datasets.iter().enumerate() {
|
||||||
|
buf.set_string(
|
||||||
|
legend_area.x + 1,
|
||||||
|
legend_area.y + 1 + i as u16,
|
||||||
|
&dataset.name,
|
||||||
|
dataset.style,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((x, y)) = layout.title_x {
|
||||||
|
let title = self.x_axis.title.unwrap();
|
||||||
|
let width = graph_area.right().saturating_sub(x);
|
||||||
|
buf.set_style(
|
||||||
|
Rect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
original_style,
|
||||||
|
);
|
||||||
|
buf.set_spans(x, y, &title, width);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((x, y)) = layout.title_y {
|
||||||
|
let title = self.y_axis.title.unwrap();
|
||||||
|
let width = graph_area.right().saturating_sub(x);
|
||||||
|
buf.set_style(
|
||||||
|
Rect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
original_style,
|
||||||
|
);
|
||||||
|
buf.set_spans(x, y, &title, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct LegendTestCase {
|
||||||
|
chart_area: Rect,
|
||||||
|
hidden_legend_constraints: (Constraint, Constraint),
|
||||||
|
legend_area: Option<Rect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_should_hide_the_legend() {
|
||||||
|
let data = [(0.0, 5.0), (1.0, 6.0), (3.0, 7.0)];
|
||||||
|
let cases = [
|
||||||
|
LegendTestCase {
|
||||||
|
chart_area: Rect::new(0, 0, 100, 100),
|
||||||
|
hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)),
|
||||||
|
legend_area: Some(Rect::new(88, 0, 12, 12)),
|
||||||
|
},
|
||||||
|
LegendTestCase {
|
||||||
|
chart_area: Rect::new(0, 0, 100, 100),
|
||||||
|
hidden_legend_constraints: (Constraint::Ratio(1, 10), Constraint::Ratio(1, 4)),
|
||||||
|
legend_area: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for case in &cases {
|
||||||
|
let datasets = (0..10)
|
||||||
|
.map(|i| {
|
||||||
|
let name = format!("Dataset #{}", i);
|
||||||
|
Dataset::default().name(name).data(&data)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let chart = Chart::new(datasets)
|
||||||
|
.x_axis(Axis::default().title("X axis"))
|
||||||
|
.y_axis(Axis::default().title("Y axis"))
|
||||||
|
.hidden_legend_constraints(case.hidden_legend_constraints);
|
||||||
|
let layout = chart.layout(case.chart_area);
|
||||||
|
assert_eq!(layout.legend_area, case.legend_area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
37
src/ratatui/widgets/clear.rs
Normal file
37
src/ratatui/widgets/clear.rs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
use crate::ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
|
||||||
|
|
||||||
|
/// A widget to clear/reset a certain area to allow overdrawing (e.g. for popups).
|
||||||
|
///
|
||||||
|
/// This widget **cannot be used to clear the terminal on the first render** as `ratatui` assumes the
|
||||||
|
/// render area is empty. Use [`crate::Terminal::clear`] instead.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::{Clear, Block, Borders};
|
||||||
|
/// # use ratatui::layout::Rect;
|
||||||
|
/// # use ratatui::Frame;
|
||||||
|
/// # use ratatui::backend::Backend;
|
||||||
|
/// fn draw_on_clear<B: Backend>(f: &mut Frame<B>, area: Rect) {
|
||||||
|
/// let block = Block::default().title("Block").borders(Borders::ALL);
|
||||||
|
/// f.render_widget(Clear, area); // <- this will clear/reset the area first
|
||||||
|
/// f.render_widget(block, area); // now render the block widget
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Popup Example
|
||||||
|
///
|
||||||
|
/// For a more complete example how to utilize `Clear` to realize popups see
|
||||||
|
/// the example `examples/popup.rs`
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Clear;
|
||||||
|
|
||||||
|
impl Widget for Clear {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
for x in area.left()..area.right() {
|
||||||
|
for y in area.top()..area.bottom() {
|
||||||
|
buf.get_mut(x, y).reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
313
src/ratatui/widgets/gauge.rs
Normal file
313
src/ratatui/widgets/gauge.rs
Normal file
|
@ -0,0 +1,313 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Style},
|
||||||
|
symbols,
|
||||||
|
text::{Span, Spans},
|
||||||
|
widgets::{Block, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A widget to display a task progress.
|
||||||
|
///
|
||||||
|
/// # Examples:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::{Widget, Gauge, Block, Borders};
|
||||||
|
/// # use ratatui::style::{Style, Color, Modifier};
|
||||||
|
/// Gauge::default()
|
||||||
|
/// .block(Block::default().borders(Borders::ALL).title("Progress"))
|
||||||
|
/// .gauge_style(Style::default().fg(Color::White).bg(Color::Black).add_modifier(Modifier::ITALIC))
|
||||||
|
/// .percent(20);
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Gauge<'a> {
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
ratio: f64,
|
||||||
|
label: Option<Span<'a>>,
|
||||||
|
use_unicode: bool,
|
||||||
|
style: Style,
|
||||||
|
gauge_style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Default for Gauge<'a> {
|
||||||
|
fn default() -> Gauge<'a> {
|
||||||
|
Gauge {
|
||||||
|
block: None,
|
||||||
|
ratio: 0.0,
|
||||||
|
label: None,
|
||||||
|
use_unicode: false,
|
||||||
|
style: Style::default(),
|
||||||
|
gauge_style: Style::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Gauge<'a> {
|
||||||
|
pub fn block(mut self, block: Block<'a>) -> Gauge<'a> {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn percent(mut self, percent: u16) -> Gauge<'a> {
|
||||||
|
assert!(
|
||||||
|
percent <= 100,
|
||||||
|
"Percentage should be between 0 and 100 inclusively."
|
||||||
|
);
|
||||||
|
self.ratio = f64::from(percent) / 100.0;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets ratio ([0.0, 1.0]) directly.
|
||||||
|
pub fn ratio(mut self, ratio: f64) -> Gauge<'a> {
|
||||||
|
assert!(
|
||||||
|
(0.0..=1.0).contains(&ratio),
|
||||||
|
"Ratio should be between 0 and 1 inclusively."
|
||||||
|
);
|
||||||
|
self.ratio = ratio;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label<T>(mut self, label: T) -> Gauge<'a>
|
||||||
|
where
|
||||||
|
T: Into<Span<'a>>,
|
||||||
|
{
|
||||||
|
self.label = Some(label.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> Gauge<'a> {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gauge_style(mut self, style: Style) -> Gauge<'a> {
|
||||||
|
self.gauge_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_unicode(mut self, unicode: bool) -> Gauge<'a> {
|
||||||
|
self.use_unicode = unicode;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Gauge<'a> {
|
||||||
|
fn render(mut self, area: Rect, buf: &mut Buffer) {
|
||||||
|
buf.set_style(area, self.style);
|
||||||
|
let gauge_area = match self.block.take() {
|
||||||
|
Some(b) => {
|
||||||
|
let inner_area = b.inner(area);
|
||||||
|
b.render(area, buf);
|
||||||
|
inner_area
|
||||||
|
}
|
||||||
|
None => area,
|
||||||
|
};
|
||||||
|
buf.set_style(gauge_area, self.gauge_style);
|
||||||
|
if gauge_area.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// compute label value and its position
|
||||||
|
// label is put at the center of the gauge_area
|
||||||
|
let label = {
|
||||||
|
let pct = f64::round(self.ratio * 100.0);
|
||||||
|
self.label
|
||||||
|
.unwrap_or_else(|| Span::from(format!("{}%", pct)))
|
||||||
|
};
|
||||||
|
let clamped_label_width = gauge_area.width.min(label.width() as u16);
|
||||||
|
let label_col = gauge_area.left() + (gauge_area.width - clamped_label_width) / 2;
|
||||||
|
let label_row = gauge_area.top() + gauge_area.height / 2;
|
||||||
|
|
||||||
|
// the gauge will be filled proportionally to the ratio
|
||||||
|
let filled_width = f64::from(gauge_area.width) * self.ratio;
|
||||||
|
let end = if self.use_unicode {
|
||||||
|
gauge_area.left() + filled_width.floor() as u16
|
||||||
|
} else {
|
||||||
|
gauge_area.left() + filled_width.round() as u16
|
||||||
|
};
|
||||||
|
for y in gauge_area.top()..gauge_area.bottom() {
|
||||||
|
// render the filled area (left to end)
|
||||||
|
for x in gauge_area.left()..end {
|
||||||
|
// spaces are needed to apply the background styling
|
||||||
|
buf.get_mut(x, y)
|
||||||
|
.set_symbol(" ")
|
||||||
|
.set_fg(self.gauge_style.bg.unwrap_or(Color::Reset))
|
||||||
|
.set_bg(self.gauge_style.fg.unwrap_or(Color::Reset));
|
||||||
|
}
|
||||||
|
if self.use_unicode && self.ratio < 1.0 {
|
||||||
|
buf.get_mut(end, y)
|
||||||
|
.set_symbol(get_unicode_block(filled_width % 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// set the span
|
||||||
|
buf.set_span(label_col, label_row, &label, clamped_label_width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_unicode_block<'a>(frac: f64) -> &'a str {
|
||||||
|
match (frac * 8.0).round() as u16 {
|
||||||
|
1 => symbols::block::ONE_EIGHTH,
|
||||||
|
2 => symbols::block::ONE_QUARTER,
|
||||||
|
3 => symbols::block::THREE_EIGHTHS,
|
||||||
|
4 => symbols::block::HALF,
|
||||||
|
5 => symbols::block::FIVE_EIGHTHS,
|
||||||
|
6 => symbols::block::THREE_QUARTERS,
|
||||||
|
7 => symbols::block::SEVEN_EIGHTHS,
|
||||||
|
8 => symbols::block::FULL,
|
||||||
|
_ => " ",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compact widget to display a task progress over a single line.
|
||||||
|
///
|
||||||
|
/// # Examples:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::{Widget, LineGauge, Block, Borders};
|
||||||
|
/// # use ratatui::style::{Style, Color, Modifier};
|
||||||
|
/// # use ratatui::symbols;
|
||||||
|
/// LineGauge::default()
|
||||||
|
/// .block(Block::default().borders(Borders::ALL).title("Progress"))
|
||||||
|
/// .gauge_style(Style::default().fg(Color::White).bg(Color::Black).add_modifier(Modifier::BOLD))
|
||||||
|
/// .line_set(symbols::line::THICK)
|
||||||
|
/// .ratio(0.4);
|
||||||
|
/// ```
|
||||||
|
pub struct LineGauge<'a> {
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
ratio: f64,
|
||||||
|
label: Option<Spans<'a>>,
|
||||||
|
line_set: symbols::line::Set,
|
||||||
|
style: Style,
|
||||||
|
gauge_style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Default for LineGauge<'a> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
block: None,
|
||||||
|
ratio: 0.0,
|
||||||
|
label: None,
|
||||||
|
style: Style::default(),
|
||||||
|
line_set: symbols::line::NORMAL,
|
||||||
|
gauge_style: Style::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> LineGauge<'a> {
|
||||||
|
pub fn block(mut self, block: Block<'a>) -> Self {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ratio(mut self, ratio: f64) -> Self {
|
||||||
|
assert!(
|
||||||
|
(0.0..=1.0).contains(&ratio),
|
||||||
|
"Ratio should be between 0 and 1 inclusively."
|
||||||
|
);
|
||||||
|
self.ratio = ratio;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn line_set(mut self, set: symbols::line::Set) -> Self {
|
||||||
|
self.line_set = set;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label<T>(mut self, label: T) -> Self
|
||||||
|
where
|
||||||
|
T: Into<Spans<'a>>,
|
||||||
|
{
|
||||||
|
self.label = Some(label.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> Self {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gauge_style(mut self, style: Style) -> Self {
|
||||||
|
self.gauge_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for LineGauge<'a> {
|
||||||
|
fn render(mut self, area: Rect, buf: &mut Buffer) {
|
||||||
|
buf.set_style(area, self.style);
|
||||||
|
let gauge_area = match self.block.take() {
|
||||||
|
Some(b) => {
|
||||||
|
let inner_area = b.inner(area);
|
||||||
|
b.render(area, buf);
|
||||||
|
inner_area
|
||||||
|
}
|
||||||
|
None => area,
|
||||||
|
};
|
||||||
|
|
||||||
|
if gauge_area.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ratio = self.ratio;
|
||||||
|
let label = self
|
||||||
|
.label
|
||||||
|
.unwrap_or_else(move || Spans::from(format!("{:.0}%", ratio * 100.0)));
|
||||||
|
let (col, row) = buf.set_spans(
|
||||||
|
gauge_area.left(),
|
||||||
|
gauge_area.top(),
|
||||||
|
&label,
|
||||||
|
gauge_area.width,
|
||||||
|
);
|
||||||
|
let start = col + 1;
|
||||||
|
if start >= gauge_area.right() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = start
|
||||||
|
+ (f64::from(gauge_area.right().saturating_sub(start)) * self.ratio).floor() as u16;
|
||||||
|
for col in start..end {
|
||||||
|
buf.get_mut(col, row)
|
||||||
|
.set_symbol(self.line_set.horizontal)
|
||||||
|
.set_style(Style {
|
||||||
|
fg: self.gauge_style.fg,
|
||||||
|
bg: None,
|
||||||
|
add_modifier: self.gauge_style.add_modifier,
|
||||||
|
sub_modifier: self.gauge_style.sub_modifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for col in end..gauge_area.right() {
|
||||||
|
buf.get_mut(col, row)
|
||||||
|
.set_symbol(self.line_set.horizontal)
|
||||||
|
.set_style(Style {
|
||||||
|
fg: self.gauge_style.bg,
|
||||||
|
bg: None,
|
||||||
|
add_modifier: self.gauge_style.add_modifier,
|
||||||
|
sub_modifier: self.gauge_style.sub_modifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic]
|
||||||
|
fn gauge_invalid_percentage() {
|
||||||
|
Gauge::default().percent(110);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic]
|
||||||
|
fn gauge_invalid_ratio_upper_bound() {
|
||||||
|
Gauge::default().ratio(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic]
|
||||||
|
fn gauge_invalid_ratio_lower_bound() {
|
||||||
|
Gauge::default().ratio(-0.5);
|
||||||
|
}
|
||||||
|
}
|
268
src/ratatui/widgets/list.rs
Normal file
268
src/ratatui/widgets/list.rs
Normal file
|
@ -0,0 +1,268 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Corner, Rect},
|
||||||
|
style::Style,
|
||||||
|
text::Text,
|
||||||
|
widgets::{Block, StatefulWidget, Widget},
|
||||||
|
};
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ListState {
|
||||||
|
offset: usize,
|
||||||
|
selected: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListState {
|
||||||
|
pub fn selected(&self) -> Option<usize> {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&mut self, index: Option<usize>) {
|
||||||
|
self.selected = index;
|
||||||
|
if index.is_none() {
|
||||||
|
self.offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListItem<'a> {
|
||||||
|
content: Text<'a>,
|
||||||
|
style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ListItem<'a> {
|
||||||
|
pub fn new<T>(content: T) -> ListItem<'a>
|
||||||
|
where
|
||||||
|
T: Into<Text<'a>>,
|
||||||
|
{
|
||||||
|
ListItem {
|
||||||
|
content: content.into(),
|
||||||
|
style: Style::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> ListItem<'a> {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height(&self) -> usize {
|
||||||
|
self.content.height()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn width(&self) -> usize {
|
||||||
|
self.content.width()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A widget to display several items among which one can be selected (optional)
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::{Block, Borders, List, ListItem};
|
||||||
|
/// # use ratatui::style::{Style, Color, Modifier};
|
||||||
|
/// let items = [ListItem::new("Item 1"), ListItem::new("Item 2"), ListItem::new("Item 3")];
|
||||||
|
/// List::new(items)
|
||||||
|
/// .block(Block::default().title("List").borders(Borders::ALL))
|
||||||
|
/// .style(Style::default().fg(Color::White))
|
||||||
|
/// .highlight_style(Style::default().add_modifier(Modifier::ITALIC))
|
||||||
|
/// .highlight_symbol(">>");
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct List<'a> {
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
items: Vec<ListItem<'a>>,
|
||||||
|
/// Style used as a base style for the widget
|
||||||
|
style: Style,
|
||||||
|
start_corner: Corner,
|
||||||
|
/// Style used to render selected item
|
||||||
|
highlight_style: Style,
|
||||||
|
/// Symbol in front of the selected item (Shift all items to the right)
|
||||||
|
highlight_symbol: Option<&'a str>,
|
||||||
|
/// Whether to repeat the highlight symbol for each line of the selected item
|
||||||
|
repeat_highlight_symbol: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> List<'a> {
|
||||||
|
pub fn new<T>(items: T) -> List<'a>
|
||||||
|
where
|
||||||
|
T: Into<Vec<ListItem<'a>>>,
|
||||||
|
{
|
||||||
|
List {
|
||||||
|
block: None,
|
||||||
|
style: Style::default(),
|
||||||
|
items: items.into(),
|
||||||
|
start_corner: Corner::TopLeft,
|
||||||
|
highlight_style: Style::default(),
|
||||||
|
highlight_symbol: None,
|
||||||
|
repeat_highlight_symbol: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn block(mut self, block: Block<'a>) -> List<'a> {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> List<'a> {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> List<'a> {
|
||||||
|
self.highlight_symbol = Some(highlight_symbol);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highlight_style(mut self, style: Style) -> List<'a> {
|
||||||
|
self.highlight_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repeat_highlight_symbol(mut self, repeat: bool) -> List<'a> {
|
||||||
|
self.repeat_highlight_symbol = repeat;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_corner(mut self, corner: Corner) -> List<'a> {
|
||||||
|
self.start_corner = corner;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_items_bounds(
|
||||||
|
&self,
|
||||||
|
selected: Option<usize>,
|
||||||
|
offset: usize,
|
||||||
|
max_height: usize,
|
||||||
|
) -> (usize, usize) {
|
||||||
|
let offset = offset.min(self.items.len().saturating_sub(1));
|
||||||
|
let mut start = offset;
|
||||||
|
let mut end = offset;
|
||||||
|
let mut height = 0;
|
||||||
|
for item in self.items.iter().skip(offset) {
|
||||||
|
if height + item.height() > max_height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
height += item.height();
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected = selected.unwrap_or(0).min(self.items.len() - 1);
|
||||||
|
while selected >= end {
|
||||||
|
height = height.saturating_add(self.items[end].height());
|
||||||
|
end += 1;
|
||||||
|
while height > max_height {
|
||||||
|
height = height.saturating_sub(self.items[start].height());
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while selected < start {
|
||||||
|
start -= 1;
|
||||||
|
height = height.saturating_add(self.items[start].height());
|
||||||
|
while height > max_height {
|
||||||
|
end -= 1;
|
||||||
|
height = height.saturating_sub(self.items[end].height());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StatefulWidget for List<'a> {
|
||||||
|
type State = ListState;
|
||||||
|
|
||||||
|
fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
|
buf.set_style(area, self.style);
|
||||||
|
let list_area = match self.block.take() {
|
||||||
|
Some(b) => {
|
||||||
|
let inner_area = b.inner(area);
|
||||||
|
b.render(area, buf);
|
||||||
|
inner_area
|
||||||
|
}
|
||||||
|
None => area,
|
||||||
|
};
|
||||||
|
|
||||||
|
if list_area.width < 1 || list_area.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.items.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let list_height = list_area.height as usize;
|
||||||
|
|
||||||
|
let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height);
|
||||||
|
state.offset = start;
|
||||||
|
|
||||||
|
let highlight_symbol = self.highlight_symbol.unwrap_or("");
|
||||||
|
let blank_symbol = " ".repeat(highlight_symbol.width());
|
||||||
|
|
||||||
|
let mut current_height = 0;
|
||||||
|
let has_selection = state.selected.is_some();
|
||||||
|
for (i, item) in self
|
||||||
|
.items
|
||||||
|
.iter_mut()
|
||||||
|
.enumerate()
|
||||||
|
.skip(state.offset)
|
||||||
|
.take(end - start)
|
||||||
|
{
|
||||||
|
let (x, y) = match self.start_corner {
|
||||||
|
Corner::BottomLeft => {
|
||||||
|
current_height += item.height() as u16;
|
||||||
|
(list_area.left(), list_area.bottom() - current_height)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let pos = (list_area.left(), list_area.top() + current_height);
|
||||||
|
current_height += item.height() as u16;
|
||||||
|
pos
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let area = Rect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: list_area.width,
|
||||||
|
height: item.height() as u16,
|
||||||
|
};
|
||||||
|
let item_style = self.style.patch(item.style);
|
||||||
|
buf.set_style(area, item_style);
|
||||||
|
|
||||||
|
let is_selected = state.selected.map(|s| s == i).unwrap_or(false);
|
||||||
|
for (j, line) in item.content.lines.iter().enumerate() {
|
||||||
|
// if the item is selected, we need to display the highlight symbol:
|
||||||
|
// - either for the first line of the item only,
|
||||||
|
// - or for each line of the item if the appropriate option is set
|
||||||
|
let symbol = if is_selected && (j == 0 || self.repeat_highlight_symbol) {
|
||||||
|
highlight_symbol
|
||||||
|
} else {
|
||||||
|
&blank_symbol
|
||||||
|
};
|
||||||
|
let (elem_x, max_element_width) = if has_selection {
|
||||||
|
let (elem_x, _) = buf.set_stringn(
|
||||||
|
x,
|
||||||
|
y + j as u16,
|
||||||
|
symbol,
|
||||||
|
list_area.width as usize,
|
||||||
|
item_style,
|
||||||
|
);
|
||||||
|
(elem_x, (list_area.width - (elem_x - x)))
|
||||||
|
} else {
|
||||||
|
(x, list_area.width)
|
||||||
|
};
|
||||||
|
buf.set_spans(elem_x, y + j as u16, line, max_element_width);
|
||||||
|
}
|
||||||
|
if is_selected {
|
||||||
|
buf.set_style(area, self.highlight_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for List<'a> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let mut state = ListState::default();
|
||||||
|
StatefulWidget::render(self, area, buf, &mut state);
|
||||||
|
}
|
||||||
|
}
|
184
src/ratatui/widgets/mod.rs
Normal file
184
src/ratatui/widgets/mod.rs
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
//! `widgets` is a collection of types that implement [`Widget`] or [`StatefulWidget`] or both.
|
||||||
|
//!
|
||||||
|
//! All widgets are implemented using the builder pattern and are consumable objects. They are not
|
||||||
|
//! meant to be stored but used as *commands* to draw common figures in the UI.
|
||||||
|
//!
|
||||||
|
//! The available widgets are:
|
||||||
|
//! - [`Block`]
|
||||||
|
//! - [`Tabs`]
|
||||||
|
//! - [`List`]
|
||||||
|
//! - [`Table`]
|
||||||
|
//! - [`Paragraph`]
|
||||||
|
//! - [`Chart`]
|
||||||
|
//! - [`BarChart`]
|
||||||
|
//! - [`Gauge`]
|
||||||
|
//! - [`Sparkline`]
|
||||||
|
//! - [`Clear`]
|
||||||
|
|
||||||
|
mod barchart;
|
||||||
|
mod block;
|
||||||
|
pub mod canvas;
|
||||||
|
mod chart;
|
||||||
|
mod clear;
|
||||||
|
mod gauge;
|
||||||
|
mod list;
|
||||||
|
mod paragraph;
|
||||||
|
mod reflow;
|
||||||
|
mod sparkline;
|
||||||
|
mod table;
|
||||||
|
mod tabs;
|
||||||
|
|
||||||
|
pub use self::barchart::BarChart;
|
||||||
|
pub use self::block::{Block, BorderType};
|
||||||
|
pub use self::chart::{Axis, Chart, Dataset, GraphType};
|
||||||
|
pub use self::clear::Clear;
|
||||||
|
pub use self::gauge::{Gauge, LineGauge};
|
||||||
|
pub use self::list::{List, ListItem, ListState};
|
||||||
|
pub use self::paragraph::{Paragraph, Wrap};
|
||||||
|
pub use self::sparkline::Sparkline;
|
||||||
|
pub use self::table::{Cell, Row, Table, TableState};
|
||||||
|
pub use self::tabs::Tabs;
|
||||||
|
|
||||||
|
use crate::ratatui::{buffer::Buffer, layout::Rect};
|
||||||
|
use bitflags::bitflags;
|
||||||
|
|
||||||
|
bitflags! {
|
||||||
|
/// Bitflags that can be composed to set the visible borders essentially on the block widget.
|
||||||
|
pub struct Borders: u8 {
|
||||||
|
/// Show no border (default)
|
||||||
|
const NONE = 0b0000;
|
||||||
|
/// Show the top border
|
||||||
|
const TOP = 0b0001;
|
||||||
|
/// Show the right border
|
||||||
|
const RIGHT = 0b0010;
|
||||||
|
/// Show the bottom border
|
||||||
|
const BOTTOM = 0b0100;
|
||||||
|
/// Show the left border
|
||||||
|
const LEFT = 0b1000;
|
||||||
|
/// Show all borders
|
||||||
|
const ALL = Self::TOP.bits | Self::RIGHT.bits | Self::BOTTOM.bits | Self::LEFT.bits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base requirements for a Widget
|
||||||
|
pub trait Widget {
|
||||||
|
/// Draws the current state of the widget in the given buffer. That is the only method required
|
||||||
|
/// to implement a custom widget.
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `StatefulWidget` is a widget that can take advantage of some local state to remember things
|
||||||
|
/// between two draw calls.
|
||||||
|
///
|
||||||
|
/// Most widgets can be drawn directly based on the input parameters. However, some features may
|
||||||
|
/// require some kind of associated state to be implemented.
|
||||||
|
///
|
||||||
|
/// For example, the [`List`] widget can highlight the item currently selected. This can be
|
||||||
|
/// translated in an offset, which is the number of elements to skip in order to have the selected
|
||||||
|
/// item within the viewport currently allocated to this widget. The widget can therefore only
|
||||||
|
/// provide the following behavior: whenever the selected item is out of the viewport scroll to a
|
||||||
|
/// predefined position (making the selected item the last viewable item or the one in the middle
|
||||||
|
/// for example). Nonetheless, if the widget has access to the last computed offset then it can
|
||||||
|
/// implement a natural scrolling experience where the last offset is reused until the selected
|
||||||
|
/// item is out of the viewport.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// # use std::io;
|
||||||
|
/// # use ratatui::Terminal;
|
||||||
|
/// # use ratatui::backend::{Backend, TestBackend};
|
||||||
|
/// # use ratatui::widgets::{Widget, List, ListItem, ListState};
|
||||||
|
///
|
||||||
|
/// // Let's say we have some events to display.
|
||||||
|
/// struct Events {
|
||||||
|
/// // `items` is the state managed by your application.
|
||||||
|
/// items: Vec<String>,
|
||||||
|
/// // `state` is the state that can be modified by the UI. It stores the index of the selected
|
||||||
|
/// // item as well as the offset computed during the previous draw call (used to implement
|
||||||
|
/// // natural scrolling).
|
||||||
|
/// state: ListState
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// impl Events {
|
||||||
|
/// fn new(items: Vec<String>) -> Events {
|
||||||
|
/// Events {
|
||||||
|
/// items,
|
||||||
|
/// state: ListState::default(),
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// pub fn set_items(&mut self, items: Vec<String>) {
|
||||||
|
/// self.items = items;
|
||||||
|
/// // We reset the state as the associated items have changed. This effectively reset
|
||||||
|
/// // the selection as well as the stored offset.
|
||||||
|
/// self.state = ListState::default();
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // Select the next item. This will not be reflected until the widget is drawn in the
|
||||||
|
/// // `Terminal::draw` callback using `Frame::render_stateful_widget`.
|
||||||
|
/// pub fn next(&mut self) {
|
||||||
|
/// let i = match self.state.selected() {
|
||||||
|
/// Some(i) => {
|
||||||
|
/// if i >= self.items.len() - 1 {
|
||||||
|
/// 0
|
||||||
|
/// } else {
|
||||||
|
/// i + 1
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// None => 0,
|
||||||
|
/// };
|
||||||
|
/// self.state.select(Some(i));
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // Select the previous item. This will not be reflected until the widget is drawn in the
|
||||||
|
/// // `Terminal::draw` callback using `Frame::render_stateful_widget`.
|
||||||
|
/// pub fn previous(&mut self) {
|
||||||
|
/// let i = match self.state.selected() {
|
||||||
|
/// Some(i) => {
|
||||||
|
/// if i == 0 {
|
||||||
|
/// self.items.len() - 1
|
||||||
|
/// } else {
|
||||||
|
/// i - 1
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// None => 0,
|
||||||
|
/// };
|
||||||
|
/// self.state.select(Some(i));
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // Unselect the currently selected item if any. The implementation of `ListState` makes
|
||||||
|
/// // sure that the stored offset is also reset.
|
||||||
|
/// pub fn unselect(&mut self) {
|
||||||
|
/// self.state.select(None);
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// # let backend = TestBackend::new(5, 5);
|
||||||
|
/// # let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
///
|
||||||
|
/// let mut events = Events::new(vec![
|
||||||
|
/// String::from("Item 1"),
|
||||||
|
/// String::from("Item 2")
|
||||||
|
/// ]);
|
||||||
|
///
|
||||||
|
/// loop {
|
||||||
|
/// terminal.draw(|f| {
|
||||||
|
/// // The items managed by the application are transformed to something
|
||||||
|
/// // that is understood by ratatui.
|
||||||
|
/// let items: Vec<ListItem>= events.items.iter().map(|i| ListItem::new(i.as_ref())).collect();
|
||||||
|
/// // The `List` widget is then built with those items.
|
||||||
|
/// let list = List::new(items);
|
||||||
|
/// // Finally the widget is rendered using the associated state. `events.state` is
|
||||||
|
/// // effectively the only thing that we will "remember" from this draw call.
|
||||||
|
/// f.render_stateful_widget(list, f.size(), &mut events.state);
|
||||||
|
/// });
|
||||||
|
///
|
||||||
|
/// // In response to some input events or an external http request or whatever:
|
||||||
|
/// events.next();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub trait StatefulWidget {
|
||||||
|
type State;
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State);
|
||||||
|
}
|
214
src/ratatui/widgets/paragraph.rs
Normal file
214
src/ratatui/widgets/paragraph.rs
Normal file
|
@ -0,0 +1,214 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::Style,
|
||||||
|
text::{StyledGrapheme, Text},
|
||||||
|
widgets::{
|
||||||
|
reflow::{LineComposer, LineTruncator, WordWrapper},
|
||||||
|
Block, Widget,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::iter;
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 {
|
||||||
|
match alignment {
|
||||||
|
Alignment::Center => (text_area_width / 2).saturating_sub(line_width / 2),
|
||||||
|
Alignment::Right => text_area_width.saturating_sub(line_width),
|
||||||
|
Alignment::Left => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A widget to display some text.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::text::{Text, Spans, Span};
|
||||||
|
/// # use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||||
|
/// # use ratatui::style::{Style, Color, Modifier};
|
||||||
|
/// # use ratatui::layout::{Alignment};
|
||||||
|
/// let text = vec![
|
||||||
|
/// Spans::from(vec![
|
||||||
|
/// Span::raw("First"),
|
||||||
|
/// Span::styled("line",Style::default().add_modifier(Modifier::ITALIC)),
|
||||||
|
/// Span::raw("."),
|
||||||
|
/// ]),
|
||||||
|
/// Spans::from(Span::styled("Second line", Style::default().fg(Color::Red))),
|
||||||
|
/// ];
|
||||||
|
/// Paragraph::new(text)
|
||||||
|
/// .block(Block::default().title("Paragraph").borders(Borders::ALL))
|
||||||
|
/// .style(Style::default().fg(Color::White).bg(Color::Black))
|
||||||
|
/// .alignment(Alignment::Center)
|
||||||
|
/// .wrap(Wrap { trim: true });
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Paragraph<'a> {
|
||||||
|
/// A block to wrap the widget in
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
/// Widget style
|
||||||
|
style: Style,
|
||||||
|
/// How to wrap the text
|
||||||
|
wrap: Option<Wrap>,
|
||||||
|
/// The text to display
|
||||||
|
text: Text<'a>,
|
||||||
|
/// Scroll
|
||||||
|
scroll: (u16, u16),
|
||||||
|
/// Alignment of the text
|
||||||
|
alignment: Alignment,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describes how to wrap text across lines.
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::{Paragraph, Wrap};
|
||||||
|
/// # use ratatui::text::Text;
|
||||||
|
/// let bullet_points = Text::from(r#"Some indented points:
|
||||||
|
/// - First thing goes here and is long so that it wraps
|
||||||
|
/// - Here is another point that is long enough to wrap"#);
|
||||||
|
///
|
||||||
|
/// // With leading spaces trimmed (window width of 30 chars):
|
||||||
|
/// Paragraph::new(bullet_points.clone()).wrap(Wrap { trim: true });
|
||||||
|
/// // Some indented points:
|
||||||
|
/// // - First thing goes here and is
|
||||||
|
/// // long so that it wraps
|
||||||
|
/// // - Here is another point that
|
||||||
|
/// // is long enough to wrap
|
||||||
|
///
|
||||||
|
/// // But without trimming, indentation is preserved:
|
||||||
|
/// Paragraph::new(bullet_points).wrap(Wrap { trim: false });
|
||||||
|
/// // Some indented points:
|
||||||
|
/// // - First thing goes here
|
||||||
|
/// // and is long so that it wraps
|
||||||
|
/// // - Here is another point
|
||||||
|
/// // that is long enough to wrap
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Wrap {
|
||||||
|
/// Should leading whitespace be trimmed
|
||||||
|
pub trim: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Paragraph<'a> {
|
||||||
|
pub fn new<T>(text: T) -> Paragraph<'a>
|
||||||
|
where
|
||||||
|
T: Into<Text<'a>>,
|
||||||
|
{
|
||||||
|
Paragraph {
|
||||||
|
block: None,
|
||||||
|
style: Default::default(),
|
||||||
|
wrap: None,
|
||||||
|
text: text.into(),
|
||||||
|
scroll: (0, 0),
|
||||||
|
alignment: Alignment::Left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn block(mut self, block: Block<'a>) -> Paragraph<'a> {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> Paragraph<'a> {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wrap(mut self, wrap: Wrap) -> Paragraph<'a> {
|
||||||
|
self.wrap = Some(wrap);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll(mut self, offset: (u16, u16)) -> Paragraph<'a> {
|
||||||
|
self.scroll = offset;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn alignment(mut self, alignment: Alignment) -> Paragraph<'a> {
|
||||||
|
self.alignment = alignment;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Paragraph<'a> {
|
||||||
|
fn render(mut self, area: Rect, buf: &mut Buffer) {
|
||||||
|
buf.set_style(area, self.style);
|
||||||
|
let text_area = match self.block.take() {
|
||||||
|
Some(b) => {
|
||||||
|
let inner_area = b.inner(area);
|
||||||
|
b.render(area, buf);
|
||||||
|
inner_area
|
||||||
|
}
|
||||||
|
None => area,
|
||||||
|
};
|
||||||
|
|
||||||
|
if text_area.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let style = self.style;
|
||||||
|
let mut styled = self.text.lines.iter().flat_map(|spans| {
|
||||||
|
spans
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.flat_map(|span| span.styled_graphemes(style))
|
||||||
|
// Required given the way composers work but might be refactored out if we change
|
||||||
|
// composers to operate on lines instead of a stream of graphemes.
|
||||||
|
.chain(iter::once(StyledGrapheme {
|
||||||
|
symbol: "\n",
|
||||||
|
style: self.style,
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut line_composer: Box<dyn LineComposer> = if let Some(Wrap { trim }) = self.wrap {
|
||||||
|
Box::new(WordWrapper::new(&mut styled, text_area.width, trim))
|
||||||
|
} else {
|
||||||
|
let mut line_composer = Box::new(LineTruncator::new(&mut styled, text_area.width));
|
||||||
|
if let Alignment::Left = self.alignment {
|
||||||
|
line_composer.set_horizontal_offset(self.scroll.1);
|
||||||
|
}
|
||||||
|
line_composer
|
||||||
|
};
|
||||||
|
let mut y = 0;
|
||||||
|
while let Some((current_line, current_line_width)) = line_composer.next_line() {
|
||||||
|
if y >= self.scroll.0 {
|
||||||
|
let mut x = get_line_offset(current_line_width, text_area.width, self.alignment);
|
||||||
|
for StyledGrapheme { symbol, style } in current_line {
|
||||||
|
let width = symbol.width();
|
||||||
|
if width == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
buf.get_mut(text_area.left() + x, text_area.top() + y - self.scroll.0)
|
||||||
|
.set_symbol(if symbol.is_empty() {
|
||||||
|
// If the symbol is empty, the last char which rendered last time will
|
||||||
|
// leave on the line. It's a quick fix.
|
||||||
|
" "
|
||||||
|
} else {
|
||||||
|
symbol
|
||||||
|
})
|
||||||
|
.set_style(*style);
|
||||||
|
x += width as u16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y += 1;
|
||||||
|
if y >= text_area.height + self.scroll.0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_width_char_at_end_of_line() {
|
||||||
|
let line = "foo\0";
|
||||||
|
let paragraph = Paragraph::new(line);
|
||||||
|
let mut buf = Buffer::with_lines(vec![line]);
|
||||||
|
paragraph.render(*buf.area(), &mut buf);
|
||||||
|
}
|
||||||
|
}
|
534
src/ratatui/widgets/reflow.rs
Normal file
534
src/ratatui/widgets/reflow.rs
Normal file
|
@ -0,0 +1,534 @@
|
||||||
|
use crate::ratatui::text::StyledGrapheme;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
const NBSP: &str = "\u{00a0}";
|
||||||
|
|
||||||
|
/// A state machine to pack styled symbols into lines.
|
||||||
|
/// Cannot implement it as Iterator since it yields slices of the internal buffer (need streaming
|
||||||
|
/// iterators for that).
|
||||||
|
pub trait LineComposer<'a> {
|
||||||
|
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A state machine that wraps lines on word boundaries.
|
||||||
|
pub struct WordWrapper<'a, 'b> {
|
||||||
|
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
|
||||||
|
max_line_width: u16,
|
||||||
|
current_line: Vec<StyledGrapheme<'a>>,
|
||||||
|
next_line: Vec<StyledGrapheme<'a>>,
|
||||||
|
/// Removes the leading whitespace from lines
|
||||||
|
trim: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b> WordWrapper<'a, 'b> {
|
||||||
|
pub fn new(
|
||||||
|
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
|
||||||
|
max_line_width: u16,
|
||||||
|
trim: bool,
|
||||||
|
) -> WordWrapper<'a, 'b> {
|
||||||
|
WordWrapper {
|
||||||
|
symbols,
|
||||||
|
max_line_width,
|
||||||
|
current_line: vec![],
|
||||||
|
next_line: vec![],
|
||||||
|
trim,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> {
|
||||||
|
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> {
|
||||||
|
if self.max_line_width == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut self.current_line, &mut self.next_line);
|
||||||
|
self.next_line.truncate(0);
|
||||||
|
|
||||||
|
let mut current_line_width = self
|
||||||
|
.current_line
|
||||||
|
.iter()
|
||||||
|
.map(|StyledGrapheme { symbol, .. }| symbol.width() as u16)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
let mut symbols_to_last_word_end: usize = 0;
|
||||||
|
let mut width_to_last_word_end: u16 = 0;
|
||||||
|
let mut prev_whitespace = false;
|
||||||
|
let mut symbols_exhausted = true;
|
||||||
|
for StyledGrapheme { symbol, style } in &mut self.symbols {
|
||||||
|
symbols_exhausted = false;
|
||||||
|
let symbol_whitespace = symbol.chars().all(&char::is_whitespace) && symbol != NBSP;
|
||||||
|
|
||||||
|
// Ignore characters wider that the total max width.
|
||||||
|
if symbol.width() as u16 > self.max_line_width
|
||||||
|
// Skip leading whitespace when trim is enabled.
|
||||||
|
|| self.trim && symbol_whitespace && symbol != "\n" && current_line_width == 0
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Break on newline and discard it.
|
||||||
|
if symbol == "\n" {
|
||||||
|
if prev_whitespace {
|
||||||
|
current_line_width = width_to_last_word_end;
|
||||||
|
self.current_line.truncate(symbols_to_last_word_end);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the previous symbol as word end.
|
||||||
|
if symbol_whitespace && !prev_whitespace {
|
||||||
|
symbols_to_last_word_end = self.current_line.len();
|
||||||
|
width_to_last_word_end = current_line_width;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_line.push(StyledGrapheme { symbol, style });
|
||||||
|
current_line_width += symbol.width() as u16;
|
||||||
|
|
||||||
|
if current_line_width > self.max_line_width {
|
||||||
|
// If there was no word break in the text, wrap at the end of the line.
|
||||||
|
let (truncate_at, truncated_width) = if symbols_to_last_word_end != 0 {
|
||||||
|
(symbols_to_last_word_end, width_to_last_word_end)
|
||||||
|
} else {
|
||||||
|
(self.current_line.len() - 1, self.max_line_width)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Push the remainder to the next line but strip leading whitespace:
|
||||||
|
{
|
||||||
|
let remainder = &self.current_line[truncate_at..];
|
||||||
|
if let Some(remainder_nonwhite) =
|
||||||
|
remainder.iter().position(|StyledGrapheme { symbol, .. }| {
|
||||||
|
!symbol.chars().all(&char::is_whitespace)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
self.next_line
|
||||||
|
.extend_from_slice(&remainder[remainder_nonwhite..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.current_line.truncate(truncate_at);
|
||||||
|
current_line_width = truncated_width;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
prev_whitespace = symbol_whitespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even if the iterator is exhausted, pass the previous remainder.
|
||||||
|
if symbols_exhausted && self.current_line.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((&self.current_line[..], current_line_width))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A state machine that truncates overhanging lines.
|
||||||
|
pub struct LineTruncator<'a, 'b> {
|
||||||
|
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
|
||||||
|
max_line_width: u16,
|
||||||
|
current_line: Vec<StyledGrapheme<'a>>,
|
||||||
|
/// Record the offset to skip render
|
||||||
|
horizontal_offset: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b> LineTruncator<'a, 'b> {
|
||||||
|
pub fn new(
|
||||||
|
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
|
||||||
|
max_line_width: u16,
|
||||||
|
) -> LineTruncator<'a, 'b> {
|
||||||
|
LineTruncator {
|
||||||
|
symbols,
|
||||||
|
max_line_width,
|
||||||
|
horizontal_offset: 0,
|
||||||
|
current_line: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_horizontal_offset(&mut self, horizontal_offset: u16) {
|
||||||
|
self.horizontal_offset = horizontal_offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b> LineComposer<'a> for LineTruncator<'a, 'b> {
|
||||||
|
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> {
|
||||||
|
if self.max_line_width == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_line.truncate(0);
|
||||||
|
let mut current_line_width = 0;
|
||||||
|
|
||||||
|
let mut skip_rest = false;
|
||||||
|
let mut symbols_exhausted = true;
|
||||||
|
let mut horizontal_offset = self.horizontal_offset as usize;
|
||||||
|
for StyledGrapheme { symbol, style } in &mut self.symbols {
|
||||||
|
symbols_exhausted = false;
|
||||||
|
|
||||||
|
// Ignore characters wider that the total max width.
|
||||||
|
if symbol.width() as u16 > self.max_line_width {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Break on newline and discard it.
|
||||||
|
if symbol == "\n" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if current_line_width + symbol.width() as u16 > self.max_line_width {
|
||||||
|
// Exhaust the remainder of the line.
|
||||||
|
skip_rest = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let symbol = if horizontal_offset == 0 {
|
||||||
|
symbol
|
||||||
|
} else {
|
||||||
|
let w = symbol.width();
|
||||||
|
if w > horizontal_offset {
|
||||||
|
let t = trim_offset(symbol, horizontal_offset);
|
||||||
|
horizontal_offset = 0;
|
||||||
|
t
|
||||||
|
} else {
|
||||||
|
horizontal_offset -= w;
|
||||||
|
""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
current_line_width += symbol.width() as u16;
|
||||||
|
self.current_line.push(StyledGrapheme { symbol, style });
|
||||||
|
}
|
||||||
|
|
||||||
|
if skip_rest {
|
||||||
|
for StyledGrapheme { symbol, .. } in &mut self.symbols {
|
||||||
|
if symbol == "\n" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if symbols_exhausted && self.current_line.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((&self.current_line[..], current_line_width))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This function will return a str slice which start at specified offset.
|
||||||
|
/// As src is a unicode str, start offset has to be calculated with each character.
|
||||||
|
fn trim_offset(src: &str, mut offset: usize) -> &str {
|
||||||
|
let mut start = 0;
|
||||||
|
for c in UnicodeSegmentation::graphemes(src, true) {
|
||||||
|
let w = c.width();
|
||||||
|
if w <= offset {
|
||||||
|
offset -= w;
|
||||||
|
start += c.len();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&src[start..]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
enum Composer {
|
||||||
|
WordWrapper { trim: bool },
|
||||||
|
LineTruncator,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_composer(which: Composer, text: &str, text_area_width: u16) -> (Vec<String>, Vec<u16>) {
|
||||||
|
let style = Default::default();
|
||||||
|
let mut styled =
|
||||||
|
UnicodeSegmentation::graphemes(text, true).map(|g| StyledGrapheme { symbol: g, style });
|
||||||
|
let mut composer: Box<dyn LineComposer> = match which {
|
||||||
|
Composer::WordWrapper { trim } => {
|
||||||
|
Box::new(WordWrapper::new(&mut styled, text_area_width, trim))
|
||||||
|
}
|
||||||
|
Composer::LineTruncator => Box::new(LineTruncator::new(&mut styled, text_area_width)),
|
||||||
|
};
|
||||||
|
let mut lines = vec![];
|
||||||
|
let mut widths = vec![];
|
||||||
|
while let Some((styled, width)) = composer.next_line() {
|
||||||
|
let line = styled
|
||||||
|
.iter()
|
||||||
|
.map(|StyledGrapheme { symbol, .. }| *symbol)
|
||||||
|
.collect::<String>();
|
||||||
|
assert!(width <= text_area_width);
|
||||||
|
lines.push(line);
|
||||||
|
widths.push(width);
|
||||||
|
}
|
||||||
|
(lines, widths)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_one_line() {
|
||||||
|
let width = 40;
|
||||||
|
for i in 1..width {
|
||||||
|
let text = "a".repeat(i);
|
||||||
|
let (word_wrapper, _) =
|
||||||
|
run_composer(Composer::WordWrapper { trim: true }, &text, width as u16);
|
||||||
|
let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width as u16);
|
||||||
|
let expected = vec![text];
|
||||||
|
assert_eq!(word_wrapper, expected);
|
||||||
|
assert_eq!(line_truncator, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_short_lines() {
|
||||||
|
let width = 20;
|
||||||
|
let text =
|
||||||
|
"abcdefg\nhijklmno\npabcdefg\nhijklmn\nopabcdefghijk\nlmnopabcd\n\n\nefghijklmno";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||||
|
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||||
|
|
||||||
|
let wrapped: Vec<&str> = text.split('\n').collect();
|
||||||
|
assert_eq!(word_wrapper, wrapped);
|
||||||
|
assert_eq!(line_truncator, wrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_long_word() {
|
||||||
|
let width = 20;
|
||||||
|
let text = "abcdefghijklmnopabcdefghijklmnopabcdefghijklmnopabcdefghijklmno";
|
||||||
|
let (word_wrapper, _) =
|
||||||
|
run_composer(Composer::WordWrapper { trim: true }, text, width as u16);
|
||||||
|
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16);
|
||||||
|
|
||||||
|
let wrapped = vec![
|
||||||
|
&text[..width],
|
||||||
|
&text[width..width * 2],
|
||||||
|
&text[width * 2..width * 3],
|
||||||
|
&text[width * 3..],
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
word_wrapper, wrapped,
|
||||||
|
"WordWrapper should detect the line cannot be broken on word boundary and \
|
||||||
|
break it at line width limit."
|
||||||
|
);
|
||||||
|
assert_eq!(line_truncator, vec![&text[..width]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_long_sentence() {
|
||||||
|
let width = 20;
|
||||||
|
let text =
|
||||||
|
"abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l m n o";
|
||||||
|
let text_multi_space =
|
||||||
|
"abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l \
|
||||||
|
m n o";
|
||||||
|
let (word_wrapper_single_space, _) =
|
||||||
|
run_composer(Composer::WordWrapper { trim: true }, text, width as u16);
|
||||||
|
let (word_wrapper_multi_space, _) = run_composer(
|
||||||
|
Composer::WordWrapper { trim: true },
|
||||||
|
text_multi_space,
|
||||||
|
width as u16,
|
||||||
|
);
|
||||||
|
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16);
|
||||||
|
|
||||||
|
let word_wrapped = vec![
|
||||||
|
"abcd efghij",
|
||||||
|
"klmnopabcd efgh",
|
||||||
|
"ijklmnopabcdefg",
|
||||||
|
"hijkl mnopab c d e f",
|
||||||
|
"g h i j k l m n o",
|
||||||
|
];
|
||||||
|
assert_eq!(word_wrapper_single_space, word_wrapped);
|
||||||
|
assert_eq!(word_wrapper_multi_space, word_wrapped);
|
||||||
|
|
||||||
|
assert_eq!(line_truncator, vec![&text[..width]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_zero_width() {
|
||||||
|
let width = 0;
|
||||||
|
let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab ";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||||
|
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||||
|
|
||||||
|
let expected: Vec<&str> = Vec::new();
|
||||||
|
assert_eq!(word_wrapper, expected);
|
||||||
|
assert_eq!(line_truncator, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_max_line_width_of_1() {
|
||||||
|
let width = 1;
|
||||||
|
let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab ";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||||
|
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||||
|
|
||||||
|
let expected: Vec<&str> = UnicodeSegmentation::graphemes(text, true)
|
||||||
|
.filter(|g| g.chars().any(|c| !c.is_whitespace()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(word_wrapper, expected);
|
||||||
|
assert_eq!(line_truncator, vec!["a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_max_line_width_of_1_double_width_characters() {
|
||||||
|
let width = 1;
|
||||||
|
let text = "コンピュータ上で文字を扱う場合、典型的には文字\naaaによる通信を行う場合にその\
|
||||||
|
両端点では、";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||||
|
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||||
|
assert_eq!(word_wrapper, vec!["", "a", "a", "a"]);
|
||||||
|
assert_eq!(line_truncator, vec!["", "a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tests WordWrapper with words some of which exceed line length and some not.
|
||||||
|
#[test]
|
||||||
|
fn line_composer_word_wrapper_mixed_length() {
|
||||||
|
let width = 20;
|
||||||
|
let text = "abcd efghij klmnopabcdefghijklmnopabcdefghijkl mnopab cdefghi j klmno";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||||
|
assert_eq!(
|
||||||
|
word_wrapper,
|
||||||
|
vec![
|
||||||
|
"abcd efghij",
|
||||||
|
"klmnopabcdefghijklmn",
|
||||||
|
"opabcdefghijkl",
|
||||||
|
"mnopab cdefghi j",
|
||||||
|
"klmno",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_double_width_chars() {
|
||||||
|
let width = 20;
|
||||||
|
let text = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点\
|
||||||
|
では、";
|
||||||
|
let (word_wrapper, word_wrapper_width) =
|
||||||
|
run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||||
|
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||||
|
assert_eq!(line_truncator, vec!["コンピュータ上で文字"]);
|
||||||
|
let wrapped = vec![
|
||||||
|
"コンピュータ上で文字",
|
||||||
|
"を扱う場合、典型的に",
|
||||||
|
"は文字による通信を行",
|
||||||
|
"う場合にその両端点で",
|
||||||
|
"は、",
|
||||||
|
];
|
||||||
|
assert_eq!(word_wrapper, wrapped);
|
||||||
|
assert_eq!(word_wrapper_width, vec![width, width, width, width, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_leading_whitespace_removal() {
|
||||||
|
let width = 20;
|
||||||
|
let text = "AAAAAAAAAAAAAAAAAAAA AAA";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||||
|
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||||
|
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", "AAA",]);
|
||||||
|
assert_eq!(line_truncator, vec!["AAAAAAAAAAAAAAAAAAAA"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tests truncation of leading whitespace.
|
||||||
|
#[test]
|
||||||
|
fn line_composer_lots_of_spaces() {
|
||||||
|
let width = 20;
|
||||||
|
let text = " ";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||||
|
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||||
|
assert_eq!(word_wrapper, vec![""]);
|
||||||
|
assert_eq!(line_truncator, vec![" "]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tests an input starting with a letter, followed by spaces - some of the behaviour is
|
||||||
|
/// incidental.
|
||||||
|
#[test]
|
||||||
|
fn line_composer_char_plus_lots_of_spaces() {
|
||||||
|
let width = 20;
|
||||||
|
let text = "a ";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||||
|
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
|
||||||
|
// What's happening below is: the first line gets consumed, trailing spaces discarded,
|
||||||
|
// after 20 of which a word break occurs (probably shouldn't). The second line break
|
||||||
|
// discards all whitespace. The result should probably be vec!["a"] but it doesn't matter
|
||||||
|
// that much.
|
||||||
|
assert_eq!(word_wrapper, vec!["a", ""]);
|
||||||
|
assert_eq!(line_truncator, vec!["a "]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_word_wrapper_double_width_chars_mixed_with_spaces() {
|
||||||
|
let width = 20;
|
||||||
|
// Japanese seems not to use spaces but we should break on spaces anyway... We're using it
|
||||||
|
// to test double-width chars.
|
||||||
|
// You are more than welcome to add word boundary detection based of alterations of
|
||||||
|
// hiragana and katakana...
|
||||||
|
// This happens to also be a test case for mixed width because regular spaces are single width.
|
||||||
|
let text = "コンピュ ータ上で文字を扱う場合、 典型的には文 字による 通信を行 う場合にその両端点では、";
|
||||||
|
let (word_wrapper, word_wrapper_width) =
|
||||||
|
run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||||
|
assert_eq!(
|
||||||
|
word_wrapper,
|
||||||
|
vec![
|
||||||
|
"コンピュ",
|
||||||
|
"ータ上で文字を扱う場",
|
||||||
|
"合、 典型的には文",
|
||||||
|
"字による 通信を行",
|
||||||
|
"う場合にその両端点で",
|
||||||
|
"は、",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Odd-sized lines have a space in them.
|
||||||
|
assert_eq!(word_wrapper_width, vec![8, 20, 17, 17, 20, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure words separated by nbsp are wrapped as if they were a single one.
|
||||||
|
#[test]
|
||||||
|
fn line_composer_word_wrapper_nbsp() {
|
||||||
|
let width = 20;
|
||||||
|
let text = "AAAAAAAAAAAAAAA AAAA\u{00a0}AAA";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
|
||||||
|
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAA", "AAAA\u{00a0}AAA",]);
|
||||||
|
|
||||||
|
// Ensure that if the character was a regular space, it would be wrapped differently.
|
||||||
|
let text_space = text.replace('\u{00a0}', " ");
|
||||||
|
let (word_wrapper_space, _) =
|
||||||
|
run_composer(Composer::WordWrapper { trim: true }, &text_space, width);
|
||||||
|
assert_eq!(word_wrapper_space, vec!["AAAAAAAAAAAAAAA AAAA", "AAA",]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_word_wrapper_preserve_indentation() {
|
||||||
|
let width = 20;
|
||||||
|
let text = "AAAAAAAAAAAAAAAAAAAA AAA";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
|
||||||
|
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", " AAA",]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_word_wrapper_preserve_indentation_with_wrap() {
|
||||||
|
let width = 10;
|
||||||
|
let text = "AAA AAA AAAAA AA AAAAAA\n B\n C\n D";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
|
||||||
|
assert_eq!(
|
||||||
|
word_wrapper,
|
||||||
|
vec!["AAA AAA", "AAAAA AA", "AAAAAA", " B", " C", " D"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_composer_word_wrapper_preserve_indentation_lots_of_whitespace() {
|
||||||
|
let width = 10;
|
||||||
|
let text = " 4 Indent\n must wrap!";
|
||||||
|
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
|
||||||
|
assert_eq!(
|
||||||
|
word_wrapper,
|
||||||
|
vec![
|
||||||
|
" ",
|
||||||
|
" 4",
|
||||||
|
"Indent",
|
||||||
|
" ",
|
||||||
|
" must",
|
||||||
|
"wrap!"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
155
src/ratatui/widgets/sparkline.rs
Normal file
155
src/ratatui/widgets/sparkline.rs
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::Rect,
|
||||||
|
style::Style,
|
||||||
|
symbols,
|
||||||
|
widgets::{Block, Widget},
|
||||||
|
};
|
||||||
|
use std::cmp::min;
|
||||||
|
|
||||||
|
/// Widget to render a sparkline over one or more lines.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::{Block, Borders, Sparkline};
|
||||||
|
/// # use ratatui::style::{Style, Color};
|
||||||
|
/// Sparkline::default()
|
||||||
|
/// .block(Block::default().title("Sparkline").borders(Borders::ALL))
|
||||||
|
/// .data(&[0, 2, 3, 4, 1, 4, 10])
|
||||||
|
/// .max(5)
|
||||||
|
/// .style(Style::default().fg(Color::Red).bg(Color::White));
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Sparkline<'a> {
|
||||||
|
/// A block to wrap the widget in
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
/// Widget style
|
||||||
|
style: Style,
|
||||||
|
/// A slice of the data to display
|
||||||
|
data: &'a [u64],
|
||||||
|
/// The maximum value to take to compute the maximum bar height (if nothing is specified, the
|
||||||
|
/// widget uses the max of the dataset)
|
||||||
|
max: Option<u64>,
|
||||||
|
/// A set of bar symbols used to represent the give data
|
||||||
|
bar_set: symbols::bar::Set,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Default for Sparkline<'a> {
|
||||||
|
fn default() -> Sparkline<'a> {
|
||||||
|
Sparkline {
|
||||||
|
block: None,
|
||||||
|
style: Default::default(),
|
||||||
|
data: &[],
|
||||||
|
max: None,
|
||||||
|
bar_set: symbols::bar::NINE_LEVELS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Sparkline<'a> {
|
||||||
|
pub fn block(mut self, block: Block<'a>) -> Sparkline<'a> {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> Sparkline<'a> {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data(mut self, data: &'a [u64]) -> Sparkline<'a> {
|
||||||
|
self.data = data;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max(mut self, max: u64) -> Sparkline<'a> {
|
||||||
|
self.max = Some(max);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bar_set(mut self, bar_set: symbols::bar::Set) -> Sparkline<'a> {
|
||||||
|
self.bar_set = bar_set;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Sparkline<'a> {
|
||||||
|
fn render(mut self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let spark_area = match self.block.take() {
|
||||||
|
Some(b) => {
|
||||||
|
let inner_area = b.inner(area);
|
||||||
|
b.render(area, buf);
|
||||||
|
inner_area
|
||||||
|
}
|
||||||
|
None => area,
|
||||||
|
};
|
||||||
|
|
||||||
|
if spark_area.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max = match self.max {
|
||||||
|
Some(v) => v,
|
||||||
|
None => *self.data.iter().max().unwrap_or(&1u64),
|
||||||
|
};
|
||||||
|
let max_index = min(spark_area.width as usize, self.data.len());
|
||||||
|
let mut data = self
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.take(max_index)
|
||||||
|
.map(|e| {
|
||||||
|
if max != 0 {
|
||||||
|
e * u64::from(spark_area.height) * 8 / max
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<u64>>();
|
||||||
|
for j in (0..spark_area.height).rev() {
|
||||||
|
for (i, d) in data.iter_mut().enumerate() {
|
||||||
|
let symbol = match *d {
|
||||||
|
0 => self.bar_set.empty,
|
||||||
|
1 => self.bar_set.one_eighth,
|
||||||
|
2 => self.bar_set.one_quarter,
|
||||||
|
3 => self.bar_set.three_eighths,
|
||||||
|
4 => self.bar_set.half,
|
||||||
|
5 => self.bar_set.five_eighths,
|
||||||
|
6 => self.bar_set.three_quarters,
|
||||||
|
7 => self.bar_set.seven_eighths,
|
||||||
|
_ => self.bar_set.full,
|
||||||
|
};
|
||||||
|
buf.get_mut(spark_area.left() + i as u16, spark_area.top() + j)
|
||||||
|
.set_symbol(symbol)
|
||||||
|
.set_style(self.style);
|
||||||
|
|
||||||
|
if *d > 8 {
|
||||||
|
*d -= 8;
|
||||||
|
} else {
|
||||||
|
*d = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_does_not_panic_if_max_is_zero() {
|
||||||
|
let widget = Sparkline::default().data(&[0, 0, 0]);
|
||||||
|
let area = Rect::new(0, 0, 3, 1);
|
||||||
|
let mut buffer = Buffer::empty(area);
|
||||||
|
widget.render(area, &mut buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_does_not_panic_if_max_is_set_to_zero() {
|
||||||
|
let widget = Sparkline::default().data(&[0, 1, 2]).max(0);
|
||||||
|
let area = Rect::new(0, 0, 3, 1);
|
||||||
|
let mut buffer = Buffer::empty(area);
|
||||||
|
widget.render(area, &mut buffer);
|
||||||
|
}
|
||||||
|
}
|
504
src/ratatui/widgets/table.rs
Normal file
504
src/ratatui/widgets/table.rs
Normal file
|
@ -0,0 +1,504 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::Style,
|
||||||
|
text::Text,
|
||||||
|
widgets::{Block, StatefulWidget, Widget},
|
||||||
|
};
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
/// A [`Cell`] contains the [`Text`] to be displayed in a [`Row`] of a [`Table`].
|
||||||
|
///
|
||||||
|
/// It can be created from anything that can be converted to a [`Text`].
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::widgets::Cell;
|
||||||
|
/// # use ratatui::style::{Style, Modifier};
|
||||||
|
/// # use ratatui::text::{Span, Spans, Text};
|
||||||
|
/// # use std::borrow::Cow;
|
||||||
|
/// Cell::from("simple string");
|
||||||
|
///
|
||||||
|
/// Cell::from(Span::from("span"));
|
||||||
|
///
|
||||||
|
/// Cell::from(Spans::from(vec![
|
||||||
|
/// Span::raw("a vec of "),
|
||||||
|
/// Span::styled("spans", Style::default().add_modifier(Modifier::BOLD))
|
||||||
|
/// ]));
|
||||||
|
///
|
||||||
|
/// Cell::from(Text::from("a text"));
|
||||||
|
///
|
||||||
|
/// Cell::from(Text::from(Cow::Borrowed("hello")));
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// You can apply a [`Style`] on the entire [`Cell`] using [`Cell::style`] or rely on the styling
|
||||||
|
/// capabilities of [`Text`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct Cell<'a> {
|
||||||
|
content: Text<'a>,
|
||||||
|
style: Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Cell<'a> {
|
||||||
|
/// Set the `Style` of this cell.
|
||||||
|
pub fn style(mut self, style: Style) -> Self {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, T> From<T> for Cell<'a>
|
||||||
|
where
|
||||||
|
T: Into<Text<'a>>,
|
||||||
|
{
|
||||||
|
fn from(content: T) -> Cell<'a> {
|
||||||
|
Cell {
|
||||||
|
content: content.into(),
|
||||||
|
style: Style::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds data to be displayed in a [`Table`] widget.
|
||||||
|
///
|
||||||
|
/// A [`Row`] is a collection of cells. It can be created from simple strings:
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::widgets::Row;
|
||||||
|
/// Row::new(vec!["Cell1", "Cell2", "Cell3"]);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// But if you need a bit more control over individual cells, you can explicitly create [`Cell`]s:
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::widgets::{Row, Cell};
|
||||||
|
/// # use ratatui::style::{Style, Color};
|
||||||
|
/// Row::new(vec![
|
||||||
|
/// Cell::from("Cell1"),
|
||||||
|
/// Cell::from("Cell2").style(Style::default().fg(Color::Yellow)),
|
||||||
|
/// ]);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// You can also construct a row from any type that can be converted into [`Text`]:
|
||||||
|
/// ```rust
|
||||||
|
/// # use std::borrow::Cow;
|
||||||
|
/// # use ratatui::widgets::Row;
|
||||||
|
/// Row::new(vec![
|
||||||
|
/// Cow::Borrowed("hello"),
|
||||||
|
/// Cow::Owned("world".to_uppercase()),
|
||||||
|
/// ]);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// By default, a row has a height of 1 but you can change this using [`Row::height`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct Row<'a> {
|
||||||
|
cells: Vec<Cell<'a>>,
|
||||||
|
height: u16,
|
||||||
|
style: Style,
|
||||||
|
bottom_margin: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Row<'a> {
|
||||||
|
/// Creates a new [`Row`] from an iterator where items can be converted to a [`Cell`].
|
||||||
|
pub fn new<T>(cells: T) -> Self
|
||||||
|
where
|
||||||
|
T: IntoIterator,
|
||||||
|
T::Item: Into<Cell<'a>>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
height: 1,
|
||||||
|
cells: cells.into_iter().map(|c| c.into()).collect(),
|
||||||
|
style: Style::default(),
|
||||||
|
bottom_margin: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the fixed height of the [`Row`]. Any [`Cell`] whose content has more lines than this
|
||||||
|
/// height will see its content truncated.
|
||||||
|
pub fn height(mut self, height: u16) -> Self {
|
||||||
|
self.height = height;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the [`Style`] of the entire row. This [`Style`] can be overridden by the [`Style`] of a
|
||||||
|
/// any individual [`Cell`] or event by their [`Text`] content.
|
||||||
|
pub fn style(mut self, style: Style) -> Self {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the bottom margin. By default, the bottom margin is `0`.
|
||||||
|
pub fn bottom_margin(mut self, margin: u16) -> Self {
|
||||||
|
self.bottom_margin = margin;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total height of the row.
|
||||||
|
fn total_height(&self) -> u16 {
|
||||||
|
self.height.saturating_add(self.bottom_margin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A widget to display data in formatted columns.
|
||||||
|
///
|
||||||
|
/// It is a collection of [`Row`]s, themselves composed of [`Cell`]s:
|
||||||
|
/// ```rust
|
||||||
|
/// # use ratatui::widgets::{Block, Borders, Table, Row, Cell};
|
||||||
|
/// # use ratatui::layout::Constraint;
|
||||||
|
/// # use ratatui::style::{Style, Color, Modifier};
|
||||||
|
/// # use ratatui::text::{Text, Spans, Span};
|
||||||
|
/// Table::new(vec![
|
||||||
|
/// // Row can be created from simple strings.
|
||||||
|
/// Row::new(vec!["Row11", "Row12", "Row13"]),
|
||||||
|
/// // You can style the entire row.
|
||||||
|
/// Row::new(vec!["Row21", "Row22", "Row23"]).style(Style::default().fg(Color::Blue)),
|
||||||
|
/// // If you need more control over the styling you may need to create Cells directly
|
||||||
|
/// Row::new(vec![
|
||||||
|
/// Cell::from("Row31"),
|
||||||
|
/// Cell::from("Row32").style(Style::default().fg(Color::Yellow)),
|
||||||
|
/// Cell::from(Spans::from(vec![
|
||||||
|
/// Span::raw("Row"),
|
||||||
|
/// Span::styled("33", Style::default().fg(Color::Green))
|
||||||
|
/// ])),
|
||||||
|
/// ]),
|
||||||
|
/// // If a Row need to display some content over multiple lines, you just have to change
|
||||||
|
/// // its height.
|
||||||
|
/// Row::new(vec![
|
||||||
|
/// Cell::from("Row\n41"),
|
||||||
|
/// Cell::from("Row\n42"),
|
||||||
|
/// Cell::from("Row\n43"),
|
||||||
|
/// ]).height(2),
|
||||||
|
/// ])
|
||||||
|
/// // You can set the style of the entire Table.
|
||||||
|
/// .style(Style::default().fg(Color::White))
|
||||||
|
/// // It has an optional header, which is simply a Row always visible at the top.
|
||||||
|
/// .header(
|
||||||
|
/// Row::new(vec!["Col1", "Col2", "Col3"])
|
||||||
|
/// .style(Style::default().fg(Color::Yellow))
|
||||||
|
/// // If you want some space between the header and the rest of the rows, you can always
|
||||||
|
/// // specify some margin at the bottom.
|
||||||
|
/// .bottom_margin(1)
|
||||||
|
/// )
|
||||||
|
/// // As any other widget, a Table can be wrapped in a Block.
|
||||||
|
/// .block(Block::default().title("Table"))
|
||||||
|
/// // Columns widths are constrained in the same way as Layout...
|
||||||
|
/// .widths(&[Constraint::Length(5), Constraint::Length(5), Constraint::Length(10)])
|
||||||
|
/// // ...and they can be separated by a fixed spacing.
|
||||||
|
/// .column_spacing(1)
|
||||||
|
/// // If you wish to highlight a row in any specific way when it is selected...
|
||||||
|
/// .highlight_style(Style::default().add_modifier(Modifier::BOLD))
|
||||||
|
/// // ...and potentially show a symbol in front of the selection.
|
||||||
|
/// .highlight_symbol(">>");
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Table<'a> {
|
||||||
|
/// A block to wrap the widget in
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
/// Base style for the widget
|
||||||
|
style: Style,
|
||||||
|
/// Width constraints for each column
|
||||||
|
widths: &'a [Constraint],
|
||||||
|
/// Space between each column
|
||||||
|
column_spacing: u16,
|
||||||
|
/// Style used to render the selected row
|
||||||
|
highlight_style: Style,
|
||||||
|
/// Symbol in front of the selected rom
|
||||||
|
highlight_symbol: Option<&'a str>,
|
||||||
|
/// Optional header
|
||||||
|
header: Option<Row<'a>>,
|
||||||
|
/// Data to display in each row
|
||||||
|
rows: Vec<Row<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Table<'a> {
|
||||||
|
pub fn new<T>(rows: T) -> Self
|
||||||
|
where
|
||||||
|
T: IntoIterator<Item = Row<'a>>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
block: None,
|
||||||
|
style: Style::default(),
|
||||||
|
widths: &[],
|
||||||
|
column_spacing: 1,
|
||||||
|
highlight_style: Style::default(),
|
||||||
|
highlight_symbol: None,
|
||||||
|
header: None,
|
||||||
|
rows: rows.into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn block(mut self, block: Block<'a>) -> Self {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn header(mut self, header: Row<'a>) -> Self {
|
||||||
|
self.header = Some(header);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widths(mut self, widths: &'a [Constraint]) -> Self {
|
||||||
|
let between_0_and_100 = |&w| match w {
|
||||||
|
Constraint::Percentage(p) => p <= 100,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
widths.iter().all(between_0_and_100),
|
||||||
|
"Percentages should be between 0 and 100 inclusively."
|
||||||
|
);
|
||||||
|
self.widths = widths;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> Self {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Self {
|
||||||
|
self.highlight_symbol = Some(highlight_symbol);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highlight_style(mut self, highlight_style: Style) -> Self {
|
||||||
|
self.highlight_style = highlight_style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn column_spacing(mut self, spacing: u16) -> Self {
|
||||||
|
self.column_spacing = spacing;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_columns_widths(&self, max_width: u16, has_selection: bool) -> Vec<u16> {
|
||||||
|
let mut constraints = Vec::with_capacity(self.widths.len() * 2 + 1);
|
||||||
|
if has_selection {
|
||||||
|
let highlight_symbol_width =
|
||||||
|
self.highlight_symbol.map(|s| s.width() as u16).unwrap_or(0);
|
||||||
|
constraints.push(Constraint::Length(highlight_symbol_width));
|
||||||
|
}
|
||||||
|
for constraint in self.widths {
|
||||||
|
constraints.push(*constraint);
|
||||||
|
constraints.push(Constraint::Length(self.column_spacing));
|
||||||
|
}
|
||||||
|
if !self.widths.is_empty() {
|
||||||
|
constraints.pop();
|
||||||
|
}
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints(constraints)
|
||||||
|
.expand_to_fill(false)
|
||||||
|
.split(Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: max_width,
|
||||||
|
height: 1,
|
||||||
|
});
|
||||||
|
let mut chunks = &chunks[..];
|
||||||
|
if has_selection {
|
||||||
|
chunks = &chunks[1..];
|
||||||
|
}
|
||||||
|
chunks.iter().step_by(2).map(|c| c.width).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_row_bounds(
|
||||||
|
&self,
|
||||||
|
selected: Option<usize>,
|
||||||
|
offset: usize,
|
||||||
|
max_height: u16,
|
||||||
|
) -> (usize, usize) {
|
||||||
|
let offset = offset.min(self.rows.len().saturating_sub(1));
|
||||||
|
let mut start = offset;
|
||||||
|
let mut end = offset;
|
||||||
|
let mut height = 0;
|
||||||
|
for item in self.rows.iter().skip(offset) {
|
||||||
|
if height + item.height > max_height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
height += item.total_height();
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected = selected.unwrap_or(0).min(self.rows.len() - 1);
|
||||||
|
while selected >= end {
|
||||||
|
height = height.saturating_add(self.rows[end].total_height());
|
||||||
|
end += 1;
|
||||||
|
while height > max_height {
|
||||||
|
height = height.saturating_sub(self.rows[start].total_height());
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while selected < start {
|
||||||
|
start -= 1;
|
||||||
|
height = height.saturating_add(self.rows[start].total_height());
|
||||||
|
while height > max_height {
|
||||||
|
end -= 1;
|
||||||
|
height = height.saturating_sub(self.rows[end].total_height());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct TableState {
|
||||||
|
offset: usize,
|
||||||
|
selected: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TableState {
|
||||||
|
pub fn selected(&self) -> Option<usize> {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&mut self, index: Option<usize>) {
|
||||||
|
self.selected = index;
|
||||||
|
if index.is_none() {
|
||||||
|
self.offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy of the receiver's scroll offset.
|
||||||
|
///
|
||||||
|
/// This is useful, for example, if you need to "synchronize" the scrolling of a `Table` and a `Paragraph`.
|
||||||
|
pub fn offset(&self) -> usize {
|
||||||
|
self.offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StatefulWidget for Table<'a> {
|
||||||
|
type State = TableState;
|
||||||
|
|
||||||
|
fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
|
if area.area() == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buf.set_style(area, self.style);
|
||||||
|
let table_area = match self.block.take() {
|
||||||
|
Some(b) => {
|
||||||
|
let inner_area = b.inner(area);
|
||||||
|
b.render(area, buf);
|
||||||
|
inner_area
|
||||||
|
}
|
||||||
|
None => area,
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_selection = state.selected.is_some();
|
||||||
|
let columns_widths = self.get_columns_widths(table_area.width, has_selection);
|
||||||
|
let highlight_symbol = self.highlight_symbol.unwrap_or("");
|
||||||
|
let blank_symbol = " ".repeat(highlight_symbol.width());
|
||||||
|
let mut current_height = 0;
|
||||||
|
let mut rows_height = table_area.height;
|
||||||
|
|
||||||
|
// Draw header
|
||||||
|
if let Some(ref header) = self.header {
|
||||||
|
let max_header_height = table_area.height.min(header.total_height());
|
||||||
|
buf.set_style(
|
||||||
|
Rect {
|
||||||
|
x: table_area.left(),
|
||||||
|
y: table_area.top(),
|
||||||
|
width: table_area.width,
|
||||||
|
height: table_area.height.min(header.height),
|
||||||
|
},
|
||||||
|
header.style,
|
||||||
|
);
|
||||||
|
let mut col = table_area.left();
|
||||||
|
if has_selection {
|
||||||
|
col += (highlight_symbol.width() as u16).min(table_area.width);
|
||||||
|
}
|
||||||
|
for (width, cell) in columns_widths.iter().zip(header.cells.iter()) {
|
||||||
|
render_cell(
|
||||||
|
buf,
|
||||||
|
cell,
|
||||||
|
Rect {
|
||||||
|
x: col,
|
||||||
|
y: table_area.top(),
|
||||||
|
width: *width,
|
||||||
|
height: max_header_height,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
col += *width + self.column_spacing;
|
||||||
|
}
|
||||||
|
current_height += max_header_height;
|
||||||
|
rows_height = rows_height.saturating_sub(max_header_height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw rows
|
||||||
|
if self.rows.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (start, end) = self.get_row_bounds(state.selected, state.offset, rows_height);
|
||||||
|
state.offset = start;
|
||||||
|
for (i, table_row) in self
|
||||||
|
.rows
|
||||||
|
.iter_mut()
|
||||||
|
.enumerate()
|
||||||
|
.skip(state.offset)
|
||||||
|
.take(end - start)
|
||||||
|
{
|
||||||
|
let (row, col) = (table_area.top() + current_height, table_area.left());
|
||||||
|
current_height += table_row.total_height();
|
||||||
|
let table_row_area = Rect {
|
||||||
|
x: col,
|
||||||
|
y: row,
|
||||||
|
width: table_area.width,
|
||||||
|
height: table_row.height,
|
||||||
|
};
|
||||||
|
buf.set_style(table_row_area, table_row.style);
|
||||||
|
let is_selected = state.selected.map(|s| s == i).unwrap_or(false);
|
||||||
|
let table_row_start_col = if has_selection {
|
||||||
|
let symbol = if is_selected {
|
||||||
|
highlight_symbol
|
||||||
|
} else {
|
||||||
|
&blank_symbol
|
||||||
|
};
|
||||||
|
let (col, _) =
|
||||||
|
buf.set_stringn(col, row, symbol, table_area.width as usize, table_row.style);
|
||||||
|
col
|
||||||
|
} else {
|
||||||
|
col
|
||||||
|
};
|
||||||
|
let mut col = table_row_start_col;
|
||||||
|
for (width, cell) in columns_widths.iter().zip(table_row.cells.iter()) {
|
||||||
|
render_cell(
|
||||||
|
buf,
|
||||||
|
cell,
|
||||||
|
Rect {
|
||||||
|
x: col,
|
||||||
|
y: row,
|
||||||
|
width: *width,
|
||||||
|
height: table_row.height,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
col += *width + self.column_spacing;
|
||||||
|
}
|
||||||
|
if is_selected {
|
||||||
|
buf.set_style(table_row_area, self.highlight_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_cell(buf: &mut Buffer, cell: &Cell, area: Rect) {
|
||||||
|
buf.set_style(area, cell.style);
|
||||||
|
for (i, spans) in cell.content.lines.iter().enumerate() {
|
||||||
|
if i as u16 >= area.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.set_spans(area.x, area.y + i as u16, spans, area.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Table<'a> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let mut state = TableState::default();
|
||||||
|
StatefulWidget::render(self, area, buf, &mut state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic]
|
||||||
|
fn table_invalid_percentages() {
|
||||||
|
Table::new(vec![]).widths(&[Constraint::Percentage(110)]);
|
||||||
|
}
|
||||||
|
}
|
129
src/ratatui/widgets/tabs.rs
Normal file
129
src/ratatui/widgets/tabs.rs
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
use crate::ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::Rect,
|
||||||
|
style::Style,
|
||||||
|
symbols,
|
||||||
|
text::{Span, Spans},
|
||||||
|
widgets::{Block, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A widget to display available tabs in a multiple panels context.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use ratatui::widgets::{Block, Borders, Tabs};
|
||||||
|
/// # use ratatui::style::{Style, Color};
|
||||||
|
/// # use ratatui::text::{Spans};
|
||||||
|
/// # use ratatui::symbols::{DOT};
|
||||||
|
/// let titles = ["Tab1", "Tab2", "Tab3", "Tab4"].iter().cloned().map(Spans::from).collect();
|
||||||
|
/// Tabs::new(titles)
|
||||||
|
/// .block(Block::default().title("Tabs").borders(Borders::ALL))
|
||||||
|
/// .style(Style::default().fg(Color::White))
|
||||||
|
/// .highlight_style(Style::default().fg(Color::Yellow))
|
||||||
|
/// .divider(DOT);
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Tabs<'a> {
|
||||||
|
/// A block to wrap this widget in if necessary
|
||||||
|
block: Option<Block<'a>>,
|
||||||
|
/// One title for each tab
|
||||||
|
titles: Vec<Spans<'a>>,
|
||||||
|
/// The index of the selected tabs
|
||||||
|
selected: usize,
|
||||||
|
/// The style used to draw the text
|
||||||
|
style: Style,
|
||||||
|
/// Style to apply to the selected item
|
||||||
|
highlight_style: Style,
|
||||||
|
/// Tab divider
|
||||||
|
divider: Span<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Tabs<'a> {
|
||||||
|
pub fn new(titles: Vec<Spans<'a>>) -> Tabs<'a> {
|
||||||
|
Tabs {
|
||||||
|
block: None,
|
||||||
|
titles,
|
||||||
|
selected: 0,
|
||||||
|
style: Default::default(),
|
||||||
|
highlight_style: Default::default(),
|
||||||
|
divider: Span::raw(symbols::line::VERTICAL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn block(mut self, block: Block<'a>) -> Tabs<'a> {
|
||||||
|
self.block = Some(block);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(mut self, selected: usize) -> Tabs<'a> {
|
||||||
|
self.selected = selected;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(mut self, style: Style) -> Tabs<'a> {
|
||||||
|
self.style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highlight_style(mut self, style: Style) -> Tabs<'a> {
|
||||||
|
self.highlight_style = style;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn divider<T>(mut self, divider: T) -> Tabs<'a>
|
||||||
|
where
|
||||||
|
T: Into<Span<'a>>,
|
||||||
|
{
|
||||||
|
self.divider = divider.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Tabs<'a> {
|
||||||
|
fn render(mut self, area: Rect, buf: &mut Buffer) {
|
||||||
|
buf.set_style(area, self.style);
|
||||||
|
let tabs_area = match self.block.take() {
|
||||||
|
Some(b) => {
|
||||||
|
let inner_area = b.inner(area);
|
||||||
|
b.render(area, buf);
|
||||||
|
inner_area
|
||||||
|
}
|
||||||
|
None => area,
|
||||||
|
};
|
||||||
|
|
||||||
|
if tabs_area.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut x = tabs_area.left();
|
||||||
|
let titles_length = self.titles.len();
|
||||||
|
for (i, title) in self.titles.into_iter().enumerate() {
|
||||||
|
let last_title = titles_length - 1 == i;
|
||||||
|
x = x.saturating_add(1);
|
||||||
|
let remaining_width = tabs_area.right().saturating_sub(x);
|
||||||
|
if remaining_width == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let pos = buf.set_spans(x, tabs_area.top(), &title, remaining_width);
|
||||||
|
if i == self.selected {
|
||||||
|
buf.set_style(
|
||||||
|
Rect {
|
||||||
|
x,
|
||||||
|
y: tabs_area.top(),
|
||||||
|
width: pos.0.saturating_sub(x),
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
self.highlight_style,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
x = pos.0.saturating_add(1);
|
||||||
|
let remaining_width = tabs_area.right().saturating_sub(x);
|
||||||
|
if remaining_width == 0 || last_title {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let pos = buf.set_span(x, tabs_area.top(), &self.divider, remaining_width);
|
||||||
|
x = pos.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue