Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
720303e806 | ||
|
|
5f0331ec89 | ||
|
|
eeed03cba2 | ||
|
|
c3b575e24e | ||
|
|
6fdb3fa18e | ||
|
|
ab5ad3fc7c | ||
|
|
332658406c | ||
|
|
ae975c7200 | ||
|
|
3df8c20dfb | ||
|
|
2dcf1a61c1 | ||
|
|
6ce4a7e84b | ||
|
|
e0687aa155 | ||
|
|
0346476ddf | ||
|
|
fd8c1a4010 | ||
|
|
dd6621daf9 | ||
|
|
d37db578b8 | ||
|
|
53d925a8ab | ||
|
|
1e0ab0c549 | ||
|
|
e82b3b77fe | ||
|
|
b696ea37b2 | ||
|
|
64d964b259 | ||
|
|
8d73d4738e | ||
|
|
556cc7b543 | ||
|
|
fbd562117b | ||
|
|
d2b0ce17a4 | ||
|
|
01a15f9809 | ||
|
|
1c9f56aa4b | ||
|
|
0e89e042e0 | ||
|
|
2e73be7982 | ||
|
|
f9d066f4d7 | ||
|
|
65c520245a | ||
|
|
f8b42adb5f |
17
.github/ISSUE_TEMPLATE/bug_report.md
vendored
17
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -13,27 +13,43 @@ 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.
|
||||
-->
|
||||
|
||||
## Are you willing to contribute a fix?
|
||||
|
||||
<!--
|
||||
If you would like to work on a fix, check one of the boxes below. Maintainers can help point
|
||||
you to the right place in the codebase.
|
||||
-->
|
||||
|
||||
- [ ] I am willing to open a PR for this bug.
|
||||
- [ ] I can try to investigate, but I will need guidance.
|
||||
- [ ] I am not able to work on a fix right now.
|
||||
|
||||
## Environment
|
||||
|
||||
<!--
|
||||
Add a description of the systems where you are observing the issue. For example:
|
||||
- OS: Linux
|
||||
@@ -50,6 +66,7 @@ Add a description of the systems where you are observing the issue. For example:
|
||||
- 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.
|
||||
|
||||
15
.github/ISSUE_TEMPLATE/feature_request.md
vendored
15
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -8,11 +8,13 @@ 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:
|
||||
@@ -22,11 +24,24 @@ Things to consider:
|
||||
-->
|
||||
|
||||
## Alternatives
|
||||
|
||||
<!--
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
-->
|
||||
|
||||
## Are you willing to contribute an implementation?
|
||||
|
||||
<!--
|
||||
If you would like to work on this, check one of the boxes below. Maintainers can help refine
|
||||
the scope and discuss approach.
|
||||
-->
|
||||
|
||||
- [ ] I am willing to open a PR implementing this.
|
||||
- [ ] I can try to implement it, but I will need guidance.
|
||||
- [ ] I am not able to implement this right now.
|
||||
|
||||
## Additional context
|
||||
|
||||
<!--
|
||||
Add any other context or screenshots about the feature request here.
|
||||
-->
|
||||
|
||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
toolchain: nightly
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
- uses: taiki-e/install-action@b9c5db3aef04caffaf95a1d03931de10fb2a140f # v2
|
||||
- uses: taiki-e/install-action@3522286d40783523f9c7880e33f785905b4c20d0 # v2
|
||||
with:
|
||||
tool: taplo-cli
|
||||
- run: cargo xtask format --check
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # master
|
||||
- uses: crate-ci/typos@bb4666ad77b539a6b4ce4eda7ebb6de553704021 # master
|
||||
|
||||
# Check for any disallowed dependencies in the codebase due to license / security issues.
|
||||
# See <https://github.com/EmbarkStudios/cargo-deny>
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: EmbarkStudios/cargo-deny-action@76cd80eb775d7bbbd2d80292136d74d39e1b4918 # v2
|
||||
- uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2
|
||||
with:
|
||||
rust-toolchain: stable
|
||||
log-level: info
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
with:
|
||||
toolchain: stable
|
||||
components: llvm-tools
|
||||
- uses: taiki-e/install-action@b9c5db3aef04caffaf95a1d03931de10fb2a140f # v2
|
||||
- uses: taiki-e/install-action@3522286d40783523f9c7880e33f785905b4c20d0 # v2
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master
|
||||
with:
|
||||
toolchain: ${{ matrix.toolchain }}
|
||||
- uses: taiki-e/install-action@b9c5db3aef04caffaf95a1d03931de10fb2a140f # v2
|
||||
- uses: taiki-e/install-action@3522286d40783523f9c7880e33f785905b4c20d0 # v2
|
||||
with:
|
||||
tool: cargo-hack
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
- uses: taiki-e/install-action@b9c5db3aef04caffaf95a1d03931de10fb2a140f # v2
|
||||
- uses: taiki-e/install-action@3522286d40783523f9c7880e33f785905b4c20d0 # v2
|
||||
with:
|
||||
tool: cargo-rdme
|
||||
- run: cargo xtask readme --check
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
- uses: dtolnay/install@74f735cdf643820234e37ae1c4089a08fd266d8a # master
|
||||
with:
|
||||
crate: cargo-docs-rs
|
||||
- uses: taiki-e/install-action@b9c5db3aef04caffaf95a1d03931de10fb2a140f # v2
|
||||
- uses: taiki-e/install-action@3522286d40783523f9c7880e33f785905b4c20d0 # v2
|
||||
with:
|
||||
tool: cargo-hack
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: taiki-e/install-action@b9c5db3aef04caffaf95a1d03931de10fb2a140f # v2
|
||||
- uses: taiki-e/install-action@3522286d40783523f9c7880e33f785905b4c20d0 # v2
|
||||
with:
|
||||
tool: cargo-hack
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
@@ -265,7 +265,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: taiki-e/install-action@b9c5db3aef04caffaf95a1d03931de10fb2a140f # v2
|
||||
- uses: taiki-e/install-action@3522286d40783523f9c7880e33f785905b4c20d0 # v2
|
||||
with:
|
||||
tool: cargo-hack
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
|
||||
@@ -10,7 +10,9 @@ GitHub with a [breaking change] label.
|
||||
|
||||
This is a quick summary of the sections below:
|
||||
|
||||
- [v0.30.0 Unreleased](#v0300-unreleased)
|
||||
- [v0.30.1](#v0301)
|
||||
- Adding `AsRef` impls for widgets may affect type inference in rare cases
|
||||
- [v0.30.0](#v0300)
|
||||
- `Flex::SpaceAround` now mirrors flexbox: space between items is twice the size of the outer gaps
|
||||
are twice the size of first and last elements
|
||||
- `block::Title` no longer exists
|
||||
@@ -93,7 +95,18 @@ This is a quick summary of the sections below:
|
||||
- MSRV is now 1.63.0
|
||||
- `List` no longer ignores empty strings
|
||||
|
||||
## v0.30.0 Unreleased
|
||||
## [v0.30.1](https://github.com/ratatui/ratatui/releases/tag/ratatui-v0.30.1)
|
||||
|
||||
### Adding `AsRef` impls for widgets may affect type inference ([#2297])
|
||||
|
||||
[#2297]: https://github.com/ratatui/ratatui/pull/2297
|
||||
|
||||
Adding `AsRef<Self>` for built-in widgets can change type inference outcomes in rare cases where
|
||||
`AsRef` is part of a trait bound, and can also conflict with downstream blanket or manual `AsRef`
|
||||
impls for widget types. If you hit new ambiguity errors, add explicit type annotations or specify
|
||||
the concrete widget type to guide inference, and remove any redundant `AsRef` impls.
|
||||
|
||||
## [v0.30.0](https://github.com/ratatui/ratatui/releases/tag/ratatui-v0.30.0)
|
||||
|
||||
### `Marker` is now non-exhaustive ([#2236])
|
||||
|
||||
|
||||
@@ -340,7 +340,7 @@ We are excited to announce the biggest release of `ratatui` so far - a Rust libr
|
||||
|
||||
- [22610b0](https://github.com/ratatui/ratatui/commit/22610b019b9e7b451cd2ba2c44aa625fd24a8f95) *(uncategorized)* Support adding an Offset to Position by @joshka in [#2239](https://github.com/ratatui/ratatui/pull/2239)
|
||||
|
||||
> Adds Position::offset() and arithmentic ops (Position + Offset and
|
||||
> Adds Position::offset() and arithmetic ops (Position + Offset and
|
||||
> Position - Offset)
|
||||
>
|
||||
> Fixes:https://github.com/ratatui/ratatui/issues/2018
|
||||
|
||||
48
Cargo.lock
generated
48
Cargo.lock
generated
@@ -417,9 +417,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.53"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
|
||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -437,9 +437,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.53"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
|
||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -1028,7 +1028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1865,9 +1865,9 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.2"
|
||||
version = "0.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f"
|
||||
checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
@@ -2099,9 +2099,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "octocrab"
|
||||
version = "0.49.2"
|
||||
version = "0.49.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a05f533ce747c92b413d143ae258c3bd11a56ffdf7f2cee1896539c89c59acc"
|
||||
checksum = "89f6f72d7084a80bf261bb6b6f83bd633323d5633d5ec7988c6c95b20448b2b5"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -2871,9 +2871,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.9"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
@@ -2943,7 +2943,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2956,7 +2956,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.9.4",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3138,15 +3138,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.146"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217ca874ae0207aac254aa02c957ded05585a90892cc8d87f9e5fa49669dadd8"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3659,9 +3659,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.48.0"
|
||||
version = "1.49.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
@@ -3694,9 +3694,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
@@ -4289,7 +4289,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4673,3 +4673,9 @@ dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f4a4e8e9dc5c62d159f04fcdbe07f4c3fb710415aab4754bf11505501e3251d"
|
||||
|
||||
@@ -64,7 +64,7 @@ strum = { version = "0.27", default-features = false, features = ["derive"] }
|
||||
termion = "4"
|
||||
termwiz = "0.23"
|
||||
thiserror = { version = "2", default-features = false }
|
||||
time = { version = "0.3", default-features = false }
|
||||
time = { version = "0.3.37", default-features = false }
|
||||
tokio = "1"
|
||||
tokio-stream = "0.1"
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -34,7 +34,7 @@ body = """
|
||||
{% macro commit(commit) -%}
|
||||
- [{{ commit.id | truncate(length=7, end="") }}]({{ "https://github.com/ratatui/ratatui/commit/" ~ commit.id }}) \
|
||||
*({{commit.scope | default(value = "uncategorized") | lower }})* {{ commit.message | upper_first | trim }}\
|
||||
{% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%}\
|
||||
{% if commit.remote.username %} by `@{{ commit.remote.username }}`{%- endif -%}\
|
||||
{% if commit.remote.pr_number %} in [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}){%- endif %}\
|
||||
{%- if commit.breaking %} [**breaking**]{% endif %}
|
||||
{%- if commit.body %}\n\n{{ commit.body | indent(prefix=" > ", first=true, blank=true) }}
|
||||
@@ -123,6 +123,7 @@ commit_preprocessors = [
|
||||
{ pattern = '\<[f]eatuers\>', replace = "features" },
|
||||
{ pattern = '\<[s]pecically\>', replace = "specially" },
|
||||
{ pattern = '\<[g]ague\>', replace = "gauge" },
|
||||
{ pattern = '\<[a]rithmentic\>', replace = "arithmetic" },
|
||||
{ pattern = '\<[i]ntructions\>', replace = "instructions" },
|
||||
{ pattern = '\<[i]mplementated\>', replace = "implemented" },
|
||||
]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// A Ratatui example that demonstrates how different layout constraints work.
|
||||
///
|
||||
/// It also supports swapping constraints, adding and removing blocks, and changing the spacing
|
||||
@@ -30,7 +32,7 @@ fn main() -> Result<()> {
|
||||
#[derive(Default)]
|
||||
struct App {
|
||||
mode: AppMode,
|
||||
spacing: u16,
|
||||
spacing: i16,
|
||||
constraints: Vec<Constraint>,
|
||||
selected_index: usize,
|
||||
value: u16,
|
||||
@@ -269,7 +271,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn instructions() -> impl Widget {
|
||||
let text = "◄ ►: select, ▲ ▼: edit, 1-6: swap, a: add, x: delete, q: quit, + -: spacing";
|
||||
let text = "◄ ►: select, ▲ ▼: edit, 1-6: swap, a: add, x: delete, q: quit, +/-: spacing";
|
||||
Paragraph::new(text)
|
||||
.fg(Self::TEXT_COLOR)
|
||||
.centered()
|
||||
@@ -307,10 +309,12 @@ impl App {
|
||||
///
|
||||
/// Only shows the gap when spacing is not zero
|
||||
fn axis(&self, width: u16) -> impl Widget {
|
||||
let label = if self.spacing != 0 {
|
||||
format!("{} px (gap: {} px)", width, self.spacing)
|
||||
} else {
|
||||
format!("{width} px")
|
||||
let label = match self.spacing.cmp(&0) {
|
||||
Ordering::Greater => format!("{width} px (gap: {} px)", self.spacing),
|
||||
Ordering::Less => {
|
||||
format!("{width} px (overlap: {} px)", self.spacing.unsigned_abs())
|
||||
}
|
||||
Ordering::Equal => format!("{width} px"),
|
||||
};
|
||||
let bar_width = width.saturating_sub(2) as usize; // we want to `<` and `>` at the ends
|
||||
let width_bar = format!("<{label:-^bar_width$}>");
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
use color_eyre::Result;
|
||||
use crossterm::event::{self, KeyCode};
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Flex, Layout, Rect};
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Clear};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
@@ -52,19 +52,14 @@ fn render(frame: &mut Frame, show_popup: bool) {
|
||||
frame.render_widget(Block::bordered().title("Content").on_blue(), content);
|
||||
|
||||
if show_popup {
|
||||
let popup = Block::bordered().title("Popup");
|
||||
let popup_area = centered_area(area, 60, 20);
|
||||
let popup_block = Block::bordered().title("Popup");
|
||||
let centered_area = area.centered(Constraint::Percentage(60), Constraint::Percentage(20));
|
||||
// clears out any background in the area before rendering the popup
|
||||
frame.render_widget(Clear, popup_area);
|
||||
frame.render_widget(popup, popup_area);
|
||||
frame.render_widget(Clear, centered_area);
|
||||
let paragraph = Paragraph::new("Lorem ipsum").block(popup_block);
|
||||
frame.render_widget(paragraph, centered_area);
|
||||
// another solution is to use the inner area of the block
|
||||
// let inner_area = popup_block.inner(centered_area);
|
||||
// frame.render_widget(your_widget, inner_area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a centered rect using up certain percentage of the available rect
|
||||
fn centered_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
|
||||
let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center);
|
||||
let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center);
|
||||
let [area] = area.layout(&vertical);
|
||||
let [area] = area.layout(&horizontal);
|
||||
area
|
||||
}
|
||||
|
||||
@@ -222,6 +222,19 @@ impl App {
|
||||
let item = data.ref_array();
|
||||
item.into_iter()
|
||||
.map(|content| Cell::from(Text::from(format!("\n{content}\n"))))
|
||||
.enumerate()
|
||||
.map(|(idx, cell)| {
|
||||
if i == 3 && idx == 1 {
|
||||
Cell::from(Text::from(
|
||||
// Gratuitously long error message to demonstrate column_span(2)
|
||||
"\n[no address or email address is available for this person]\n"
|
||||
.to_string(),
|
||||
))
|
||||
.column_span(2)
|
||||
} else {
|
||||
cell
|
||||
}
|
||||
})
|
||||
.collect::<Row>()
|
||||
.style(Style::new().fg(self.colors.row_fg).bg(color))
|
||||
.height(4)
|
||||
|
||||
@@ -109,19 +109,28 @@ use crate::layout::{Position, Size};
|
||||
mod test;
|
||||
pub use self::test::TestBackend;
|
||||
|
||||
/// Enum representing the different types of clearing operations that can be performed
|
||||
/// on the terminal screen.
|
||||
/// Defines which region of the terminal's visible display area is cleared.
|
||||
///
|
||||
/// Clearing operates on character cells in the active display surface. It does not move, hide, or
|
||||
/// reset the cursor position. If the cursor lies inside the cleared region, the character cell at
|
||||
/// the cursor position is cleared as well.
|
||||
///
|
||||
/// Clearing applies to the terminal's visible display area, not just content previously drawn by
|
||||
/// Ratatui. No guarantees are made about scrollback, history, or off-screen buffers.
|
||||
#[derive(Debug, Display, EnumString, Clone, Copy, Eq, PartialEq, Hash)]
|
||||
pub enum ClearType {
|
||||
/// Clear the entire screen.
|
||||
/// Clears all character cells in the visible display area.
|
||||
All,
|
||||
/// Clear everything after the cursor.
|
||||
/// Clears all character cells from the cursor position (inclusive) through the end of the
|
||||
/// display area.
|
||||
AfterCursor,
|
||||
/// Clear everything before the cursor.
|
||||
/// Clears all character cells from the start of the display area through the cursor position
|
||||
/// (inclusive).
|
||||
BeforeCursor,
|
||||
/// Clear the current line.
|
||||
/// Clears all character cells in the cursor's current line.
|
||||
CurrentLine,
|
||||
/// Clear everything from the cursor until the next newline.
|
||||
/// Clears all character cells from the cursor position (inclusive) to the end of the current
|
||||
/// line.
|
||||
UntilNewLine,
|
||||
}
|
||||
|
||||
@@ -237,7 +246,14 @@ pub trait Backend {
|
||||
self.set_cursor_position(Position { x, y })
|
||||
}
|
||||
|
||||
/// Clears the whole terminal screen
|
||||
/// Clears all character cells in the terminal's visible display area.
|
||||
///
|
||||
/// This operation preserves the cursor position. If the cursor lies within the cleared
|
||||
/// region, the character cell at the cursor position is cleared. No guarantees are made about
|
||||
/// scrollback, history, or off-screen buffers.
|
||||
///
|
||||
/// This is equivalent to calling [`clear_region`](Self::clear_region) with
|
||||
/// [`ClearType::All`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
@@ -251,7 +267,13 @@ pub trait Backend {
|
||||
/// ```
|
||||
fn clear(&mut self) -> Result<(), Self::Error>;
|
||||
|
||||
/// Clears a specific region of the terminal specified by the [`ClearType`] parameter
|
||||
/// Clears a specific region of the terminal's visible display area, as defined by
|
||||
/// [`ClearType`].
|
||||
///
|
||||
/// This operation preserves the cursor position. If the cursor lies within the cleared
|
||||
/// region, the character cell at the cursor position is cleared. Clearing applies to the
|
||||
/// active display surface only and does not make guarantees about scrollback, history, or
|
||||
/// off-screen buffers.
|
||||
///
|
||||
/// This method is optional and may not be implemented by all backends. The default
|
||||
/// implementation calls [`clear`] if the `clear_type` is [`ClearType::All`] and returns an
|
||||
|
||||
@@ -105,6 +105,19 @@ impl TestBackend {
|
||||
&self.buffer
|
||||
}
|
||||
|
||||
/// Returns whether the cursor is visible.
|
||||
pub const fn cursor_visible(&self) -> bool {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
/// Returns the current cursor position.
|
||||
pub const fn cursor_position(&self) -> Position {
|
||||
Position {
|
||||
x: self.pos.0,
|
||||
y: self.pos.1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a reference to the internal scrollback buffer of the `TestBackend`.
|
||||
///
|
||||
/// The scrollback buffer represents the part of the screen that is currently hidden from view,
|
||||
@@ -275,12 +288,12 @@ impl Backend for TestBackend {
|
||||
let region = match clear_type {
|
||||
ClearType::All => return self.clear(),
|
||||
ClearType::AfterCursor => {
|
||||
let index = self.buffer.index_of(self.pos.0, self.pos.1) + 1;
|
||||
let index = self.buffer.index_of(self.pos.0, self.pos.1);
|
||||
&mut self.buffer.content[index..]
|
||||
}
|
||||
ClearType::BeforeCursor => {
|
||||
let index = self.buffer.index_of(self.pos.0, self.pos.1);
|
||||
&mut self.buffer.content[..index]
|
||||
&mut self.buffer.content[..=index]
|
||||
}
|
||||
ClearType::CurrentLine => {
|
||||
let line_start_index = self.buffer.index_of(0, self.pos.1);
|
||||
@@ -620,7 +633,7 @@ mod tests {
|
||||
backend.assert_buffer_lines([
|
||||
"aaaaaaaaaa",
|
||||
"aaaaaaaaaa",
|
||||
"aaaa ",
|
||||
"aaa ",
|
||||
" ",
|
||||
" ",
|
||||
]);
|
||||
@@ -644,7 +657,7 @@ mod tests {
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" aaaaa",
|
||||
" aaaa",
|
||||
"aaaaaaaaaa",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::text::{Line, Span};
|
||||
/// use ratatui_core::layout::{Position, Rect};
|
||||
/// use ratatui_core::style::{Color, Style};
|
||||
///
|
||||
/// # fn foo() -> Option<()> {
|
||||
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut buf = Buffer::empty(Rect {
|
||||
/// x: 0,
|
||||
/// y: 0,
|
||||
@@ -39,13 +39,15 @@ use crate::text::{Line, Span};
|
||||
///
|
||||
/// // indexing using (x, y) tuple (which is converted to Position)
|
||||
/// buf[(0, 1)].set_symbol("B");
|
||||
/// assert_eq!(buf[(0, 1)].symbol(), "x");
|
||||
/// assert_eq!(buf[(0, 1)].symbol(), "B");
|
||||
///
|
||||
/// // getting an Option instead of panicking if the position is outside the buffer
|
||||
/// let cell = buf.cell_mut(Position { x: 0, y: 2 })?;
|
||||
/// let cell = buf
|
||||
/// .cell_mut(Position { x: 0, y: 2 })
|
||||
/// .ok_or("cell not found")?;
|
||||
/// cell.set_symbol("C");
|
||||
///
|
||||
/// let cell = buf.cell(Position { x: 0, y: 2 })?;
|
||||
/// let cell = buf.cell(Position { x: 0, y: 2 }).ok_or("cell not found")?;
|
||||
/// assert_eq!(cell.symbol(), "C");
|
||||
///
|
||||
/// buf.set_string(
|
||||
@@ -58,7 +60,7 @@ use crate::text::{Line, Span};
|
||||
/// assert_eq!(cell.symbol(), "r");
|
||||
/// assert_eq!(cell.fg, Color::Red);
|
||||
/// assert_eq!(cell.bg, Color::White);
|
||||
/// # Some(())
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Default, Clone, Eq, PartialEq, Hash)]
|
||||
|
||||
@@ -31,10 +31,381 @@
|
||||
//! [`Backend`]: crate::backend::Backend
|
||||
//! [`Buffer`]: crate::buffer::Buffer
|
||||
|
||||
mod backend;
|
||||
mod buffers;
|
||||
mod cursor;
|
||||
mod frame;
|
||||
mod terminal;
|
||||
mod init;
|
||||
mod inline;
|
||||
mod render;
|
||||
mod resize;
|
||||
mod viewport;
|
||||
|
||||
pub use frame::{CompletedFrame, Frame};
|
||||
pub use terminal::{Options as TerminalOptions, Terminal};
|
||||
pub use viewport::Viewport;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::buffer::Buffer;
|
||||
use crate::layout::{Position, Rect};
|
||||
|
||||
/// An interface to interact and draw [`Frame`]s on the user's terminal.
|
||||
///
|
||||
/// This is the main entry point for Ratatui. It is responsible for drawing and maintaining the
|
||||
/// state of the buffers, cursor and viewport.
|
||||
///
|
||||
/// If you're building a fullscreen application with the `ratatui` crate's default backend
|
||||
/// ([Crossterm]), prefer [`ratatui::run`] (or [`ratatui::init`] + [`ratatui::restore`]) over
|
||||
/// constructing `Terminal` directly. These helpers enable common terminal modes (raw mode +
|
||||
/// alternate screen) and restore them on exit and on panic.
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// ratatui::run(|terminal| {
|
||||
/// let mut should_quit = false;
|
||||
/// while !should_quit {
|
||||
/// terminal.draw(|frame| {
|
||||
/// frame.render_widget("Hello, World!", frame.area());
|
||||
/// })?;
|
||||
///
|
||||
/// // Handle events, update application state, and set `should_quit = true` to exit.
|
||||
/// }
|
||||
/// Ok(())
|
||||
/// })?;
|
||||
/// ```
|
||||
///
|
||||
/// # Typical Usage
|
||||
///
|
||||
/// In a typical application, the flow is: set up a terminal, run an event loop, update state, and
|
||||
/// draw each frame.
|
||||
///
|
||||
/// 1. Choose a setup path for a `Terminal`. Most apps call [`ratatui::run`], which passes a
|
||||
/// preconfigured `Terminal` into your callback. If you need more control, use [`ratatui::init`]
|
||||
/// and [`ratatui::restore`], or construct a `Terminal` manually via [`Terminal::new`]
|
||||
/// (fullscreen) or [`Terminal::with_options`] (select a [`Viewport`]).
|
||||
/// 2. Enter your application's event loop and call [`Terminal::draw`] (or [`Terminal::try_draw`])
|
||||
/// to render the current UI state into a [`Frame`].
|
||||
/// 3. Handle input and application state updates between draw calls.
|
||||
/// 4. If the terminal is resized, call [`Terminal::draw`] again. Ratatui automatically resizes
|
||||
/// fullscreen and inline viewports during `draw`; fixed viewports require an explicit call to
|
||||
/// [`Terminal::resize`] if you want the region to change.
|
||||
///
|
||||
/// # Rendering Pipeline
|
||||
///
|
||||
/// A single call to [`Terminal::draw`] (or [`Terminal::try_draw`]) represents one render pass. In
|
||||
/// broad strokes, Ratatui:
|
||||
///
|
||||
/// 1. Checks whether the underlying terminal size changed (see [`Terminal::autoresize`]).
|
||||
/// 2. Creates a [`Frame`] backed by the current buffer (see [`Terminal::get_frame`]).
|
||||
/// 3. Runs your render callback to populate that buffer.
|
||||
/// 4. Diffs the current buffer against the previous buffer and writes the changes (see
|
||||
/// [`Terminal::flush`]).
|
||||
/// 5. Applies cursor visibility and position requested by the frame (see
|
||||
/// [`Frame::set_cursor_position`]).
|
||||
/// 6. Swaps the buffers to prepare for the next render pass (see [`Terminal::swap_buffers`]).
|
||||
/// 7. Flushes the backend (see [`Backend::flush`]).
|
||||
///
|
||||
/// Each render pass starts with an empty buffer for the current viewport. Your render callback
|
||||
/// should render everything that should be visible in [`Frame::area`], even if it is unchanged
|
||||
/// from the previous frame. Ratatui diffs the current and previous buffers and only writes the
|
||||
/// changes; anything you don't render is treated as empty and may clear previously drawn content.
|
||||
///
|
||||
/// If the viewport size changes between render passes (for example via [`Terminal::autoresize`] or
|
||||
/// an explicit [`Terminal::resize`]), Ratatui clears the viewport and resets the previous buffer so
|
||||
/// the next `draw` is treated as a full redraw.
|
||||
///
|
||||
/// Most applications should use [`Terminal::draw`] / [`Terminal::try_draw`]. For manual rendering
|
||||
/// (primarily for tests), you can build a frame with [`Terminal::get_frame`], write diffs with
|
||||
/// [`Terminal::flush`], then call [`Terminal::swap_buffers`]. If your backend buffers output, also
|
||||
/// call [`Backend::flush`].
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # mod ratatui {
|
||||
/// # pub use ratatui_core::backend;
|
||||
/// # pub use ratatui_core::terminal::Terminal;
|
||||
/// # }
|
||||
/// use ratatui::Terminal;
|
||||
/// use ratatui::backend::{Backend, TestBackend};
|
||||
///
|
||||
/// let backend = TestBackend::new(10, 10);
|
||||
/// let mut terminal = Terminal::new(backend)?;
|
||||
///
|
||||
/// // Manual render pass (roughly what `Terminal::draw` does internally).
|
||||
/// {
|
||||
/// let mut frame = terminal.get_frame();
|
||||
/// frame.render_widget("Hello World!", frame.area());
|
||||
/// }
|
||||
///
|
||||
/// terminal.flush()?;
|
||||
/// terminal.swap_buffers();
|
||||
/// terminal.backend_mut().flush()?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
///
|
||||
/// # Viewports
|
||||
///
|
||||
/// The viewport controls *where* Ratatui draws and therefore what [`Frame::area`] represents.
|
||||
/// Most applications use [`Viewport::Fullscreen`], but Ratatui also supports [`Viewport::Inline`]
|
||||
/// and [`Viewport::Fixed`].
|
||||
///
|
||||
/// Choose a viewport at initialization time with [`Terminal::with_options`] and
|
||||
/// [`TerminalOptions`].
|
||||
///
|
||||
/// In [`Viewport::Fullscreen`], the viewport is the entire terminal and `Frame::area` starts at
|
||||
/// (0, 0). Ratatui automatically resizes the internal buffers when the terminal size changes.
|
||||
///
|
||||
/// In [`Viewport::Fixed`], the viewport is a user-provided [`Rect`] in terminal coordinates.
|
||||
/// `Frame::area` is that exact rectangle (including its `x`/`y` offset). Fixed viewports are not
|
||||
/// automatically resized; if the region should change, call [`Terminal::resize`].
|
||||
///
|
||||
/// In [`Viewport::Inline`], Ratatui draws into a rectangle anchored to where the UI started. This
|
||||
/// mode is described in more detail in the "Inline Viewport" section below.
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use ratatui::{layout::Rect, Terminal, TerminalOptions, Viewport};
|
||||
/// use ratatui::backend::CrosstermBackend;
|
||||
///
|
||||
/// // Fullscreen (most common):
|
||||
/// let fullscreen = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
|
||||
///
|
||||
/// // Fixed region (your app manages the coordinates):
|
||||
/// let viewport = Viewport::Fixed(Rect::new(0, 0, 30, 10));
|
||||
/// let fixed = Terminal::with_options(
|
||||
/// CrosstermBackend::new(std::io::stdout()),
|
||||
/// TerminalOptions { viewport },
|
||||
/// )?;
|
||||
/// ```
|
||||
///
|
||||
/// Applications should detect terminal resizes and call [`Terminal::draw`] to redraw the
|
||||
/// application with the new size. This will automatically resize the internal buffers to match the
|
||||
/// new size for inline and fullscreen viewports. Fixed viewports are not resized automatically.
|
||||
///
|
||||
/// # Inline Viewport
|
||||
///
|
||||
/// Inline mode is designed for applications that want to embed a UI into a larger CLI flow. In
|
||||
/// [`Viewport::Inline`], Ratatui anchors the viewport to the backend cursor row at initialization
|
||||
/// time and always starts drawing at column 0.
|
||||
///
|
||||
/// To reserve vertical space for the requested height, Ratatui may append lines. When the cursor is
|
||||
/// near the bottom edge, terminals scroll; Ratatui accounts for that scrolling by shifting the
|
||||
/// computed viewport origin upward so the viewport stays fully visible.
|
||||
///
|
||||
/// While running in inline mode, [`Terminal::insert_before`] can be used to print output above the
|
||||
/// viewport without disturbing the UI.
|
||||
/// When Ratatui is built with the `scrolling-regions` feature, `insert_before` can do this without
|
||||
/// clearing and redrawing the viewport.
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use ratatui::{TerminalOptions, Viewport};
|
||||
///
|
||||
/// println!("Some output above the UI");
|
||||
///
|
||||
/// let options = TerminalOptions {
|
||||
/// viewport: Viewport::Inline(10),
|
||||
/// };
|
||||
/// let mut terminal = ratatui::try_init_with_options(options)?;
|
||||
///
|
||||
/// terminal.insert_before(1, |buf| {
|
||||
/// // Render a single line of output into `buf` before the UI.
|
||||
/// // (For example: logs, status updates, or command output.)
|
||||
/// })?;
|
||||
/// ```
|
||||
///
|
||||
/// # More Information
|
||||
///
|
||||
/// - Choosing a viewport: [`Terminal::with_options`], [`TerminalOptions`], and [`Viewport`]
|
||||
/// - The rendering pipeline: [`Terminal::draw`] and [`Terminal::try_draw`]
|
||||
/// - Resize handling: [`Terminal::autoresize`] and [`Terminal::resize`]
|
||||
/// - Manual rendering and testing: [`Terminal::get_frame`], [`Terminal::flush`], and
|
||||
/// [`Terminal::swap_buffers`]
|
||||
/// - Printing above an inline UI: [`Terminal::insert_before`]
|
||||
///
|
||||
/// # Initialization
|
||||
///
|
||||
/// Most interactive TUIs need process-wide terminal setup (for example: raw mode and an alternate
|
||||
/// screen) and matching teardown on exit and on panic. In Ratatui, that setup lives in the
|
||||
/// `ratatui` crate; `Terminal` itself focuses on rendering and does not implicitly change those
|
||||
/// modes.
|
||||
///
|
||||
/// If you're using the `ratatui` crate with its default backend ([Crossterm]), there are three
|
||||
/// common entry points:
|
||||
///
|
||||
/// - [`ratatui::run`]: recommended for most applications. Provides a [`ratatui::DefaultTerminal`],
|
||||
/// runs your closure, and restores terminal state on exit and on panic.
|
||||
/// - [`ratatui::init`] + [`ratatui::restore`]: like `run`, but you control the event loop and
|
||||
/// decide when to restore.
|
||||
/// - [`Terminal::new`] / [`Terminal::with_options`]: manual construction (for example: custom
|
||||
/// backends such as [Termion] / [Termwiz], inline UIs, or fixed viewports). You are responsible
|
||||
/// for terminal mode setup and teardown.
|
||||
///
|
||||
/// [`ratatui::run`] was introduced in Ratatui 0.30, so older tutorials may use `init`/`restore` or
|
||||
/// manual construction.
|
||||
///
|
||||
/// Some applications install a custom panic hook to log a crash report, print a friendlier error,
|
||||
/// or integrate with error reporting. If you do, install it before calling [`ratatui::init`] /
|
||||
/// [`ratatui::run`]. Ratatui wraps the current hook so it can restore terminal state first (for
|
||||
/// example: leaving the alternate screen and disabling raw mode) and then delegate to your hook.
|
||||
///
|
||||
/// Crossterm is cross-platform and is what most Ratatui applications use by default. Ratatui also
|
||||
/// supports other backends such as [Termion] and [Termwiz], and third-party backends can integrate
|
||||
/// by implementing [`Backend`].
|
||||
///
|
||||
/// # How it works
|
||||
///
|
||||
/// `Terminal` ties together a [`Backend`], a [`Viewport`], and a double-buffered diffing renderer.
|
||||
/// The high-level flow is described in the "Rendering Pipeline" section above; this section focuses
|
||||
/// on how that pipeline is implemented.
|
||||
///
|
||||
/// `Terminal` is generic over a [`Backend`] implementation and does not depend on a particular
|
||||
/// terminal library. It relies on the backend to:
|
||||
///
|
||||
/// - report the current screen size (used by [`Terminal::autoresize`])
|
||||
/// - draw cell updates (used by [`Terminal::flush`])
|
||||
/// - clear regions (used by [`Terminal::clear`] and [`Terminal::resize`])
|
||||
/// - move and show/hide the cursor (used by [`Terminal::try_draw`])
|
||||
/// - optionally append lines (used by inline viewports and by [`Terminal::insert_before`])
|
||||
///
|
||||
/// ## Buffers and diffing
|
||||
///
|
||||
/// The `Terminal` maintains two [`Buffer`]s sized to the current viewport. During a render pass,
|
||||
/// widgets draw into the "current" buffer via the [`Frame`] passed to your callback. At the end of
|
||||
/// the pass, [`Terminal::flush`] diffs the current buffer against the previous buffer and sends
|
||||
/// only the changed cells to the backend.
|
||||
///
|
||||
/// After flushing, [`Terminal::swap_buffers`] flips which buffer is considered "current" and resets
|
||||
/// the next buffer. This is why each render pass starts from an empty buffer: your callback is
|
||||
/// expected to fully redraw the viewport every time.
|
||||
///
|
||||
/// The [`CompletedFrame`] returned from [`Terminal::draw`] / [`Terminal::try_draw`] provides a
|
||||
/// reference to the buffer that was just rendered, which can be useful for assertions in tests.
|
||||
///
|
||||
/// ## Viewport state and resizing
|
||||
///
|
||||
/// The active [`Viewport`] controls how the viewport area is computed:
|
||||
///
|
||||
/// - Fullscreen: `Frame::area` covers the full backend size.
|
||||
/// - Fixed: `Frame::area` is the exact rectangle you provided in terminal coordinates.
|
||||
/// - Inline: `Frame::area` is a rectangle anchored to the backend cursor row.
|
||||
///
|
||||
/// For fullscreen and inline viewports, [`Terminal::autoresize`] checks the backend size during
|
||||
/// every render pass and calls [`Terminal::resize`] when it changes. Resizing updates the internal
|
||||
/// buffer sizes and clears the affected region; it also resets the previous buffer so the next draw
|
||||
/// is treated as a full redraw.
|
||||
///
|
||||
/// ## Cursor tracking
|
||||
///
|
||||
/// The cursor position requested by [`Frame::set_cursor_position`] is applied after
|
||||
/// [`Terminal::flush`] so the cursor ends up on top of the rendered UI. `Terminal` also tracks a
|
||||
/// "last known cursor position" as a best-effort record of where it last wrote, and uses that
|
||||
/// information when recomputing inline viewports on resize.
|
||||
///
|
||||
/// ## Inline-specific behavior
|
||||
///
|
||||
/// Inline viewports reserve vertical space by calling [`Backend::append_lines`]. If the cursor is
|
||||
/// close enough to the bottom edge, terminals scroll as lines are appended. Ratatui accounts for
|
||||
/// that scrolling by shifting the computed viewport origin upward so the viewport remains fully
|
||||
/// visible. On resize, Ratatui recomputes the inline origin while trying to keep the cursor at the
|
||||
/// same relative row inside the viewport.
|
||||
///
|
||||
/// When Ratatui is built with the `scrolling-regions` feature, [`Terminal::insert_before`] uses
|
||||
/// terminal scrolling regions to insert content above an inline viewport without clearing and
|
||||
/// redrawing it.
|
||||
///
|
||||
/// [Crossterm]: https://crates.io/crates/crossterm
|
||||
/// [Termion]: https://crates.io/crates/termion
|
||||
/// [Termwiz]: https://crates.io/crates/termwiz
|
||||
/// [`backend`]: crate::backend
|
||||
/// [`Backend`]: crate::backend::Backend
|
||||
/// [`Backend::flush`]: crate::backend::Backend::flush
|
||||
/// [`Buffer`]: crate::buffer::Buffer
|
||||
/// [`ratatui::DefaultTerminal`]: https://docs.rs/ratatui/latest/ratatui/type.DefaultTerminal.html
|
||||
/// [`ratatui::init`]: https://docs.rs/ratatui/latest/ratatui/fn.init.html
|
||||
/// [`ratatui::restore`]: https://docs.rs/ratatui/latest/ratatui/fn.restore.html
|
||||
/// [`ratatui::run`]: https://docs.rs/ratatui/latest/ratatui/fn.run.html
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct Terminal<B>
|
||||
where
|
||||
B: Backend,
|
||||
{
|
||||
/// The backend used to write updates to the terminal.
|
||||
///
|
||||
/// Most application code does not need to interact with the backend directly; see
|
||||
/// [`Terminal::draw`]. Accessing the backend can be useful for backend-specific testing and
|
||||
/// inspection (see [`Terminal::backend`]).
|
||||
backend: B,
|
||||
/// Double-buffered render state.
|
||||
///
|
||||
/// [`Terminal::flush`] diffs `buffers[current]` against the other buffer to compute a minimal
|
||||
/// set of updates to send to the backend.
|
||||
buffers: [Buffer; 2],
|
||||
/// Index of the "current" buffer in [`Terminal::buffers`].
|
||||
///
|
||||
/// This toggles between 0 and 1 and is updated by [`Terminal::swap_buffers`].
|
||||
current: usize,
|
||||
/// Whether Ratatui believes it has hidden the cursor.
|
||||
///
|
||||
/// This is tracked so [`Drop`] can attempt to restore cursor visibility.
|
||||
hidden_cursor: bool,
|
||||
/// The configured [`Viewport`] mode.
|
||||
///
|
||||
/// This determines how the initial viewport area is computed during construction, whether
|
||||
/// [`Terminal::autoresize`] runs, how [`Terminal::clear`] behaves, and whether operations like
|
||||
/// [`Terminal::insert_before`] have any effect.
|
||||
viewport: Viewport,
|
||||
/// The current viewport rectangle in terminal coordinates.
|
||||
///
|
||||
/// This is the area returned by [`Frame::area`] and the size of the internal buffers. It is
|
||||
/// set during construction and updated by [`Terminal::resize`]. In inline mode, calls to
|
||||
/// [`Terminal::insert_before`] can also move the viewport vertically.
|
||||
viewport_area: Rect,
|
||||
/// Last known renderable "screen" area.
|
||||
///
|
||||
/// For fullscreen and inline viewports this tracks the backend-reported terminal size. For
|
||||
/// fixed viewports, this tracks the user-provided fixed area.
|
||||
///
|
||||
/// This is used by [`Terminal::autoresize`] and is reported via [`CompletedFrame::area`].
|
||||
last_known_area: Rect,
|
||||
/// Last known cursor position in terminal coordinates.
|
||||
///
|
||||
/// This is updated when:
|
||||
///
|
||||
/// - [`Terminal::set_cursor_position`] is called directly.
|
||||
/// - [`Frame::set_cursor_position`] is used during [`Terminal::draw`].
|
||||
/// - [`Terminal::flush`] observes a diff update (used as a proxy for the "last written" cell).
|
||||
///
|
||||
/// Inline viewports use this during [`Terminal::resize`] to preserve the cursor's relative
|
||||
/// position within the viewport.
|
||||
last_known_cursor_pos: Position,
|
||||
/// Number of frames rendered so far.
|
||||
///
|
||||
/// This increments after each successful [`Terminal::draw`] / [`Terminal::try_draw`] and wraps
|
||||
/// at `usize::MAX`.
|
||||
frame_count: usize,
|
||||
}
|
||||
|
||||
/// Options to pass to [`Terminal::with_options`]
|
||||
///
|
||||
/// Most applications can use [`Terminal::new`]. Use `TerminalOptions` when you need to configure a
|
||||
/// non-default [`Viewport`] at initialization time (see [`Terminal`] for an overview).
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct TerminalOptions {
|
||||
/// Viewport used to draw to the terminal.
|
||||
///
|
||||
/// See [`Terminal`] for a higher-level overview, and [`Viewport`] for the per-variant
|
||||
/// definition.
|
||||
pub viewport: Viewport,
|
||||
}
|
||||
|
||||
impl<B> Drop for Terminal<B>
|
||||
where
|
||||
B: Backend,
|
||||
{
|
||||
fn drop(&mut self) {
|
||||
// Attempt to restore the cursor state
|
||||
if self.hidden_cursor {
|
||||
#[allow(unused_variables)]
|
||||
if let Err(err) = self.show_cursor() {
|
||||
#[cfg(feature = "std")]
|
||||
std::eprintln!("Failed to show the cursor: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
ratatui-core/src/terminal/backend.rs
Normal file
75
ratatui-core/src/terminal/backend.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::layout::Size;
|
||||
use crate::terminal::Terminal;
|
||||
|
||||
impl<B: Backend> Terminal<B> {
|
||||
/// Returns a shared reference to the backend.
|
||||
///
|
||||
/// This is primarily useful for backend-specific inspection in tests (e.g. reading
|
||||
/// [`TestBackend`]'s buffer). Most applications should interact with the terminal via
|
||||
/// [`Terminal::draw`] rather than calling backend methods directly.
|
||||
///
|
||||
/// [`TestBackend`]: crate::backend::TestBackend
|
||||
pub const fn backend(&self) -> &B {
|
||||
&self.backend
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the backend.
|
||||
///
|
||||
/// This is an advanced escape hatch. Mutating the backend directly can desynchronize Ratatui's
|
||||
/// internal buffers from what's on-screen; if you do this, you may need to call
|
||||
/// [`Terminal::clear`] to force a full redraw.
|
||||
pub const fn backend_mut(&mut self) -> &mut B {
|
||||
&mut self.backend
|
||||
}
|
||||
|
||||
/// Queries the real size of the backend.
|
||||
///
|
||||
/// This returns the size of the underlying terminal. The current renderable area depends on
|
||||
/// the configured [`Viewport`]; use [`Frame::area`] inside [`Terminal::draw`] if you want the
|
||||
/// area you should render into.
|
||||
///
|
||||
/// [`Frame::area`]: crate::terminal::Frame::area
|
||||
/// [`Terminal::draw`]: crate::terminal::Terminal::draw
|
||||
/// [`Viewport`]: crate::terminal::Viewport
|
||||
pub fn size(&self) -> Result<Size, B::Error> {
|
||||
self.backend.size()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::backend::TestBackend;
|
||||
use crate::layout::{Position, Size};
|
||||
use crate::terminal::Terminal;
|
||||
|
||||
#[test]
|
||||
fn backend_returns_shared_reference() {
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
assert_eq!(terminal.backend().cursor_position(), Position::ORIGIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_mut_allows_mutating_backend_state() {
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.backend_mut().resize(4, 3);
|
||||
|
||||
assert_eq!(terminal.size().unwrap(), Size::new(4, 3));
|
||||
terminal
|
||||
.backend()
|
||||
.assert_buffer_lines([" ", " ", " "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_queries_underlying_backend_size() {
|
||||
let mut backend = TestBackend::new(3, 2);
|
||||
backend.resize(4, 3);
|
||||
let terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
assert_eq!(terminal.size().unwrap(), Size::new(4, 3));
|
||||
}
|
||||
}
|
||||
380
ratatui-core/src/terminal/buffers.rs
Normal file
380
ratatui-core/src/terminal/buffers.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
use crate::backend::{Backend, ClearType};
|
||||
use crate::buffer::{Buffer, Cell};
|
||||
use crate::layout::{Position, Rect};
|
||||
use crate::terminal::{Frame, Terminal, Viewport};
|
||||
|
||||
impl<B: Backend> Terminal<B> {
|
||||
/// Returns a [`Frame`] for manual rendering.
|
||||
///
|
||||
/// Most applications should render via [`Terminal::draw`] / [`Terminal::try_draw`]. This method
|
||||
/// exposes the frame construction step used by [`Terminal::try_draw`] so tests and advanced
|
||||
/// callers can render without running the full draw pipeline.
|
||||
///
|
||||
/// Unlike `draw` / `try_draw`, this does not call [`Terminal::autoresize`], does not write
|
||||
/// updates to the backend, and does not apply any cursor changes. After rendering, you
|
||||
/// typically call [`Terminal::flush`], [`Terminal::swap_buffers`], and [`Backend::flush`].
|
||||
///
|
||||
/// The returned `Frame` mutably borrows the current buffer, so it must be dropped before you
|
||||
/// can call methods like [`Terminal::flush`]. The example below uses a scope to make that
|
||||
/// explicit.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # mod ratatui {
|
||||
/// # pub use ratatui_core::backend;
|
||||
/// # pub use ratatui_core::terminal::Terminal;
|
||||
/// # }
|
||||
/// use ratatui::Terminal;
|
||||
/// use ratatui::backend::{Backend, TestBackend};
|
||||
///
|
||||
/// let backend = TestBackend::new(30, 5);
|
||||
/// let mut terminal = Terminal::new(backend)?;
|
||||
/// {
|
||||
/// let mut frame = terminal.get_frame();
|
||||
/// frame.render_widget("Hello", frame.area());
|
||||
/// }
|
||||
/// // When not using `draw`, present the buffer manually:
|
||||
/// terminal.flush()?;
|
||||
/// terminal.swap_buffers();
|
||||
/// terminal.backend_mut().flush()?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
///
|
||||
/// [`Backend::flush`]: crate::backend::Backend::flush
|
||||
pub const fn get_frame(&mut self) -> Frame<'_> {
|
||||
let count = self.frame_count;
|
||||
Frame {
|
||||
cursor_position: None,
|
||||
viewport_area: self.viewport_area,
|
||||
buffer: self.current_buffer_mut(),
|
||||
count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the current buffer as a mutable reference.
|
||||
///
|
||||
/// This is the buffer that the next [`Frame`] will render into (see [`Terminal::get_frame`]).
|
||||
/// Most applications should render inside [`Terminal::draw`] and access the buffer via
|
||||
/// [`Frame::buffer_mut`] instead.
|
||||
pub const fn current_buffer_mut(&mut self) -> &mut Buffer {
|
||||
&mut self.buffers[self.current]
|
||||
}
|
||||
|
||||
/// Writes the current buffer to the backend using a diff against the previous buffer.
|
||||
///
|
||||
/// This is one of the building blocks used by [`Terminal::draw`] / [`Terminal::try_draw`]. It
|
||||
/// does not swap buffers or flush the backend; see [`Terminal::swap_buffers`] and
|
||||
/// [`Backend::flush`].
|
||||
///
|
||||
/// Implementation note: when there are updates, Ratatui records the position of the last
|
||||
/// updated cell as the "last known cursor position". Inline viewports use this to preserve the
|
||||
/// cursor's relative position within the viewport across resizes.
|
||||
///
|
||||
/// [`Backend::flush`]: crate::backend::Backend::flush
|
||||
pub fn flush(&mut self) -> Result<(), B::Error> {
|
||||
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 = Position { x: *col, y: *row };
|
||||
}
|
||||
self.backend.draw(updates.into_iter())
|
||||
}
|
||||
|
||||
/// Clears the inactive buffer and swaps it with the current buffer.
|
||||
///
|
||||
/// This is part of the standard rendering flow (see [`Terminal::try_draw`]). If you render
|
||||
/// manually using [`Terminal::get_frame`] and [`Terminal::flush`], call this afterward so the
|
||||
/// next flush can compute diffs against the correct "previous" buffer.
|
||||
pub fn swap_buffers(&mut self) {
|
||||
self.buffers[1 - self.current].reset();
|
||||
self.current = 1 - self.current;
|
||||
}
|
||||
|
||||
/// Clear the terminal and force a full redraw on the next draw call.
|
||||
///
|
||||
/// What gets cleared depends on the active [`Viewport`]:
|
||||
///
|
||||
/// - [`Viewport::Fullscreen`]: clears the entire terminal.
|
||||
/// - [`Viewport::Fixed`]: clears only the viewport region.
|
||||
/// - [`Viewport::Inline`]: clears after the viewport's origin, leaving any content above the
|
||||
/// viewport untouched.
|
||||
///
|
||||
/// Current behavior: for [`Viewport::Inline`], clearing runs from the viewport origin through
|
||||
/// the end of the visible display area, not just the viewport's rectangle. This is an
|
||||
/// implementation detail rather than a contract; do not rely on it.
|
||||
///
|
||||
/// This preserves the cursor position.
|
||||
///
|
||||
/// This also resets the "previous" buffer so the next [`Terminal::flush`] redraws the full
|
||||
/// viewport. [`Terminal::resize`] calls this internally.
|
||||
///
|
||||
/// Implementation note: this uses [`ClearType::AfterCursor`] starting at the viewport origin.
|
||||
pub fn clear(&mut self) -> Result<(), B::Error> {
|
||||
let original_cursor = self.backend.get_cursor_position()?;
|
||||
match self.viewport {
|
||||
Viewport::Fullscreen => self.backend.clear_region(ClearType::All)?,
|
||||
Viewport::Inline(_) => {
|
||||
self.backend
|
||||
.set_cursor_position(self.viewport_area.as_position())?;
|
||||
self.backend.clear_region(ClearType::AfterCursor)?;
|
||||
}
|
||||
Viewport::Fixed(_) => {
|
||||
let area = self.viewport_area;
|
||||
self.clear_fixed_viewport(area)?;
|
||||
}
|
||||
}
|
||||
self.backend.set_cursor_position(original_cursor)?;
|
||||
// Reset the back buffer to make sure the next update will redraw everything.
|
||||
self.buffers[1 - self.current].reset();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clears a fixed viewport using terminal clear commands when possible.
|
||||
///
|
||||
/// Terminal clear commands can be faster than per-cell updates.
|
||||
fn clear_fixed_viewport(&mut self, area: Rect) -> Result<(), B::Error> {
|
||||
if area.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let size = self.backend.size()?;
|
||||
let is_full_width = area.x == 0 && area.width == size.width;
|
||||
let ends_at_bottom = area.bottom() == size.height;
|
||||
if is_full_width && ends_at_bottom {
|
||||
self.backend.set_cursor_position(area.as_position())?;
|
||||
self.backend.clear_region(ClearType::AfterCursor)?;
|
||||
} else if is_full_width {
|
||||
self.clear_full_width_rows(area)?;
|
||||
} else {
|
||||
self.clear_region_cells(area)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clears full-width rows using line clear commands.
|
||||
///
|
||||
/// This avoids per-cell writes when the viewport spans the full width.
|
||||
fn clear_full_width_rows(&mut self, area: Rect) -> Result<(), B::Error> {
|
||||
for y in area.top()..area.bottom() {
|
||||
self.backend.set_cursor_position(Position { x: 0, y })?;
|
||||
self.backend.clear_region(ClearType::CurrentLine)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clears a non-full-width region by writing empty cells directly.
|
||||
///
|
||||
/// This is used when line-based clears would affect cells outside the viewport.
|
||||
fn clear_region_cells(&mut self, area: Rect) -> Result<(), B::Error> {
|
||||
let clear_cell = Cell::default();
|
||||
let updates = area.positions().map(|pos| (pos.x, pos.y, &clear_cell));
|
||||
self.backend.draw(updates)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::backend::{Backend, TestBackend};
|
||||
use crate::buffer::{Buffer, Cell};
|
||||
use crate::layout::{Position, Rect};
|
||||
use crate::terminal::{Terminal, TerminalOptions, Viewport};
|
||||
|
||||
#[test]
|
||||
fn get_frame_uses_current_viewport_and_frame_count() {
|
||||
let backend = TestBackend::new(5, 3);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
let frame = terminal.get_frame();
|
||||
assert_eq!(frame.count, 0);
|
||||
assert_eq!(frame.area().width, 5);
|
||||
assert_eq!(frame.area().height, 3);
|
||||
assert_eq!(frame.buffer.area, frame.area());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_writes_updates_and_tracks_last_updated_cell() {
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
{
|
||||
let frame = terminal.get_frame();
|
||||
frame.buffer[(1, 0)].set_symbol("x");
|
||||
}
|
||||
|
||||
terminal.flush().unwrap();
|
||||
terminal.backend().assert_buffer_lines([" x ", " "]);
|
||||
assert_eq!(terminal.last_known_cursor_pos, Position { x: 1, y: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_with_no_updates_does_not_change_last_known_cursor_pos() {
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.set_cursor_position((2, 1)).unwrap();
|
||||
|
||||
terminal.flush().unwrap();
|
||||
|
||||
assert_eq!(terminal.last_known_cursor_pos, Position { x: 2, y: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_buffers_resets_new_current_buffer() {
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.buffers[1][(0, 0)].set_symbol("x");
|
||||
terminal.swap_buffers();
|
||||
|
||||
assert_eq!(terminal.current, 1);
|
||||
assert_eq!(
|
||||
terminal.buffers[terminal.current],
|
||||
Buffer::empty(terminal.viewport_area)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_fullscreen_clears_backend_and_resets_back_buffer() {
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
{
|
||||
let frame = terminal.get_frame();
|
||||
frame.buffer[(0, 0)] = Cell::new("x");
|
||||
}
|
||||
terminal.flush().unwrap();
|
||||
terminal.backend().assert_buffer_lines(["x ", " "]);
|
||||
|
||||
terminal.buffers[1][(2, 1)] = Cell::new("y");
|
||||
terminal.clear().unwrap();
|
||||
|
||||
terminal.backend().assert_buffer_lines([" ", " "]);
|
||||
assert_eq!(
|
||||
terminal.buffers[1 - terminal.current],
|
||||
Buffer::empty(terminal.viewport_area)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_inline_clears_after_viewport_origin_and_resets_back_buffer() {
|
||||
// Inline clear is implemented as:
|
||||
// 1) move the backend cursor to the viewport origin
|
||||
// 2) call ClearType::AfterCursor once
|
||||
let mut backend = TestBackend::with_lines([
|
||||
"before 1 ",
|
||||
"before 2 ",
|
||||
"viewport 1",
|
||||
"viewport 2",
|
||||
"after 1 ",
|
||||
"after 2 ",
|
||||
]);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 2, y: 2 })
|
||||
.unwrap();
|
||||
let options = TerminalOptions {
|
||||
viewport: Viewport::Inline(2),
|
||||
};
|
||||
let mut terminal = Terminal::with_options(backend, options).unwrap();
|
||||
terminal
|
||||
.backend_mut()
|
||||
.set_cursor_position(Position { x: 2, y: 2 })
|
||||
.unwrap();
|
||||
|
||||
terminal.buffers[1][(2, 2)] = Cell::new("x");
|
||||
terminal.clear().unwrap();
|
||||
|
||||
// Inline viewport is anchored to the cursor row (y = 2) with height 2. Clear runs from
|
||||
// the viewport origin through the end of the display, including the rows after it.
|
||||
terminal.backend().assert_buffer_lines([
|
||||
"before 1 ",
|
||||
"before 2 ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
]);
|
||||
assert_eq!(
|
||||
terminal.buffers[1 - terminal.current],
|
||||
Buffer::empty(terminal.viewport_area)
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.backend().cursor_position(),
|
||||
Position { x: 2, y: 2 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_fixed_clears_viewport_rows_and_resets_back_buffer() {
|
||||
// For full-width fixed viewports that reach the terminal bottom, clear uses
|
||||
// ClearType::AfterCursor starting at the viewport origin.
|
||||
let mut backend = TestBackend::with_lines(["before 1 ", "viewport 1", "viewport 2"]);
|
||||
backend.set_cursor_position((2, 0)).unwrap();
|
||||
let options = TerminalOptions {
|
||||
viewport: Viewport::Fixed(Rect::new(0, 1, 10, 2)),
|
||||
};
|
||||
let mut terminal = Terminal::with_options(backend, options).unwrap();
|
||||
|
||||
terminal.clear().unwrap();
|
||||
|
||||
terminal
|
||||
.backend()
|
||||
.assert_buffer_lines(["before 1 ", " ", " "]);
|
||||
assert_eq!(
|
||||
terminal.buffers[1 - terminal.current],
|
||||
Buffer::empty(terminal.viewport_area)
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.backend().cursor_position(),
|
||||
Position { x: 2, y: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_fixed_full_width_not_at_bottom() {
|
||||
let mut backend =
|
||||
TestBackend::with_lines(["before 1 ", "viewport 1", "viewport 2", "after 1 "]);
|
||||
backend.set_cursor_position((1, 0)).unwrap();
|
||||
let options = TerminalOptions {
|
||||
viewport: Viewport::Fixed(Rect::new(0, 1, 10, 2)),
|
||||
};
|
||||
let mut terminal = Terminal::with_options(backend, options).unwrap();
|
||||
|
||||
terminal.clear().unwrap();
|
||||
|
||||
terminal.backend().assert_buffer_lines([
|
||||
"before 1 ",
|
||||
" ",
|
||||
" ",
|
||||
"after 1 ",
|
||||
]);
|
||||
assert_eq!(
|
||||
terminal.backend().cursor_position(),
|
||||
Position { x: 1, y: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_fixed_respects_non_full_width_viewport() {
|
||||
let mut backend =
|
||||
TestBackend::with_lines(["before 1 ", "viewport 1", "viewport 2", "after 1 "]);
|
||||
backend.set_cursor_position((3, 0)).unwrap();
|
||||
let options = TerminalOptions {
|
||||
viewport: Viewport::Fixed(Rect::new(1, 1, 3, 2)),
|
||||
};
|
||||
let mut terminal = Terminal::with_options(backend, options).unwrap();
|
||||
|
||||
terminal.clear().unwrap();
|
||||
|
||||
terminal.backend().assert_buffer_lines([
|
||||
"before 1 ",
|
||||
"v port 1",
|
||||
"v port 2",
|
||||
"after 1 ",
|
||||
]);
|
||||
assert_eq!(
|
||||
terminal.backend().cursor_position(),
|
||||
Position { x: 3, y: 0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
151
ratatui-core/src/terminal/cursor.rs
Normal file
151
ratatui-core/src/terminal/cursor.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::layout::Position;
|
||||
use crate::terminal::Terminal;
|
||||
|
||||
impl<B: Backend> Terminal<B> {
|
||||
/// Hides the cursor.
|
||||
///
|
||||
/// When using [`Terminal::draw`], prefer controlling the cursor with
|
||||
/// [`Frame::set_cursor_position`]. Mixing the APIs can lead to surprising results.
|
||||
///
|
||||
/// [`Frame::set_cursor_position`]: crate::terminal::Frame::set_cursor_position
|
||||
/// [`Terminal::draw`]: crate::terminal::Terminal::draw
|
||||
pub fn hide_cursor(&mut self) -> Result<(), B::Error> {
|
||||
self.backend.hide_cursor()?;
|
||||
self.hidden_cursor = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows the cursor.
|
||||
///
|
||||
/// When using [`Terminal::draw`], prefer controlling the cursor with
|
||||
/// [`Frame::set_cursor_position`]. Mixing the APIs can lead to surprising results.
|
||||
///
|
||||
/// [`Frame::set_cursor_position`]: crate::terminal::Frame::set_cursor_position
|
||||
/// [`Terminal::draw`]: crate::terminal::Terminal::draw
|
||||
pub fn show_cursor(&mut self) -> Result<(), B::Error> {
|
||||
self.backend.show_cursor()?;
|
||||
self.hidden_cursor = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets the current cursor position.
|
||||
///
|
||||
/// This is the position of the cursor after the last draw call and is returned as a tuple of
|
||||
/// `(x, y)` coordinates.
|
||||
#[deprecated = "use `get_cursor_position()` instead which returns `Result<Position>`"]
|
||||
pub fn get_cursor(&mut self) -> Result<(u16, u16), B::Error> {
|
||||
let Position { x, y } = self.get_cursor_position()?;
|
||||
Ok((x, y))
|
||||
}
|
||||
|
||||
/// Sets the cursor position.
|
||||
#[deprecated = "use `set_cursor_position((x, y))` instead which takes `impl Into<Position>`"]
|
||||
pub fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), B::Error> {
|
||||
self.set_cursor_position(Position { x, y })
|
||||
}
|
||||
|
||||
/// Gets the current cursor position.
|
||||
///
|
||||
/// This queries the backend for the current cursor position.
|
||||
///
|
||||
/// When using [`Terminal::draw`], prefer controlling the cursor with
|
||||
/// [`Frame::set_cursor_position`]. For direct control, see [`Terminal::set_cursor_position`].
|
||||
///
|
||||
/// [`Frame::set_cursor_position`]: crate::terminal::Frame::set_cursor_position
|
||||
/// [`Terminal::draw`]: crate::terminal::Terminal::draw
|
||||
pub fn get_cursor_position(&mut self) -> Result<Position, B::Error> {
|
||||
self.backend.get_cursor_position()
|
||||
}
|
||||
|
||||
/// Sets the cursor position.
|
||||
///
|
||||
/// This updates the backend cursor and Ratatui's internal cursor tracking. Inline viewports
|
||||
/// use that tracking when recomputing the viewport on resize.
|
||||
///
|
||||
/// When using [`Terminal::draw`], consider using [`Frame::set_cursor_position`] instead so the
|
||||
/// cursor is updated as part of the normal rendering flow.
|
||||
///
|
||||
/// [`Frame::set_cursor_position`]: crate::terminal::Frame::set_cursor_position
|
||||
/// [`Terminal::draw`]: crate::terminal::Terminal::draw
|
||||
pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), B::Error> {
|
||||
let position = position.into();
|
||||
self.backend.set_cursor_position(position)?;
|
||||
self.last_known_cursor_pos = position;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::backend::{Backend, TestBackend};
|
||||
use crate::layout::Position;
|
||||
use crate::terminal::Terminal;
|
||||
|
||||
#[test]
|
||||
fn hide_cursor_updates_terminal_state() {
|
||||
let backend = TestBackend::new(10, 5);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.hide_cursor().unwrap();
|
||||
|
||||
assert!(terminal.hidden_cursor);
|
||||
assert!(!terminal.backend().cursor_visible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_cursor_updates_terminal_state() {
|
||||
let backend = TestBackend::new(10, 5);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.hide_cursor().unwrap();
|
||||
terminal.show_cursor().unwrap();
|
||||
|
||||
assert!(!terminal.hidden_cursor);
|
||||
assert!(terminal.backend().cursor_visible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_cursor_position_updates_backend_and_tracking() {
|
||||
let backend = TestBackend::new(10, 5);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.set_cursor_position((3, 4)).unwrap();
|
||||
|
||||
assert_eq!(terminal.last_known_cursor_pos, Position { x: 3, y: 4 });
|
||||
terminal
|
||||
.backend_mut()
|
||||
.assert_cursor_position(Position { x: 3, y: 4 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_cursor_position_queries_backend() {
|
||||
let backend = TestBackend::new(10, 5);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal
|
||||
.backend_mut()
|
||||
.set_cursor_position(Position { x: 7, y: 2 })
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
terminal.get_cursor_position().unwrap(),
|
||||
Position { x: 7, y: 2 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn deprecated_cursor_wrappers_delegate_to_position_apis() {
|
||||
let backend = TestBackend::new(10, 5);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.set_cursor(4, 1).unwrap();
|
||||
|
||||
assert_eq!(terminal.get_cursor().unwrap(), (4, 1));
|
||||
assert_eq!(terminal.last_known_cursor_pos, Position { x: 4, y: 1 });
|
||||
terminal
|
||||
.backend_mut()
|
||||
.assert_cursor_position(Position { x: 4, y: 1 });
|
||||
}
|
||||
}
|
||||
236
ratatui-core/src/terminal/init.rs
Normal file
236
ratatui-core/src/terminal/init.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::buffer::Buffer;
|
||||
use crate::layout::Position;
|
||||
use crate::terminal::inline::compute_inline_size;
|
||||
use crate::terminal::{Terminal, TerminalOptions, Viewport};
|
||||
|
||||
impl<B: Backend> Terminal<B> {
|
||||
/// Creates a new [`Terminal`] with the given [`Backend`] with a full screen viewport.
|
||||
///
|
||||
/// This is a convenience for [`Terminal::with_options`] with [`Viewport::Fullscreen`].
|
||||
///
|
||||
/// After creating a terminal, call [`Terminal::draw`] (or [`Terminal::try_draw`]) in a loop to
|
||||
/// render your UI.
|
||||
///
|
||||
/// Note that unlike [`ratatui::init`], this does not install a panic hook, so it is
|
||||
/// recommended to do that manually when using this function, otherwise any panic messages will
|
||||
/// be printed to the alternate screen and the terminal may be left in an unusable state.
|
||||
///
|
||||
/// See [how to set up panic hooks](https://ratatui.rs/recipes/apps/panic-hooks/) and
|
||||
/// [`better-panic` example](https://ratatui.rs/recipes/apps/better-panic/) for more
|
||||
/// information.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # #![allow(unexpected_cfgs)]
|
||||
/// # #[cfg(feature = "crossterm")]
|
||||
/// # {
|
||||
/// use std::io::stdout;
|
||||
///
|
||||
/// use ratatui::Terminal;
|
||||
/// use ratatui::backend::CrosstermBackend;
|
||||
///
|
||||
/// let backend = CrosstermBackend::new(stdout());
|
||||
/// let _terminal = Terminal::new(backend)?;
|
||||
///
|
||||
/// // Optionally set up a panic hook to restore the terminal on panic.
|
||||
/// let old_hook = std::panic::take_hook();
|
||||
/// std::panic::set_hook(Box::new(move |info| {
|
||||
/// ratatui::restore();
|
||||
/// old_hook(info);
|
||||
/// }));
|
||||
/// # }
|
||||
/// # #[cfg(not(feature = "crossterm"))]
|
||||
/// # {
|
||||
/// # use ratatui_core::{backend::TestBackend, terminal::Terminal};
|
||||
/// # let backend = TestBackend::new(10, 10);
|
||||
/// # let _terminal = Terminal::new(backend)?;
|
||||
/// # }
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
///
|
||||
/// [`ratatui::init`]: https://docs.rs/ratatui/latest/ratatui/fn.init.html
|
||||
pub fn new(backend: B) -> Result<Self, B::Error> {
|
||||
Self::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Fullscreen,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
|
||||
///
|
||||
/// The viewport determines what area is exposed to widgets via [`Frame::area`]. See
|
||||
/// [`Viewport`] for an overview of the available modes.
|
||||
///
|
||||
/// [`Frame::area`]: crate::terminal::Frame::area
|
||||
///
|
||||
/// After creating a terminal, call [`Terminal::draw`] (or [`Terminal::try_draw`]) in a loop to
|
||||
/// render your UI.
|
||||
///
|
||||
/// Resize behavior depends on the selected viewport:
|
||||
///
|
||||
/// - [`Viewport::Fullscreen`] and [`Viewport::Inline`] are automatically resized during
|
||||
/// [`Terminal::draw`] (via [`Terminal::autoresize`]).
|
||||
/// - [`Viewport::Fixed`] is not automatically resized; call [`Terminal::resize`] if the region
|
||||
/// should change.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # #![allow(unexpected_cfgs)]
|
||||
/// # #[cfg(feature = "crossterm")]
|
||||
/// # {
|
||||
/// use std::io::stdout;
|
||||
///
|
||||
/// use ratatui::backend::CrosstermBackend;
|
||||
/// use ratatui::layout::Rect;
|
||||
/// use ratatui::{Terminal, TerminalOptions, Viewport};
|
||||
///
|
||||
/// let backend = CrosstermBackend::new(stdout());
|
||||
/// let viewport = Viewport::Fixed(Rect::new(0, 0, 10, 10));
|
||||
/// let _terminal = Terminal::with_options(backend, TerminalOptions { viewport })?;
|
||||
/// # }
|
||||
/// # #[cfg(not(feature = "crossterm"))]
|
||||
/// # {
|
||||
/// # use ratatui_core::{
|
||||
/// # backend::TestBackend,
|
||||
/// # layout::Rect,
|
||||
/// # terminal::{Terminal, TerminalOptions, Viewport},
|
||||
/// # };
|
||||
/// # let backend = TestBackend::new(10, 10);
|
||||
/// # let viewport = Viewport::Fixed(Rect::new(0, 0, 10, 10));
|
||||
/// # let _terminal = Terminal::with_options(backend, TerminalOptions { viewport })?;
|
||||
/// # }
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
///
|
||||
/// When the viewport is [`Viewport::Inline`], Ratatui anchors the viewport to the current
|
||||
/// cursor row at initialization time (always starting at column 0). Ratatui may scroll the
|
||||
/// terminal to make enough room for the requested height so the viewport stays fully visible.
|
||||
pub fn with_options(mut backend: B, options: TerminalOptions) -> Result<Self, B::Error> {
|
||||
let area = match options.viewport {
|
||||
Viewport::Fullscreen | Viewport::Inline(_) => backend.size()?.into(),
|
||||
Viewport::Fixed(area) => area,
|
||||
};
|
||||
let (viewport_area, cursor_pos) = match options.viewport {
|
||||
Viewport::Fullscreen => (area, Position::ORIGIN),
|
||||
Viewport::Inline(height) => {
|
||||
compute_inline_size(&mut backend, height, area.as_size(), 0)?
|
||||
}
|
||||
Viewport::Fixed(area) => (area, area.as_position()),
|
||||
};
|
||||
Ok(Self {
|
||||
backend,
|
||||
buffers: [Buffer::empty(viewport_area), Buffer::empty(viewport_area)],
|
||||
current: 0,
|
||||
hidden_cursor: false,
|
||||
viewport: options.viewport,
|
||||
viewport_area,
|
||||
last_known_area: area,
|
||||
last_known_cursor_pos: cursor_pos,
|
||||
frame_count: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::backend::{Backend, TestBackend};
|
||||
use crate::layout::{Position, Rect};
|
||||
use crate::terminal::{Terminal, TerminalOptions, Viewport};
|
||||
|
||||
#[test]
|
||||
fn new_fullscreen_initializes_state() {
|
||||
let backend = TestBackend::new(10, 5);
|
||||
let terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport, Viewport::Fullscreen);
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 0, 10, 5));
|
||||
assert_eq!(terminal.last_known_area, Rect::new(0, 0, 10, 5));
|
||||
assert_eq!(terminal.last_known_cursor_pos, Position::ORIGIN);
|
||||
assert_eq!(terminal.current, 0);
|
||||
assert!(!terminal.hidden_cursor);
|
||||
assert_eq!(terminal.frame_count, 0);
|
||||
assert_eq!(terminal.buffers[0].area, terminal.viewport_area);
|
||||
assert_eq!(terminal.buffers[1].area, terminal.viewport_area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_options_fixed_uses_fixed_area() {
|
||||
let backend = TestBackend::new(10, 10);
|
||||
let viewport = Viewport::Fixed(Rect::new(2, 3, 5, 4));
|
||||
let terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: viewport.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport, viewport);
|
||||
assert_eq!(terminal.viewport_area, Rect::new(2, 3, 5, 4));
|
||||
assert_eq!(terminal.last_known_area, Rect::new(2, 3, 5, 4));
|
||||
assert_eq!(terminal.last_known_cursor_pos, Position { x: 2, y: 3 });
|
||||
assert_eq!(terminal.buffers[0].area, terminal.viewport_area);
|
||||
assert_eq!(terminal.buffers[1].area, terminal.viewport_area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_options_inline_anchors_to_cursor_when_space_available() {
|
||||
let mut backend = TestBackend::new(10, 10);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 3 })
|
||||
.unwrap();
|
||||
|
||||
let terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(4),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 3, 10, 4));
|
||||
assert_eq!(terminal.last_known_cursor_pos, Position { x: 0, y: 3 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_options_inline_shifts_up_when_near_bottom() {
|
||||
let mut backend = TestBackend::new(10, 10);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 8 })
|
||||
.unwrap();
|
||||
|
||||
let terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(4),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 6, 10, 4));
|
||||
assert_eq!(terminal.last_known_cursor_pos, Position { x: 0, y: 8 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_options_inline_clamps_height_to_terminal() {
|
||||
let mut backend = TestBackend::new(10, 3);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 0 })
|
||||
.unwrap();
|
||||
|
||||
let terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(10),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 0, 10, 3));
|
||||
}
|
||||
}
|
||||
927
ratatui-core/src/terminal/inline.rs
Normal file
927
ratatui-core/src/terminal/inline.rs
Normal file
@@ -0,0 +1,927 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::buffer::{Buffer, Cell};
|
||||
use crate::layout::{Position, Rect, Size};
|
||||
use crate::terminal::{Terminal, Viewport};
|
||||
|
||||
impl<B: Backend> Terminal<B> {
|
||||
/// Insert some content before the current inline viewport. This has no effect when the
|
||||
/// viewport is not inline.
|
||||
///
|
||||
/// This is intended for inline UIs that want to print output (e.g. logs or status messages)
|
||||
/// above the UI without breaking it. See [`Viewport::Inline`] for how inline viewports are
|
||||
/// anchored.
|
||||
///
|
||||
/// The `draw_fn` closure will be called to draw into a writable `Buffer` that is `height`
|
||||
/// lines tall. The content of that `Buffer` will then be inserted before the viewport.
|
||||
///
|
||||
/// When Ratatui is built with the `scrolling-regions` feature, this can be done without
|
||||
/// clearing and redrawing the viewport. Without `scrolling-regions`, Ratatui falls back to a
|
||||
/// more portable approach and clears the viewport so the next [`Terminal::draw`] repaints it.
|
||||
///
|
||||
/// If the viewport isn't yet at the bottom of the screen, inserted lines will push it towards
|
||||
/// the bottom. Once the viewport is at the bottom of the screen, inserted lines will scroll
|
||||
/// the area of the screen above the viewport upwards.
|
||||
///
|
||||
/// Before:
|
||||
/// ```text
|
||||
/// +---------------------+
|
||||
/// | pre-existing line 1 |
|
||||
/// | pre-existing line 2 |
|
||||
/// +---------------------+
|
||||
/// | viewport |
|
||||
/// +---------------------+
|
||||
/// | |
|
||||
/// | |
|
||||
/// +---------------------+
|
||||
/// ```
|
||||
///
|
||||
/// After inserting 2 lines:
|
||||
/// ```text
|
||||
/// +---------------------+
|
||||
/// | pre-existing line 1 |
|
||||
/// | pre-existing line 2 |
|
||||
/// | inserted line 1 |
|
||||
/// | inserted line 2 |
|
||||
/// +---------------------+
|
||||
/// | viewport |
|
||||
/// +---------------------+
|
||||
/// +---------------------+
|
||||
/// ```
|
||||
///
|
||||
/// After inserting 2 more lines:
|
||||
/// ```text
|
||||
/// +---------------------+
|
||||
/// | pre-existing line 2 |
|
||||
/// | inserted line 1 |
|
||||
/// | inserted line 2 |
|
||||
/// | inserted line 3 |
|
||||
/// | inserted line 4 |
|
||||
/// +---------------------+
|
||||
/// | viewport |
|
||||
/// +---------------------+
|
||||
/// ```
|
||||
///
|
||||
/// If more lines are inserted than there is space on the screen, then the top lines will go
|
||||
/// directly into the terminal's scrollback buffer. At the limit, if the viewport takes up the
|
||||
/// whole screen, all lines will be inserted directly into the scrollback buffer.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ## Insert a single line before the current viewport
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # mod ratatui {
|
||||
/// # pub use ratatui_core::backend;
|
||||
/// # pub use ratatui_core::layout;
|
||||
/// # pub use ratatui_core::style;
|
||||
/// # pub use ratatui_core::terminal::{Terminal, TerminalOptions, Viewport};
|
||||
/// # pub use ratatui_core::text;
|
||||
/// # pub use ratatui_core::widgets;
|
||||
/// # }
|
||||
/// use ratatui::backend::{Backend, TestBackend};
|
||||
/// use ratatui::layout::Position;
|
||||
/// use ratatui::style::{Color, Style};
|
||||
/// use ratatui::text::{Line, Span};
|
||||
/// use ratatui::widgets::Widget;
|
||||
/// use ratatui::{Terminal, TerminalOptions, Viewport};
|
||||
///
|
||||
/// let mut backend = TestBackend::new(10, 10);
|
||||
/// // Simulate existing output above the inline UI.
|
||||
/// backend.set_cursor_position(Position::new(0, 3))?;
|
||||
/// let mut terminal = Terminal::with_options(
|
||||
/// backend,
|
||||
/// TerminalOptions {
|
||||
/// viewport: Viewport::Inline(4),
|
||||
/// },
|
||||
/// )?;
|
||||
///
|
||||
/// terminal.insert_before(1, |buf| {
|
||||
/// Line::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);
|
||||
/// })?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
pub fn insert_before<F>(&mut self, height: u16, draw_fn: F) -> Result<(), B::Error>
|
||||
where
|
||||
F: FnOnce(&mut Buffer),
|
||||
{
|
||||
match self.viewport {
|
||||
#[cfg(feature = "scrolling-regions")]
|
||||
Viewport::Inline(_) => self.insert_before_scrolling_regions(height, draw_fn),
|
||||
#[cfg(not(feature = "scrolling-regions"))]
|
||||
Viewport::Inline(_) => self.insert_before_no_scrolling_regions(height, draw_fn),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement `Self::insert_before` using standard backend capabilities.
|
||||
///
|
||||
/// This is the fallback implementation when the `scrolling-regions` feature is disabled. It
|
||||
/// renders the inserted lines into a temporary [`Buffer`], then draws them directly to the
|
||||
/// backend in chunks, scrolling the terminal as needed.
|
||||
///
|
||||
/// See [`Terminal::insert_before`] for the public API contract.
|
||||
#[cfg(not(feature = "scrolling-regions"))]
|
||||
fn insert_before_no_scrolling_regions(
|
||||
&mut self,
|
||||
height: u16,
|
||||
draw_fn: impl FnOnce(&mut Buffer),
|
||||
) -> Result<(), B::Error> {
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: self.viewport_area.width,
|
||||
height,
|
||||
};
|
||||
let mut buffer = Buffer::empty(area);
|
||||
draw_fn(&mut buffer);
|
||||
let mut buffer = buffer.content.as_slice();
|
||||
|
||||
// Use i32 variables so we don't have worry about overflowed u16s when adding, or about
|
||||
// negative results when subtracting.
|
||||
let mut drawn_height: i32 = self.viewport_area.top().into();
|
||||
let mut buffer_height: i32 = height.into();
|
||||
let viewport_height: i32 = self.viewport_area.height.into();
|
||||
let screen_height: i32 = self.last_known_area.height.into();
|
||||
|
||||
// The algorithm here is to loop, drawing large chunks of text (up to a screen-full at a
|
||||
// time), until the remainder of the buffer plus the viewport fits on the screen. We choose
|
||||
// this loop condition because it guarantees that we can write the remainder of the buffer
|
||||
// with just one call to Self::draw_lines().
|
||||
while buffer_height + viewport_height > screen_height {
|
||||
// We will draw as much of the buffer as possible on this iteration in order to make
|
||||
// forward progress. So we have:
|
||||
//
|
||||
// to_draw = min(buffer_height, screen_height)
|
||||
//
|
||||
// We may need to scroll the screen up to make room to draw. We choose the minimal
|
||||
// possible scroll amount so we don't end up with the viewport sitting in the middle of
|
||||
// the screen when this function is done. The amount to scroll by is:
|
||||
//
|
||||
// scroll_up = max(0, drawn_height + to_draw - screen_height)
|
||||
//
|
||||
// We want `scroll_up` to be enough so that, after drawing, we have used the whole
|
||||
// screen (drawn_height - scroll_up + to_draw = screen_height). However, there might
|
||||
// already be enough room on the screen to draw without scrolling (drawn_height +
|
||||
// to_draw <= screen_height). In this case, we just don't scroll at all.
|
||||
let to_draw = buffer_height.min(screen_height);
|
||||
let scroll_up = 0.max(drawn_height + to_draw - screen_height);
|
||||
self.scroll_up(scroll_up as u16)?;
|
||||
buffer = self.draw_lines((drawn_height - scroll_up) as u16, to_draw as u16, buffer)?;
|
||||
drawn_height += to_draw - scroll_up;
|
||||
buffer_height -= to_draw;
|
||||
}
|
||||
|
||||
// There is now enough room on the screen for the remaining buffer plus the viewport,
|
||||
// though we may still need to scroll up some of the existing text first. It's possible
|
||||
// that by this point we've drained the buffer, but we may still need to scroll up to make
|
||||
// room for the viewport.
|
||||
//
|
||||
// We want to scroll up the exact amount that will leave us completely filling the screen.
|
||||
// However, it's possible that the viewport didn't start on the bottom of the screen and
|
||||
// the added lines weren't enough to push it all the way to the bottom. We deal with this
|
||||
// case by just ensuring that our scroll amount is non-negative.
|
||||
//
|
||||
// We want:
|
||||
// screen_height = drawn_height - scroll_up + buffer_height + viewport_height
|
||||
// Or, equivalently:
|
||||
// scroll_up = drawn_height + buffer_height + viewport_height - screen_height
|
||||
let scroll_up = 0.max(drawn_height + buffer_height + viewport_height - screen_height);
|
||||
self.scroll_up(scroll_up as u16)?;
|
||||
self.draw_lines(
|
||||
(drawn_height - scroll_up) as u16,
|
||||
buffer_height as u16,
|
||||
buffer,
|
||||
)?;
|
||||
drawn_height += buffer_height - scroll_up;
|
||||
|
||||
self.set_viewport_area(Rect {
|
||||
y: drawn_height as u16,
|
||||
..self.viewport_area
|
||||
});
|
||||
|
||||
// Clear the viewport off the screen. We didn't clear earlier for two reasons. First, it
|
||||
// wasn't necessary because the buffer we drew out of isn't sparse, so it overwrote
|
||||
// whatever was on the screen. Second, there is a weird bug with tmux where a full screen
|
||||
// clear plus immediate scrolling causes some garbage to go into the scrollback.
|
||||
self.clear()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Implement `Self::insert_before` using scrolling regions.
|
||||
///
|
||||
/// If a terminal supports scrolling regions, it means that we can define a subset of rows of
|
||||
/// the screen, and then tell the terminal to scroll up or down just within that region. The
|
||||
/// rows outside of the region are not affected.
|
||||
///
|
||||
/// This function utilizes this feature to avoid having to redraw the viewport. This is done
|
||||
/// either by splitting the screen at the top of the viewport, and then creating a gap by
|
||||
/// either scrolling the viewport down, or scrolling the area above it up. The lines to insert
|
||||
/// are then drawn into the gap created.
|
||||
#[cfg(feature = "scrolling-regions")]
|
||||
fn insert_before_scrolling_regions(
|
||||
&mut self,
|
||||
mut height: u16,
|
||||
draw_fn: impl FnOnce(&mut Buffer),
|
||||
) -> Result<(), B::Error> {
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: self.viewport_area.width,
|
||||
height,
|
||||
};
|
||||
let mut buffer = Buffer::empty(area);
|
||||
draw_fn(&mut buffer);
|
||||
let mut buffer = buffer.content.as_slice();
|
||||
|
||||
// Handle the special case where the viewport takes up the whole screen.
|
||||
if self.viewport_area.height == self.last_known_area.height {
|
||||
// "Borrow" the top line of the viewport. Draw over it, then immediately scroll it into
|
||||
// scrollback. Do this repeatedly until the whole buffer has been put into scrollback.
|
||||
let mut first = true;
|
||||
while !buffer.is_empty() {
|
||||
buffer = if first {
|
||||
self.draw_lines(0, 1, buffer)?
|
||||
} else {
|
||||
self.draw_lines_over_cleared(0, 1, buffer)?
|
||||
};
|
||||
first = false;
|
||||
self.backend.scroll_region_up(0..1, 1)?;
|
||||
}
|
||||
|
||||
// Redraw the top line of the viewport.
|
||||
let width = self.viewport_area.width as usize;
|
||||
let top_line = self.buffers[1 - self.current].content[0..width].to_vec();
|
||||
self.draw_lines_over_cleared(0, 1, &top_line)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Handle the case where the viewport isn't yet at the bottom of the screen.
|
||||
{
|
||||
let viewport_top = self.viewport_area.top();
|
||||
let viewport_bottom = self.viewport_area.bottom();
|
||||
let screen_bottom = self.last_known_area.bottom();
|
||||
if viewport_bottom < screen_bottom {
|
||||
let to_draw = height.min(screen_bottom - viewport_bottom);
|
||||
self.backend
|
||||
.scroll_region_down(viewport_top..viewport_bottom + to_draw, to_draw)?;
|
||||
buffer = self.draw_lines_over_cleared(viewport_top, to_draw, buffer)?;
|
||||
self.set_viewport_area(Rect {
|
||||
y: viewport_top + to_draw,
|
||||
..self.viewport_area
|
||||
});
|
||||
height -= to_draw;
|
||||
}
|
||||
}
|
||||
|
||||
let viewport_top = self.viewport_area.top();
|
||||
while height > 0 {
|
||||
let to_draw = height.min(viewport_top);
|
||||
self.backend.scroll_region_up(0..viewport_top, to_draw)?;
|
||||
buffer = self.draw_lines_over_cleared(viewport_top - to_draw, to_draw, buffer)?;
|
||||
height -= to_draw;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw lines at the given vertical offset. The slice of cells must contain enough cells
|
||||
/// for the requested lines. A slice of the unused cells are returned.
|
||||
///
|
||||
/// This is a small internal helper used by [`Terminal::insert_before`]. It writes cells
|
||||
/// directly to the backend in terminal coordinates (not viewport coordinates).
|
||||
fn draw_lines<'a>(
|
||||
&mut self,
|
||||
y_offset: u16,
|
||||
lines_to_draw: u16,
|
||||
cells: &'a [Cell],
|
||||
) -> Result<&'a [Cell], B::Error> {
|
||||
let width: usize = self.last_known_area.width.into();
|
||||
let (to_draw, remainder) = cells.split_at(width * lines_to_draw as usize);
|
||||
if lines_to_draw > 0 {
|
||||
let iter = to_draw
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| ((i % width) as u16, y_offset + (i / width) as u16, c));
|
||||
self.backend.draw(iter)?;
|
||||
self.backend.flush()?;
|
||||
}
|
||||
Ok(remainder)
|
||||
}
|
||||
|
||||
/// Draw lines at the given vertical offset, assuming that the lines they are replacing on the
|
||||
/// screen are cleared. The slice of cells must contain enough cells for the requested lines. A
|
||||
/// slice of the unused cells are returned.
|
||||
///
|
||||
/// This is used by the `scrolling-regions` implementation of [`Terminal::insert_before`] to
|
||||
/// avoid relying on a full-screen clear while updating only part of the terminal.
|
||||
#[cfg(feature = "scrolling-regions")]
|
||||
fn draw_lines_over_cleared<'a>(
|
||||
&mut self,
|
||||
y_offset: u16,
|
||||
lines_to_draw: u16,
|
||||
cells: &'a [Cell],
|
||||
) -> Result<&'a [Cell], B::Error> {
|
||||
let width: usize = self.last_known_area.width.into();
|
||||
let (to_draw, remainder) = cells.split_at(width * lines_to_draw as usize);
|
||||
if lines_to_draw > 0 {
|
||||
let area = Rect::new(0, y_offset, width as u16, y_offset + lines_to_draw);
|
||||
let old = Buffer::empty(area);
|
||||
let new = Buffer {
|
||||
area,
|
||||
content: to_draw.to_vec(),
|
||||
};
|
||||
self.backend.draw(old.diff(&new).into_iter())?;
|
||||
self.backend.flush()?;
|
||||
}
|
||||
Ok(remainder)
|
||||
}
|
||||
|
||||
/// Scroll the whole screen up by the given number of lines.
|
||||
///
|
||||
/// This is used by [`Terminal::insert_before`] when the `scrolling-regions` feature is
|
||||
/// disabled.
|
||||
/// It scrolls by moving the cursor to the last row and calling [`Backend::append_lines`].
|
||||
#[cfg(not(feature = "scrolling-regions"))]
|
||||
fn scroll_up(&mut self, lines_to_scroll: u16) -> Result<(), B::Error> {
|
||||
if lines_to_scroll > 0 {
|
||||
self.set_cursor_position(Position::new(
|
||||
0,
|
||||
self.last_known_area.height.saturating_sub(1),
|
||||
))?;
|
||||
self.backend.append_lines(lines_to_scroll)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the on-screen area for an inline viewport.
|
||||
///
|
||||
/// This helper is used by [`Terminal::with_options`] (initialization) and [`Terminal::resize`]
|
||||
/// (after a terminal resize) to translate `Viewport::Inline(height)` into a concrete [`Rect`].
|
||||
///
|
||||
/// This returns the computed viewport area and the cursor position observed at the start of the
|
||||
/// call.
|
||||
///
|
||||
/// Inline viewports always start at column 0, span the full terminal width, and are anchored to the
|
||||
/// backend cursor row at the time of the call. The requested height is clamped to the current
|
||||
/// terminal height.
|
||||
///
|
||||
/// Ratatui reserves vertical space for the requested height by calling [`Backend::append_lines`].
|
||||
/// If the cursor is close enough to the bottom that appending would run past the last row,
|
||||
/// terminals scroll; in that case we shift the computed `y` upward by the number of rows scrolled
|
||||
/// so the viewport remains fully visible.
|
||||
///
|
||||
/// `offset_in_previous_viewport` is used by [`Terminal::resize`] to keep the cursor at the same
|
||||
/// relative row within the viewport across resizes.
|
||||
///
|
||||
/// Related viewport code lives in:
|
||||
///
|
||||
/// - [`Terminal::with_options`] (selects the viewport and computes the initial area)
|
||||
/// - [`Terminal::autoresize`] (detects backend size changes during [`Terminal::draw`])
|
||||
/// - [`Terminal::resize`] (recomputes the viewport and clears before the next draw)
|
||||
pub(crate) fn compute_inline_size<B: Backend>(
|
||||
backend: &mut B,
|
||||
height: u16,
|
||||
size: Size,
|
||||
offset_in_previous_viewport: u16,
|
||||
) -> Result<(Rect, Position), B::Error> {
|
||||
let pos = backend.get_cursor_position()?;
|
||||
let mut row = pos.y;
|
||||
|
||||
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,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::backend::{Backend, TestBackend};
|
||||
use crate::layout::{Position, Rect, Size};
|
||||
use crate::style::Style;
|
||||
use crate::terminal::inline::compute_inline_size;
|
||||
use crate::terminal::{Terminal, TerminalOptions, Viewport};
|
||||
|
||||
#[test]
|
||||
fn compute_inline_size_uses_cursor_offset_when_space_available() {
|
||||
// Diagram (terminal height = 10, requested viewport height = 4):
|
||||
//
|
||||
// Cursor at y=6, previous cursor offset within viewport = 1.
|
||||
//
|
||||
// Before (conceptually):
|
||||
// 0
|
||||
// 1
|
||||
// 2
|
||||
// 3
|
||||
// 4
|
||||
// 5 <- viewport top (expected)
|
||||
// 6 <- cursor row (observed_pos.y)
|
||||
// 7
|
||||
// 8
|
||||
// 9
|
||||
//
|
||||
// After: viewport top y = 5 (6 - 1), height = 4 => rows 5..9 (exclusive).
|
||||
let mut backend = TestBackend::new(10, 10);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 6 })
|
||||
.unwrap();
|
||||
|
||||
let (area, observed_pos) =
|
||||
compute_inline_size(&mut backend, 4, Size::new(10, 10), 1).unwrap();
|
||||
|
||||
assert_eq!(observed_pos, Position { x: 0, y: 6 });
|
||||
assert_eq!(area, Rect::new(0, 5, 10, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_inline_size_saturates_when_offset_exceeds_cursor_row() {
|
||||
// Diagram (terminal height = 10, requested viewport height = 4):
|
||||
//
|
||||
// Cursor at y=0, previous cursor offset within viewport = 5 (nonsensical but possible if
|
||||
// callers pass a stale/oversized offset).
|
||||
//
|
||||
// We saturate so the computed viewport top cannot go negative:
|
||||
// top = cursor_y.saturating_sub(offset) = 0.saturating_sub(5) = 0
|
||||
//
|
||||
// Expected viewport area:
|
||||
// y=0..4 (fully pinned to the top)
|
||||
let mut backend = TestBackend::new(10, 10);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 0 })
|
||||
.unwrap();
|
||||
|
||||
let (area, _observed_pos) =
|
||||
compute_inline_size(&mut backend, 4, Size::new(10, 10), 5).unwrap();
|
||||
|
||||
assert_eq!(area, Rect::new(0, 0, 10, 4));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "scrolling-regions"))]
|
||||
mod no_scrolling_regions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn insert_before_is_noop_for_non_inline_viewports() {
|
||||
// Diagram:
|
||||
//
|
||||
// Viewport is fullscreen (not inline), so insert_before() is a no-op.
|
||||
//
|
||||
// Screen before:
|
||||
// x..
|
||||
// ...
|
||||
//
|
||||
// Screen after:
|
||||
// x..
|
||||
// ...
|
||||
let mut terminal = Terminal::new(TestBackend::new(3, 2)).unwrap();
|
||||
{
|
||||
let frame = terminal.get_frame();
|
||||
frame.buffer[(0, 0)].set_symbol("x");
|
||||
}
|
||||
terminal.flush().unwrap();
|
||||
|
||||
let viewport_area = terminal.viewport_area;
|
||||
terminal
|
||||
.insert_before(1, |buf| {
|
||||
buf.set_string(0, 0, "zzz", Style::default());
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, viewport_area);
|
||||
terminal.backend().assert_buffer_lines(["x ", " "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_before_pushes_viewport_down_when_space_available() {
|
||||
// Diagram (screen height = 10, viewport height = 4, cursor row = 3):
|
||||
//
|
||||
// Before:
|
||||
// 0: 0000000000
|
||||
// 1: 1111111111
|
||||
// 2: 2222222222
|
||||
// 3: [viewport top] 3333333333
|
||||
// 4: 4444444444
|
||||
// 5: 5555555555
|
||||
// 6: 6666666666
|
||||
// 7: 7777777777
|
||||
// 8: 8888888888
|
||||
// 9: 9999999999
|
||||
//
|
||||
// After inserting 1 line above an inline viewport (no scrolling regions):
|
||||
// - A line is drawn at the old viewport top (y=3)
|
||||
// - The viewport moves down by 1 row (new top y=4)
|
||||
// - The viewport is cleared so it will be redrawn on the next draw()
|
||||
let mut backend = TestBackend::with_lines([
|
||||
"0000000000",
|
||||
"1111111111",
|
||||
"2222222222",
|
||||
"3333333333",
|
||||
"4444444444",
|
||||
"5555555555",
|
||||
"6666666666",
|
||||
"7777777777",
|
||||
"8888888888",
|
||||
"9999999999",
|
||||
]);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 3 })
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(4),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.insert_before(1, |buf| {
|
||||
buf.set_string(0, 0, "INSERTLINE", Style::default());
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 4, 10, 4));
|
||||
terminal.backend().assert_buffer_lines([
|
||||
"0000000000",
|
||||
"1111111111",
|
||||
"2222222222",
|
||||
"INSERTLINE",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_before_scrolls_when_viewport_is_at_bottom() {
|
||||
// Diagram (screen height = 10, viewport height = 4, cursor row = 6):
|
||||
//
|
||||
// Before:
|
||||
// 0: 0000000000
|
||||
// 1: 1111111111
|
||||
// 2: 2222222222
|
||||
// 3: 3333333333
|
||||
// 4: 4444444444
|
||||
// 5: 5555555555
|
||||
// 6: [viewport top] 6666666666
|
||||
// 7: 7777777777
|
||||
// 8: 8888888888
|
||||
// 9: 9999999999
|
||||
//
|
||||
// After inserting 2 lines:
|
||||
// - The area above the viewport scrolls up to make room
|
||||
// - Inserted lines appear immediately above the viewport
|
||||
// - The viewport is cleared so it will be redrawn on the next draw()
|
||||
let mut backend = TestBackend::with_lines([
|
||||
"0000000000",
|
||||
"1111111111",
|
||||
"2222222222",
|
||||
"3333333333",
|
||||
"4444444444",
|
||||
"5555555555",
|
||||
"6666666666",
|
||||
"7777777777",
|
||||
"8888888888",
|
||||
"9999999999",
|
||||
]);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 6 })
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(4),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.insert_before(2, |buf| {
|
||||
buf.set_string(0, 0, "INSERTED1", Style::default());
|
||||
buf.set_string(0, 1, "INSERTED2", Style::default());
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 6, 10, 4));
|
||||
terminal.backend().assert_buffer_lines([
|
||||
"2222222222",
|
||||
"3333333333",
|
||||
"4444444444",
|
||||
"5555555555",
|
||||
"INSERTED1 ",
|
||||
"INSERTED2 ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_before_then_draw_repaints_cleared_viewport() {
|
||||
// Diagram (screen height = 10, viewport height = 4, cursor row = 6):
|
||||
//
|
||||
// 1) Draw a frame into the inline viewport at the bottom:
|
||||
// 6..9: AAAAAAAAAA
|
||||
//
|
||||
// 2) Insert 2 lines above the viewport:
|
||||
// - Inserts appear at rows 4..5
|
||||
// - Viewport is cleared (so it is blank on-screen until the next draw)
|
||||
//
|
||||
// 3) Draw again:
|
||||
// 6..9: BBBBBBBBBB
|
||||
//
|
||||
// Expected final screen:
|
||||
// 4: INSERTED00
|
||||
// 5: INSERTED01
|
||||
// 6..9: BBBBBBBBBB
|
||||
let mut backend = TestBackend::new(10, 10);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 6 })
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(4),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
for y in area.top()..area.bottom() {
|
||||
frame
|
||||
.buffer
|
||||
.set_string(area.x, y, "AAAAAAAAAA", Style::default());
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.insert_before(2, |buf| {
|
||||
buf.set_string(0, 0, "INSERTED00", Style::default());
|
||||
buf.set_string(0, 1, "INSERTED01", Style::default());
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
for y in area.top()..area.bottom() {
|
||||
frame
|
||||
.buffer
|
||||
.set_string(area.x, y, "BBBBBBBBBB", Style::default());
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal.backend().assert_buffer_lines([
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
"INSERTED00",
|
||||
"INSERTED01",
|
||||
"BBBBBBBBBB",
|
||||
"BBBBBBBBBB",
|
||||
"BBBBBBBBBB",
|
||||
"BBBBBBBBBB",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "scrolling-regions")]
|
||||
mod scrolling_regions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn insert_before_moves_viewport_down_without_clearing() {
|
||||
// Diagram (screen height = 10, viewport height = 4, cursor row = 3):
|
||||
//
|
||||
// With scrolling regions enabled, we can create a gap and draw the inserted line
|
||||
// without clearing the viewport content.
|
||||
//
|
||||
// Before:
|
||||
// 2: 2222222222
|
||||
// 3: [viewport top] 3333333333
|
||||
// 4: 4444444444
|
||||
//
|
||||
// After:
|
||||
// 3: INSERTLINE
|
||||
// 4: 3333333333 (viewport content preserved)
|
||||
let mut backend = TestBackend::with_lines([
|
||||
"0000000000",
|
||||
"1111111111",
|
||||
"2222222222",
|
||||
"3333333333",
|
||||
"4444444444",
|
||||
"5555555555",
|
||||
"6666666666",
|
||||
"7777777777",
|
||||
"8888888888",
|
||||
"9999999999",
|
||||
]);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 3 })
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(4),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.insert_before(1, |buf| {
|
||||
buf.set_string(0, 0, "INSERTLINE", Style::default());
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 4, 10, 4));
|
||||
terminal.backend().assert_buffer_lines([
|
||||
"0000000000",
|
||||
"1111111111",
|
||||
"2222222222",
|
||||
"INSERTLINE",
|
||||
"3333333333",
|
||||
"4444444444",
|
||||
"5555555555",
|
||||
"6666666666",
|
||||
"8888888888",
|
||||
"9999999999",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_before_when_viewport_is_at_bottom_preserves_viewport() {
|
||||
// Diagram (screen height = 10, viewport height = 4, viewport top = 6):
|
||||
//
|
||||
// With scrolling regions enabled and the viewport already at the bottom:
|
||||
// - The region above the viewport (rows 0..6) scrolls up to make room.
|
||||
// - Inserted lines are drawn into the cleared space immediately above the viewport.
|
||||
// - The viewport itself is not cleared and stays on-screen.
|
||||
//
|
||||
// Before (after drawing V into the viewport):
|
||||
// 0: 0000000000
|
||||
// 1: 1111111111
|
||||
// 2: 2222222222
|
||||
// 3: 3333333333
|
||||
// 4: 4444444444
|
||||
// 5: 5555555555
|
||||
// 6..9: VVVVVVVVVV
|
||||
//
|
||||
// After inserting 2 lines:
|
||||
// 0..3: previous 2..5
|
||||
// 4: AAAAAAAAAA
|
||||
// 5: BBBBBBBBBB
|
||||
// 6..9: VVVVVVVVVV
|
||||
//
|
||||
// The scrolled-off lines are appended to scrollback (previous 0 and 1).
|
||||
let mut backend = TestBackend::with_lines([
|
||||
"0000000000",
|
||||
"1111111111",
|
||||
"2222222222",
|
||||
"3333333333",
|
||||
"4444444444",
|
||||
"5555555555",
|
||||
"6666666666",
|
||||
"7777777777",
|
||||
"8888888888",
|
||||
"9999999999",
|
||||
]);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 6 })
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(4),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
for y in area.top()..area.bottom() {
|
||||
frame
|
||||
.buffer
|
||||
.set_string(area.x, y, "VVVVVVVVVV", Style::default());
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.insert_before(2, |buf| {
|
||||
buf.set_string(0, 0, "AAAAAAAAAA", Style::default());
|
||||
buf.set_string(0, 1, "BBBBBBBBBB", Style::default());
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal.backend().assert_buffer_lines([
|
||||
"2222222222",
|
||||
"3333333333",
|
||||
"4444444444",
|
||||
"5555555555",
|
||||
"AAAAAAAAAA",
|
||||
"BBBBBBBBBB",
|
||||
"VVVVVVVVVV",
|
||||
"VVVVVVVVVV",
|
||||
"VVVVVVVVVV",
|
||||
"VVVVVVVVVV",
|
||||
]);
|
||||
terminal
|
||||
.backend()
|
||||
.assert_scrollback_lines(["0000000000", "1111111111"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_before_when_viewport_is_fullscreen_appends_to_scrollback() {
|
||||
// Diagram (screen height = 4, viewport height = 4):
|
||||
//
|
||||
// When the viewport takes the whole screen, there is no visible "area above" it.
|
||||
// The scrolling-regions implementation handles this by repeatedly:
|
||||
// - drawing one line over the top row
|
||||
// - immediately scrolling that row into scrollback
|
||||
//
|
||||
// The viewport content stays on-screen; inserted lines end up in scrollback.
|
||||
let mut backend = TestBackend::new(10, 4);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 0 })
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(4),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
frame
|
||||
.buffer
|
||||
.set_string(area.x, area.y, "VIEWLINE00", Style::default());
|
||||
frame
|
||||
.buffer
|
||||
.set_string(area.x, area.y + 1, "VIEWLINE01", Style::default());
|
||||
frame
|
||||
.buffer
|
||||
.set_string(area.x, area.y + 2, "VIEWLINE02", Style::default());
|
||||
frame
|
||||
.buffer
|
||||
.set_string(area.x, area.y + 3, "VIEWLINE03", Style::default());
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.insert_before(2, |buf| {
|
||||
buf.set_string(0, 0, "INSERTED00", Style::default());
|
||||
buf.set_string(0, 1, "INSERTED01", Style::default());
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal.backend().assert_buffer_lines([
|
||||
"VIEWLINE00",
|
||||
"VIEWLINE01",
|
||||
"VIEWLINE02",
|
||||
"VIEWLINE03",
|
||||
]);
|
||||
terminal
|
||||
.backend()
|
||||
.assert_scrollback_lines(["INSERTED00", "INSERTED01"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
737
ratatui-core/src/terminal/render.rs
Normal file
737
ratatui-core/src/terminal/render.rs
Normal file
@@ -0,0 +1,737 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::terminal::{CompletedFrame, Frame, Terminal};
|
||||
|
||||
impl<B: Backend> Terminal<B> {
|
||||
/// Draws a single frame to the terminal.
|
||||
///
|
||||
/// Returns a [`CompletedFrame`] if successful, otherwise a backend error (`B::Error`).
|
||||
///
|
||||
/// If the render callback passed to this method can fail, use [`try_draw`] instead.
|
||||
///
|
||||
/// Applications should call `draw` or [`try_draw`] in a loop to continuously render the
|
||||
/// terminal. These methods are the main entry points for drawing to the terminal.
|
||||
///
|
||||
/// [`try_draw`]: Terminal::try_draw
|
||||
///
|
||||
/// The [`Frame`] passed to the render callback represents the currently configured
|
||||
/// [`Viewport`] (see [`Frame::area`] and [`Terminal::with_options`]).
|
||||
///
|
||||
/// Build layout relative to the [`Rect`] returned by [`Frame::area`] rather than assuming the
|
||||
/// origin is `(0, 0)`, so the same rendering code works for fixed and inline viewports.
|
||||
///
|
||||
/// [`Frame::area`]: crate::terminal::Frame::area
|
||||
/// [`Rect`]: crate::layout::Rect
|
||||
/// [`Viewport`]: crate::terminal::Viewport
|
||||
///
|
||||
/// This method will:
|
||||
///
|
||||
/// - call [`Terminal::autoresize`] if necessary
|
||||
/// - call the render callback, passing it a [`Frame`] reference to render to
|
||||
/// - call [`Terminal::flush`] to write changes to the backend
|
||||
/// - show/hide the cursor based on [`Frame::set_cursor_position`]
|
||||
/// - call [`Terminal::swap_buffers`] to prepare for the next render pass
|
||||
/// - call [`Backend::flush`]
|
||||
/// - return a [`CompletedFrame`] with the current buffer and the area used for rendering
|
||||
///
|
||||
/// The [`CompletedFrame`] returned by this method can be useful for debugging or testing
|
||||
/// purposes, but it is often not used in regular applications.
|
||||
///
|
||||
/// The render callback should fully render the entire frame when called, including areas that
|
||||
/// are unchanged from the previous frame. This is because each frame is compared to the
|
||||
/// previous frame to determine what has changed, and only the changes are written to the
|
||||
/// terminal. If the render callback does not fully render the frame, the terminal will not be
|
||||
/// in a consistent state.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # mod ratatui {
|
||||
/// # pub use ratatui_core::backend;
|
||||
/// # pub use ratatui_core::layout;
|
||||
/// # pub use ratatui_core::terminal::{Frame, Terminal};
|
||||
/// # }
|
||||
/// use ratatui::backend::TestBackend;
|
||||
/// use ratatui::layout::Position;
|
||||
/// use ratatui::{Frame, Terminal};
|
||||
///
|
||||
/// let backend = TestBackend::new(10, 10);
|
||||
/// let mut terminal = Terminal::new(backend)?;
|
||||
///
|
||||
/// // With a closure.
|
||||
/// terminal.draw(|frame| {
|
||||
/// let area = frame.area();
|
||||
/// frame.render_widget("Hello World!", area);
|
||||
/// frame.set_cursor_position(Position { x: 0, y: 0 });
|
||||
/// })?;
|
||||
///
|
||||
/// // Or with a function.
|
||||
/// terminal.draw(render)?;
|
||||
///
|
||||
/// fn render(frame: &mut Frame<'_>) {
|
||||
/// frame.render_widget("Hello World!", frame.area());
|
||||
/// }
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
///
|
||||
/// [`Backend::flush`]: crate::backend::Backend::flush
|
||||
pub fn draw<F>(&mut self, render_callback: F) -> Result<CompletedFrame<'_>, B::Error>
|
||||
where
|
||||
F: FnOnce(&mut Frame),
|
||||
{
|
||||
self.try_draw(|frame| {
|
||||
render_callback(frame);
|
||||
Ok::<(), B::Error>(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Tries to draw a single frame to the terminal.
|
||||
///
|
||||
/// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise
|
||||
/// [`Result::Err`] containing the backend error (`B::Error`) that caused the failure.
|
||||
///
|
||||
/// This is the equivalent of [`Terminal::draw`] but the render callback is a function or
|
||||
/// closure that returns a `Result` instead of nothing.
|
||||
///
|
||||
/// Applications should call `try_draw` or [`draw`] in a loop to continuously render the
|
||||
/// terminal. These methods are the main entry points for drawing to the terminal.
|
||||
///
|
||||
/// [`draw`]: Terminal::draw
|
||||
///
|
||||
/// The [`Frame`] passed to the render callback represents the currently configured
|
||||
/// [`Viewport`] (see [`Frame::area`] and [`Terminal::with_options`]).
|
||||
///
|
||||
/// Build layout relative to the [`Rect`] returned by [`Frame::area`] rather than assuming the
|
||||
/// origin is `(0, 0)`, so the same rendering code works for fixed and inline viewports.
|
||||
///
|
||||
/// [`Frame::area`]: crate::terminal::Frame::area
|
||||
/// [`Rect`]: crate::layout::Rect
|
||||
/// [`Viewport`]: crate::terminal::Viewport
|
||||
///
|
||||
/// This method will:
|
||||
///
|
||||
/// - call [`Terminal::autoresize`] if necessary
|
||||
/// - call the render callback, passing it a [`Frame`] reference to render to
|
||||
/// - call [`Terminal::flush`] to write changes to the backend
|
||||
/// - show/hide the cursor based on [`Frame::set_cursor_position`]
|
||||
/// - call [`Terminal::swap_buffers`] to prepare for the next render pass
|
||||
/// - call [`Backend::flush`]
|
||||
/// - return a [`CompletedFrame`] with the current buffer and the area used for rendering
|
||||
///
|
||||
/// The render callback passed to `try_draw` can return any [`Result`] with an error type that
|
||||
/// can be converted into `B::Error` using the [`Into`] trait. This makes it possible to use the
|
||||
/// `?` operator to propagate errors that occur during rendering. If the render callback returns
|
||||
/// an error, the error will be returned from `try_draw` and the terminal will not be updated.
|
||||
///
|
||||
/// The [`CompletedFrame`] returned by this method can be useful for debugging or testing
|
||||
/// purposes, but it is often not used in regular applications.
|
||||
///
|
||||
/// The render callback should fully render the entire frame when called, including areas that
|
||||
/// are unchanged from the previous frame. This is because each frame is compared to the
|
||||
/// previous frame to determine what has changed, and only the changes are written to the
|
||||
/// terminal. If the render function does not fully render the frame, the terminal will not be
|
||||
/// in a consistent state.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # #![allow(unexpected_cfgs)]
|
||||
/// # #[cfg(feature = "crossterm")]
|
||||
/// # {
|
||||
/// use std::io;
|
||||
///
|
||||
/// use ratatui::backend::CrosstermBackend;
|
||||
/// use ratatui::layout::Position;
|
||||
/// use ratatui::{Frame, Terminal};
|
||||
///
|
||||
/// let backend = CrosstermBackend::new(std::io::stdout());
|
||||
/// let mut terminal = Terminal::new(backend)?;
|
||||
///
|
||||
/// // With a closure that returns `Result`.
|
||||
/// terminal.try_draw(|frame| -> io::Result<()> {
|
||||
/// let _value: u8 = "42".parse().map_err(io::Error::other)?;
|
||||
/// let area = frame.area();
|
||||
/// frame.render_widget("Hello World!", area);
|
||||
/// frame.set_cursor_position(Position { x: 0, y: 0 });
|
||||
/// Ok(())
|
||||
/// })?;
|
||||
///
|
||||
/// // Or with a function.
|
||||
/// terminal.try_draw(render)?;
|
||||
///
|
||||
/// fn render(frame: &mut Frame<'_>) -> io::Result<()> {
|
||||
/// frame.render_widget("Hello World!", frame.area());
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// # }
|
||||
/// # #[cfg(not(feature = "crossterm"))]
|
||||
/// # {
|
||||
/// # use ratatui_core::{backend::TestBackend, terminal::Terminal};
|
||||
/// # let backend = TestBackend::new(10, 10);
|
||||
/// # let mut terminal = Terminal::new(backend)?;
|
||||
/// # terminal
|
||||
/// # .try_draw(|frame| {
|
||||
/// # frame.render_widget("Hello World!", frame.area());
|
||||
/// # Ok::<(), core::convert::Infallible>(())
|
||||
/// # })
|
||||
/// # ?;
|
||||
/// # }
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
///
|
||||
/// [`Backend::flush`]: crate::backend::Backend::flush
|
||||
pub fn try_draw<F, E>(&mut self, render_callback: F) -> Result<CompletedFrame<'_>, B::Error>
|
||||
where
|
||||
F: FnOnce(&mut Frame) -> Result<(), E>,
|
||||
E: Into<B::Error>,
|
||||
{
|
||||
// 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();
|
||||
|
||||
render_callback(&mut frame).map_err(Into::into)?;
|
||||
|
||||
// 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
|
||||
// Buffer. Thus, we're taking the important data out of the Frame and dropping it.
|
||||
let cursor_position = frame.cursor_position;
|
||||
|
||||
// Apply the buffer diff to the backend (this is the terminal's "flush" step, distinct
|
||||
// from `Backend::flush` below which flushes the backend's output).
|
||||
self.flush()?;
|
||||
|
||||
match cursor_position {
|
||||
None => self.hide_cursor()?,
|
||||
Some(position) => {
|
||||
self.show_cursor()?;
|
||||
self.set_cursor_position(position)?;
|
||||
}
|
||||
}
|
||||
|
||||
self.swap_buffers();
|
||||
|
||||
// Flush any buffered backend output.
|
||||
self.backend.flush()?;
|
||||
|
||||
let completed_frame = CompletedFrame {
|
||||
buffer: &self.buffers[1 - self.current],
|
||||
area: self.last_known_area,
|
||||
count: self.frame_count,
|
||||
};
|
||||
|
||||
// increment frame count before returning from draw
|
||||
self.frame_count = self.frame_count.wrapping_add(1);
|
||||
|
||||
Ok(completed_frame)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use core::fmt;
|
||||
|
||||
use crate::backend::{Backend, ClearType, TestBackend, WindowSize};
|
||||
use crate::buffer::{Buffer, Cell};
|
||||
use crate::layout::{Position, Rect};
|
||||
use crate::terminal::{Terminal, TerminalOptions, Viewport};
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
struct TestError(&'static str);
|
||||
|
||||
impl fmt::Display for TestError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl core::error::Error for TestError {}
|
||||
|
||||
/// A thin wrapper around [`TestBackend`] with a fallible error type.
|
||||
///
|
||||
/// [`TestBackend`] uses [`core::convert::Infallible`] as its associated `Backend::Error`, which
|
||||
/// is ideal for most tests but makes it impossible to write a `try_draw` callback that returns
|
||||
/// an error (because `E: Into<B::Error>` would require converting a real error into
|
||||
/// `Infallible`). This wrapper keeps the same observable backend behavior (buffer + cursor)
|
||||
/// while allowing tests to exercise `Terminal::try_draw`'s error path.
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
struct FallibleTestBackend {
|
||||
inner: TestBackend,
|
||||
}
|
||||
|
||||
impl FallibleTestBackend {
|
||||
fn new(inner: TestBackend) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for FallibleTestBackend {
|
||||
type Error = TestError;
|
||||
|
||||
fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
|
||||
where
|
||||
I: Iterator<Item = (u16, u16, &'a crate::buffer::Cell)>,
|
||||
{
|
||||
self.inner.draw(content).map_err(|err| match err {})
|
||||
}
|
||||
|
||||
fn append_lines(&mut self, n: u16) -> Result<(), Self::Error> {
|
||||
self.inner.append_lines(n).map_err(|err| match err {})
|
||||
}
|
||||
|
||||
fn hide_cursor(&mut self) -> Result<(), Self::Error> {
|
||||
self.inner.hide_cursor().map_err(|err| match err {})
|
||||
}
|
||||
|
||||
fn show_cursor(&mut self) -> Result<(), Self::Error> {
|
||||
self.inner.show_cursor().map_err(|err| match err {})
|
||||
}
|
||||
|
||||
fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
|
||||
self.inner.get_cursor_position().map_err(|err| match err {})
|
||||
}
|
||||
|
||||
fn set_cursor_position<P: Into<Position>>(
|
||||
&mut self,
|
||||
position: P,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.inner
|
||||
.set_cursor_position(position)
|
||||
.map_err(|err| match err {})
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> Result<(), Self::Error> {
|
||||
self.inner.clear().map_err(|err| match err {})
|
||||
}
|
||||
|
||||
fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
|
||||
self.inner
|
||||
.clear_region(clear_type)
|
||||
.map_err(|err| match err {})
|
||||
}
|
||||
|
||||
fn size(&self) -> Result<crate::layout::Size, Self::Error> {
|
||||
self.inner.size().map_err(|err| match err {})
|
||||
}
|
||||
|
||||
fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
|
||||
self.inner.window_size().map_err(|err| match err {})
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<(), Self::Error> {
|
||||
self.inner.flush().map_err(|err| match err {})
|
||||
}
|
||||
|
||||
#[cfg(feature = "scrolling-regions")]
|
||||
fn scroll_region_up(
|
||||
&mut self,
|
||||
region: core::ops::Range<u16>,
|
||||
line_count: u16,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.inner
|
||||
.scroll_region_up(region, line_count)
|
||||
.map_err(|err| match err {})
|
||||
}
|
||||
|
||||
#[cfg(feature = "scrolling-regions")]
|
||||
fn scroll_region_down(
|
||||
&mut self,
|
||||
region: core::ops::Range<u16>,
|
||||
line_count: u16,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.inner
|
||||
.scroll_region_down(region, line_count)
|
||||
.map_err(|err| match err {})
|
||||
}
|
||||
}
|
||||
|
||||
/// `draw` hides the cursor when the frame does not request a cursor position.
|
||||
///
|
||||
/// This asserts the end-to-end effect on the backend (buffer contents + cursor state) as well
|
||||
/// as internal frame counting.
|
||||
#[test]
|
||||
fn draw_hides_cursor_when_frame_cursor_is_not_set() {
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.show_cursor().unwrap();
|
||||
|
||||
let completed = terminal
|
||||
.draw(|frame| {
|
||||
// Ensure the frame produces updates so `Terminal::flush` writes to the backend.
|
||||
frame.buffer_mut()[(0, 0)] = Cell::new("x");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(completed.count, 0, "first draw returns count 0");
|
||||
assert_eq!(
|
||||
completed.area,
|
||||
Rect::new(0, 0, 3, 2),
|
||||
"completed area matches terminal size in fullscreen mode"
|
||||
);
|
||||
assert_eq!(
|
||||
completed.buffer,
|
||||
&Buffer::with_lines(["x ", " "]),
|
||||
"completed buffer contains the rendered content"
|
||||
);
|
||||
|
||||
assert!(terminal.hidden_cursor);
|
||||
assert!(!terminal.backend().cursor_visible());
|
||||
assert_eq!(
|
||||
terminal.frame_count, 1,
|
||||
"successful draw increments frame_count"
|
||||
);
|
||||
}
|
||||
|
||||
/// `draw` applies the cursor requested by `Frame::set_cursor_position`.
|
||||
///
|
||||
/// The cursor is updated after rendering has been flushed, so it appears on top of the drawn
|
||||
/// UI.
|
||||
#[test]
|
||||
fn draw_shows_and_positions_cursor_when_frame_cursor_is_set() {
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.hide_cursor().unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
// The cursor is applied after the frame is flushed.
|
||||
frame.set_cursor_position(Position { x: 2, y: 1 });
|
||||
frame.buffer_mut()[(1, 0)] = Cell::new("y");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(!terminal.hidden_cursor);
|
||||
assert!(terminal.backend().cursor_visible());
|
||||
assert_eq!(
|
||||
terminal.backend().cursor_position(),
|
||||
Position { x: 2, y: 1 },
|
||||
"backend cursor is positioned after flushing"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.last_known_cursor_pos,
|
||||
Position { x: 2, y: 1 },
|
||||
"terminal cursor tracking matches the final cursor position"
|
||||
);
|
||||
}
|
||||
|
||||
/// When the render callback returns an error, `try_draw` does not update the terminal.
|
||||
///
|
||||
/// This is a characterization of the "no partial updates" behavior: backend contents and
|
||||
/// cursor state are unchanged and `frame_count` does not advance.
|
||||
#[test]
|
||||
fn try_draw_propagates_render_errors_without_updating_backend() {
|
||||
let backend = FallibleTestBackend::new(TestBackend::with_lines(["aaa", "bbb"]));
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.show_cursor().unwrap();
|
||||
|
||||
let was_hidden = terminal.hidden_cursor;
|
||||
let cursor_visible = terminal.backend().inner.cursor_visible();
|
||||
let cursor_position = terminal.backend().inner.cursor_position();
|
||||
|
||||
let result = terminal.try_draw(|_frame| Err::<(), _>(TestError("render failed")));
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
TestError("render failed"),
|
||||
"try_draw returns the render callback error"
|
||||
);
|
||||
|
||||
assert_eq!(terminal.frame_count, 0, "frame_count is unchanged on error");
|
||||
assert_eq!(
|
||||
terminal.backend().inner.buffer(),
|
||||
&Buffer::with_lines(["aaa", "bbb"]),
|
||||
"backend buffer is unchanged on error"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.hidden_cursor, was_hidden,
|
||||
"terminal cursor state is unchanged on error"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.backend().inner.cursor_visible(),
|
||||
cursor_visible,
|
||||
"backend cursor visibility is unchanged on error"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.backend().inner.cursor_position(),
|
||||
cursor_position,
|
||||
"backend cursor position is unchanged on error"
|
||||
);
|
||||
}
|
||||
|
||||
/// `draw` autoresizes fullscreen terminals and clears before rendering.
|
||||
///
|
||||
/// This simulates the backend resizing between draw calls; `draw` runs `autoresize()` first
|
||||
/// (which calls `resize()` and clears) so the frame renders into a fresh, correctly-sized
|
||||
/// region.
|
||||
#[test]
|
||||
fn draw_clears_on_fullscreen_resize_before_rendering() {
|
||||
let backend = TestBackend::with_lines(["xxx", "yyy"]);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.backend_mut().resize(4, 3);
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
// Render a marker to show we rendered after the clear.
|
||||
frame.buffer_mut()[(0, 0)] = Cell::new("x");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
terminal.viewport_area,
|
||||
Rect::new(0, 0, 4, 3),
|
||||
"viewport area tracks the resized terminal size"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.last_known_area,
|
||||
Rect::new(0, 0, 4, 3),
|
||||
"last_known_area tracks the resized terminal size"
|
||||
);
|
||||
terminal
|
||||
.backend()
|
||||
.assert_buffer_lines(["x ", " ", " "]);
|
||||
}
|
||||
|
||||
/// In fixed viewports, `Frame::area` is an absolute terminal rectangle.
|
||||
///
|
||||
/// This asserts that rendering at `frame.area().x/y` updates the backend at that absolute
|
||||
/// position.
|
||||
#[test]
|
||||
fn draw_uses_fixed_viewport_coordinates() {
|
||||
let backend = TestBackend::new(5, 3);
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Fixed(Rect::new(2, 1, 2, 1)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
assert_eq!(
|
||||
frame.area(),
|
||||
Rect::new(2, 1, 2, 1),
|
||||
"frame area matches the configured fixed viewport"
|
||||
);
|
||||
let area = frame.area();
|
||||
frame.buffer_mut()[(area.x, area.y)] = Cell::new("z");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.backend()
|
||||
.assert_buffer_lines([" ", " z ", " "]);
|
||||
}
|
||||
|
||||
/// Inline viewports render into a sub-rectangle, but `CompletedFrame::area` reports terminal
|
||||
/// size.
|
||||
///
|
||||
/// This asserts that the `CompletedFrame` returned from `draw` reports the full terminal
|
||||
/// size while its buffer is sized to the inline viewport, and that rendering uses the inline
|
||||
/// viewport's absolute origin.
|
||||
#[test]
|
||||
fn draw_inline_completed_frame_reports_terminal_size() {
|
||||
let mut inner = TestBackend::new(6, 5);
|
||||
inner.set_cursor_position((0, 2)).unwrap();
|
||||
let mut terminal = Terminal::with_options(
|
||||
inner,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(3),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let viewport_area = terminal.viewport_area;
|
||||
{
|
||||
// `CompletedFrame` borrows the terminal, so backend assertions happen after it drops.
|
||||
let completed = terminal
|
||||
.draw(|frame| {
|
||||
assert_eq!(
|
||||
frame.area(),
|
||||
viewport_area,
|
||||
"inline frame area matches the computed viewport"
|
||||
);
|
||||
frame.buffer_mut()[(viewport_area.x, viewport_area.y)] = Cell::new("i");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
completed.area,
|
||||
Rect::new(0, 0, 6, 5),
|
||||
"completed area reports the full terminal size"
|
||||
);
|
||||
assert_eq!(
|
||||
completed.buffer.area, viewport_area,
|
||||
"completed buffer is sized to the inline viewport"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(viewport_area.x, viewport_area.y)].symbol(),
|
||||
"i"
|
||||
);
|
||||
}
|
||||
|
||||
/// Inline viewports are autoresized during `draw`.
|
||||
///
|
||||
/// This asserts that when the backend reports a different terminal size, `draw` recomputes the
|
||||
/// inline viewport rectangle and renders into the new viewport area.
|
||||
#[test]
|
||||
fn draw_inline_autoresize_recomputes_viewport_on_grow() {
|
||||
let mut backend = TestBackend::new(6, 5);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 2 })
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(3),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
frame.set_cursor_position(Position {
|
||||
x: area.x,
|
||||
y: area.y.saturating_add(1),
|
||||
});
|
||||
frame.buffer_mut()[(area.x, area.y)] = Cell::new("a");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal.backend_mut().resize(8, 7);
|
||||
let new_area = Rect::new(0, 0, 8, 7);
|
||||
|
||||
let previous_viewport = terminal.viewport_area;
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
frame.buffer_mut()[(area.x, area.y)] = Cell::new("g");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
terminal.last_known_area, new_area,
|
||||
"inline last_known_area tracks the resized terminal size"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.viewport_area.width, 8,
|
||||
"inline viewport width tracks the resized terminal width"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.viewport_area.height, 3,
|
||||
"inline viewport height is capped by the configured inline height"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.viewport_area.y, previous_viewport.y,
|
||||
"inline viewport stays anchored relative to the cursor across a grow"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(terminal.viewport_area.x, terminal.viewport_area.y)]
|
||||
.symbol(),
|
||||
"g",
|
||||
"render output lands at the recomputed viewport origin"
|
||||
);
|
||||
}
|
||||
|
||||
/// Inline viewports are autoresized during `draw`.
|
||||
///
|
||||
/// This asserts that shrinking the backend terminal size causes `draw` to recompute the inline
|
||||
/// viewport origin so it stays visible, and that rendering uses the new viewport origin.
|
||||
#[test]
|
||||
fn draw_inline_autoresize_recomputes_viewport_on_shrink() {
|
||||
let mut backend = TestBackend::new(6, 6);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 4 })
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(4),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
frame.set_cursor_position(Position {
|
||||
x: area.x,
|
||||
y: area.y.saturating_add(2),
|
||||
});
|
||||
frame.buffer_mut()[(area.x, area.y)] = Cell::new("a");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal.backend_mut().resize(6, 5);
|
||||
let new_area = Rect::new(0, 0, 6, 5);
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
frame.buffer_mut()[(area.x, area.y)] = Cell::new("s");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
terminal.last_known_area, new_area,
|
||||
"inline last_known_area tracks the resized terminal size"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.viewport_area,
|
||||
Rect::new(0, 1, 6, 4),
|
||||
"inline viewport is recomputed to stay visible after a shrink"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(terminal.viewport_area.x, terminal.viewport_area.y)]
|
||||
.symbol(),
|
||||
"s",
|
||||
"render output lands at the recomputed viewport origin"
|
||||
);
|
||||
}
|
||||
|
||||
/// `CompletedFrame` is only valid until the next draw call.
|
||||
///
|
||||
/// This asserts that each `draw` returns the buffer for the frame that was just rendered
|
||||
/// and that the count increments after each successful draw.
|
||||
#[test]
|
||||
fn draw_returns_completed_frame_for_current_render_pass() {
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
{
|
||||
// `CompletedFrame` borrows the terminal, and is only valid until the next draw call.
|
||||
let first = terminal
|
||||
.draw(|frame| {
|
||||
frame.buffer_mut()[(0, 0)] = Cell::new("a");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.count, 0, "first CompletedFrame has count 0");
|
||||
assert_eq!(
|
||||
first.buffer,
|
||||
&Buffer::with_lines(["a ", " "]),
|
||||
"first frame's buffer contains the first render output"
|
||||
);
|
||||
}
|
||||
|
||||
let second = terminal
|
||||
.draw(|frame| {
|
||||
frame.buffer_mut()[(0, 0)] = Cell::new("b");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(second.count, 1, "second CompletedFrame has count 1");
|
||||
assert_eq!(
|
||||
second.buffer,
|
||||
&Buffer::with_lines(["b ", " "]),
|
||||
"second frame's buffer contains the second render output"
|
||||
);
|
||||
}
|
||||
}
|
||||
255
ratatui-core/src/terminal/resize.rs
Normal file
255
ratatui-core/src/terminal/resize.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::layout::Rect;
|
||||
use crate::terminal::inline::compute_inline_size;
|
||||
use crate::terminal::{Terminal, Viewport};
|
||||
|
||||
impl<B: Backend> Terminal<B> {
|
||||
/// Updates the Terminal so that internal buffers match the requested area.
|
||||
///
|
||||
/// This updates the buffer size used for rendering and triggers a full clear so the next
|
||||
/// [`Terminal::draw`] paints into a consistent area.
|
||||
///
|
||||
/// When the viewport is [`Viewport::Inline`], the `area` argument is treated as the new
|
||||
/// terminal size and the viewport origin is recomputed relative to the current cursor position.
|
||||
/// Ratatui attempts to keep the cursor at the same relative row within the viewport across
|
||||
/// resizes.
|
||||
///
|
||||
/// See also: [`Terminal::autoresize`] (automatic resizing during [`Terminal::draw`]).
|
||||
pub fn resize(&mut self, area: Rect) -> Result<(), B::Error> {
|
||||
let next_area = match self.viewport {
|
||||
Viewport::Inline(height) => {
|
||||
let offset_in_previous_viewport = self
|
||||
.last_known_cursor_pos
|
||||
.y
|
||||
.saturating_sub(self.viewport_area.top());
|
||||
compute_inline_size(
|
||||
&mut self.backend,
|
||||
height,
|
||||
area.as_size(),
|
||||
offset_in_previous_viewport,
|
||||
)?
|
||||
.0
|
||||
}
|
||||
Viewport::Fixed(_) | Viewport::Fullscreen => area,
|
||||
};
|
||||
self.set_viewport_area(next_area);
|
||||
self.clear()?;
|
||||
|
||||
self.last_known_area = area;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queries the backend for size and resizes if it doesn't match the previous size.
|
||||
///
|
||||
/// This is called automatically during [`Terminal::draw`] for fullscreen and inline viewports.
|
||||
/// Fixed viewports are not automatically resized.
|
||||
///
|
||||
/// If the size changed, this calls [`Terminal::resize`] (which clears the screen).
|
||||
pub fn autoresize(&mut self) -> Result<(), B::Error> {
|
||||
// fixed viewports do not get autoresized
|
||||
if matches!(self.viewport, Viewport::Fullscreen | Viewport::Inline(_)) {
|
||||
let area = self.size()?.into();
|
||||
if area != self.last_known_area {
|
||||
self.resize(area)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resize internal buffers and update the current viewport area.
|
||||
///
|
||||
/// This is an internal helper used by [`Terminal::with_options`] and [`Terminal::resize`].
|
||||
pub(crate) 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;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::backend::{Backend, TestBackend};
|
||||
use crate::buffer::Buffer;
|
||||
use crate::layout::{Position, Rect};
|
||||
use crate::terminal::{Terminal, TerminalOptions, Viewport};
|
||||
|
||||
#[test]
|
||||
fn resize_fullscreen_updates_viewport_and_buffer_areas() {
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.backend_mut().resize(4, 3);
|
||||
let new_area = Rect::new(0, 0, 4, 3);
|
||||
terminal.resize(new_area).unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, new_area);
|
||||
assert_eq!(terminal.last_known_area, new_area);
|
||||
assert_eq!(terminal.buffers[terminal.current].area, new_area);
|
||||
assert_eq!(terminal.buffers[1 - terminal.current].area, new_area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_fullscreen_triggers_clear_and_resets_back_buffer() {
|
||||
// This test is specifically about the side effects of `resize`:
|
||||
// - it calls `clear` to force a full redraw
|
||||
// - it resets the "previous" buffer
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
// Put visible content on the backend so we can tell whether a clear happened.
|
||||
{
|
||||
let frame = terminal.get_frame();
|
||||
frame.buffer[(0, 0)].set_symbol("x");
|
||||
}
|
||||
terminal.flush().unwrap();
|
||||
terminal.backend().assert_buffer_lines(["x ", " "]);
|
||||
|
||||
terminal.backend_mut().resize(4, 3);
|
||||
let new_area = Rect::new(0, 0, 4, 3);
|
||||
terminal.resize(new_area).unwrap();
|
||||
|
||||
terminal
|
||||
.backend()
|
||||
.assert_buffer_lines([" ", " ", " "]);
|
||||
assert_eq!(
|
||||
terminal.buffers[1 - terminal.current],
|
||||
Buffer::empty(new_area)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoresize_fullscreen_uses_backend_size_when_changed() {
|
||||
let backend = TestBackend::new(3, 2);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
{
|
||||
let frame = terminal.get_frame();
|
||||
frame.buffer[(0, 0)].set_symbol("x");
|
||||
}
|
||||
terminal.flush().unwrap();
|
||||
|
||||
terminal.backend_mut().resize(4, 3);
|
||||
terminal.autoresize().unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 0, 4, 3));
|
||||
assert_eq!(terminal.last_known_area, Rect::new(0, 0, 4, 3));
|
||||
terminal
|
||||
.backend()
|
||||
.assert_buffer_lines([" ", " ", " "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoresize_fixed_does_not_change_viewport() {
|
||||
let backend = TestBackend::with_lines(["xxx", "yyy"]);
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Fixed(Rect::new(1, 0, 2, 2)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal.autoresize().unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, Rect::new(1, 0, 2, 2));
|
||||
assert_eq!(terminal.last_known_area, Rect::new(1, 0, 2, 2));
|
||||
terminal.backend().assert_buffer_lines(["xxx", "yyy"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_fixed_changes_viewport_area_and_buffer_sizes() {
|
||||
let backend = TestBackend::new(5, 3);
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Fixed(Rect::new(1, 1, 2, 1)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal.resize(Rect::new(0, 0, 3, 2)).unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 0, 3, 2));
|
||||
assert_eq!(terminal.last_known_area, Rect::new(0, 0, 3, 2));
|
||||
assert_eq!(
|
||||
terminal.buffers[terminal.current].area,
|
||||
terminal.viewport_area
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.buffers[1 - terminal.current].area,
|
||||
terminal.viewport_area
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_inline_recomputes_origin_using_previous_cursor_offset() {
|
||||
let mut backend = TestBackend::new(10, 10);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 4 })
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(4),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 4, 10, 4));
|
||||
|
||||
// Characterization test:
|
||||
// This test simulates a terminal resize (increasing the terminal height) while an inline
|
||||
// viewport is active. The key behavior being exercised is that the viewport remains
|
||||
// anchored to the backend cursor row and preserves the cursor's relative offset within the
|
||||
// previous viewport.
|
||||
//
|
||||
// For inline viewports, `Terminal::resize(area)` interprets `area` as the *new terminal
|
||||
// size*, then recomputes the viewport origin based on:
|
||||
// - the backend cursor position at the time of the call
|
||||
// - the cursor offset within the *previous* viewport (`last_known_cursor_pos -
|
||||
// viewport_top`)
|
||||
//
|
||||
// This means `resize(Rect { .. })` can update `viewport_area.y` even when the passed-in
|
||||
// `area.y` is 0, because `viewport_area` is anchored to the cursor row, not the terminal
|
||||
// origin.
|
||||
terminal.last_known_cursor_pos = Position { x: 0, y: 5 };
|
||||
terminal
|
||||
.backend_mut()
|
||||
.set_cursor_position(Position { x: 0, y: 6 })
|
||||
.unwrap();
|
||||
|
||||
terminal.backend_mut().resize(10, 12);
|
||||
let new_terminal_area = Rect::new(0, 0, 10, 12);
|
||||
terminal.resize(new_terminal_area).unwrap();
|
||||
|
||||
// Previous viewport top was y=4, and last_known_cursor_pos was y=5, so the cursor offset
|
||||
// within the viewport is 1 row. At the time of resize the backend cursor is at y=6, so the
|
||||
// new viewport top becomes 6 - 1 = 5.
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 5, 10, 4));
|
||||
assert_eq!(terminal.last_known_area, new_terminal_area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_inline_clamps_height_to_terminal_height() {
|
||||
// Characterization test:
|
||||
// This test simulates a terminal resize that *reduces* the terminal height. Inline
|
||||
// viewports clamp their height to the new terminal size so the viewport remains fully
|
||||
// visible.
|
||||
let mut backend = TestBackend::new(10, 10);
|
||||
backend
|
||||
.set_cursor_position(Position { x: 0, y: 0 })
|
||||
.unwrap();
|
||||
let mut terminal = Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(10),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
terminal.backend_mut().resize(10, 3);
|
||||
terminal.resize(Rect::new(0, 0, 10, 3)).unwrap();
|
||||
|
||||
assert_eq!(terminal.viewport_area, Rect::new(0, 0, 10, 3));
|
||||
}
|
||||
}
|
||||
@@ -1,926 +0,0 @@
|
||||
use crate::backend::{Backend, ClearType};
|
||||
use crate::buffer::{Buffer, Cell};
|
||||
use crate::layout::{Position, Rect, Size};
|
||||
use crate::terminal::{CompletedFrame, Frame, TerminalOptions, Viewport};
|
||||
|
||||
/// An interface to interact and draw [`Frame`]s on the user's terminal.
|
||||
///
|
||||
/// This is the main entry point for Ratatui. It is responsible for drawing and maintaining the
|
||||
/// state of the buffers, cursor and viewport.
|
||||
///
|
||||
/// The [`Terminal`] is generic over a [`Backend`] implementation which is used to interface with
|
||||
/// the underlying terminal library. The [`Backend`] trait is implemented for three popular Rust
|
||||
/// terminal libraries: [Crossterm], [Termion] and [Termwiz]. See the [`backend`] module for more
|
||||
/// information.
|
||||
///
|
||||
/// The `Terminal` struct maintains two buffers: the current and the previous.
|
||||
/// When the widgets are drawn, the changes are accumulated in the current buffer.
|
||||
/// At the end of each draw pass, the two buffers are compared, and only the changes
|
||||
/// between these buffers are written to the terminal, avoiding any redundant operations.
|
||||
/// After flushing these changes, the buffers are swapped to prepare for the next draw cycle.
|
||||
///
|
||||
/// The terminal also has a viewport which is the area of the terminal that is currently visible to
|
||||
/// the user. It can be either fullscreen, inline or fixed. See [`Viewport`] for more information.
|
||||
///
|
||||
/// Applications should detect terminal resizes and call [`Terminal::draw`] to redraw the
|
||||
/// application with the new size. This will automatically resize the internal buffers to match the
|
||||
/// new size for inline and fullscreen viewports. Fixed viewports are not resized automatically.
|
||||
///
|
||||
/// # Initialization
|
||||
///
|
||||
/// For most applications, consider using the convenience functions `ratatui::run()`,
|
||||
/// `ratatui::init()`, and `ratatui::restore()` (available since version 0.28.1) along with the
|
||||
/// `DefaultTerminal` type alias instead of constructing `Terminal` instances manually. These
|
||||
/// functions handle the common setup and teardown tasks automatically. Manual construction
|
||||
/// using `Terminal::new()` or `Terminal::with_options()` is still supported for applications
|
||||
/// that need fine-grained control over initialization.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ## Using convenience functions (recommended for most applications)
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// // Modern approach using convenience functions
|
||||
/// ratatui::run(|terminal| {
|
||||
/// terminal.draw(|frame| {
|
||||
/// let area = frame.area();
|
||||
/// frame.render_widget(Paragraph::new("Hello World!"), area);
|
||||
/// })?;
|
||||
/// Ok(())
|
||||
/// })?;
|
||||
/// ```
|
||||
///
|
||||
/// ## Manual construction (for fine-grained control)
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use std::io::stdout;
|
||||
///
|
||||
/// use ratatui::{backend::CrosstermBackend, widgets::Paragraph, Terminal};
|
||||
///
|
||||
/// let backend = CrosstermBackend::new(stdout());
|
||||
/// let mut terminal = Terminal::new(backend)?;
|
||||
/// terminal.draw(|frame| {
|
||||
/// let area = frame.area();
|
||||
/// frame.render_widget(Paragraph::new("Hello World!"), area);
|
||||
/// })?;
|
||||
/// # std::io::Result::Ok(())
|
||||
/// ```
|
||||
///
|
||||
/// [Crossterm]: https://crates.io/crates/crossterm
|
||||
/// [Termion]: https://crates.io/crates/termion
|
||||
/// [Termwiz]: https://crates.io/crates/termwiz
|
||||
/// [`backend`]: crate::backend
|
||||
/// [`Backend`]: crate::backend::Backend
|
||||
/// [`Buffer`]: crate::buffer::Buffer
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct Terminal<B>
|
||||
where
|
||||
B: Backend,
|
||||
{
|
||||
/// The backend used to interface with the terminal
|
||||
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,
|
||||
/// Area of the viewport
|
||||
viewport_area: Rect,
|
||||
/// Last known area of the terminal. Used to detect if the internal buffers have to be resized.
|
||||
last_known_area: 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: Position,
|
||||
/// Number of frames rendered up until current time.
|
||||
frame_count: usize,
|
||||
}
|
||||
|
||||
/// Options to pass to [`Terminal::with_options`]
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub struct Options {
|
||||
/// Viewport used to draw to the terminal
|
||||
pub viewport: Viewport,
|
||||
}
|
||||
|
||||
impl<B> Drop for Terminal<B>
|
||||
where
|
||||
B: Backend,
|
||||
{
|
||||
fn drop(&mut self) {
|
||||
// Attempt to restore the cursor state
|
||||
if self.hidden_cursor {
|
||||
#[allow(unused_variables)]
|
||||
if let Err(err) = self.show_cursor() {
|
||||
#[cfg(feature = "std")]
|
||||
std::eprintln!("Failed to show the cursor: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<B> Terminal<B>
|
||||
where
|
||||
B: Backend,
|
||||
{
|
||||
/// Creates a new [`Terminal`] with the given [`Backend`] with a full screen viewport.
|
||||
///
|
||||
/// Note that unlike `ratatui::init`, this does not install a panic hook, so it is recommended
|
||||
/// to do that manually when using this function, otherwise any panic messages will be printed
|
||||
/// to the alternate screen and the terminal may be left in an unusable state.
|
||||
///
|
||||
/// See [how to set up panic hooks](https://ratatui.rs/recipes/apps/panic-hooks/) and
|
||||
/// [`better-panic` example](https://ratatui.rs/recipes/apps/better-panic/) for more
|
||||
/// information.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use std::io::stdout;
|
||||
///
|
||||
/// use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
///
|
||||
/// let backend = CrosstermBackend::new(stdout());
|
||||
/// let terminal = Terminal::new(backend)?;
|
||||
///
|
||||
/// // Optionally set up a panic hook to restore the terminal on panic.
|
||||
/// let old_hook = std::panic::take_hook();
|
||||
/// std::panic::set_hook(Box::new(move |info| {
|
||||
/// ratatui::restore();
|
||||
/// old_hook(info);
|
||||
/// }));
|
||||
/// # std::io::Result::Ok(())
|
||||
/// ```
|
||||
pub fn new(backend: B) -> Result<Self, B::Error> {
|
||||
Self::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Fullscreen,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use std::io::stdout;
|
||||
///
|
||||
/// use ratatui::{backend::CrosstermBackend, layout::Rect, Terminal, TerminalOptions, Viewport};
|
||||
///
|
||||
/// let backend = CrosstermBackend::new(stdout());
|
||||
/// let viewport = Viewport::Fixed(Rect::new(0, 0, 10, 10));
|
||||
/// let terminal = Terminal::with_options(backend, TerminalOptions { viewport })?;
|
||||
/// # std::io::Result::Ok(())
|
||||
/// ```
|
||||
pub fn with_options(mut backend: B, options: TerminalOptions) -> Result<Self, B::Error> {
|
||||
let area = match options.viewport {
|
||||
Viewport::Fullscreen | Viewport::Inline(_) => backend.size()?.into(),
|
||||
Viewport::Fixed(area) => area,
|
||||
};
|
||||
let (viewport_area, cursor_pos) = match options.viewport {
|
||||
Viewport::Fullscreen => (area, Position::ORIGIN),
|
||||
Viewport::Inline(height) => {
|
||||
compute_inline_size(&mut backend, height, area.as_size(), 0)?
|
||||
}
|
||||
Viewport::Fixed(area) => (area, area.as_position()),
|
||||
};
|
||||
Ok(Self {
|
||||
backend,
|
||||
buffers: [Buffer::empty(viewport_area), Buffer::empty(viewport_area)],
|
||||
current: 0,
|
||||
hidden_cursor: false,
|
||||
viewport: options.viewport,
|
||||
viewport_area,
|
||||
last_known_area: area,
|
||||
last_known_cursor_pos: cursor_pos,
|
||||
frame_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a Frame object which provides a consistent view into the terminal state for rendering.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// This exists to support more advanced use cases. Most cases should be fine using
|
||||
/// [`Terminal::draw`].
|
||||
///
|
||||
/// [`Terminal::get_frame`] should be used when you need direct access to the frame buffer
|
||||
/// outside of draw closure, for example:
|
||||
///
|
||||
/// - Unit testing widgets
|
||||
/// - Buffer state inspection
|
||||
/// - Cursor manipulation
|
||||
/// - Multiple rendering passes/Buffer Manipulation
|
||||
/// - Custom frame lifecycle management
|
||||
/// - Buffer exporting
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Getting the buffer and asserting on some cells after rendering a widget.
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use ratatui::{backend::TestBackend, Terminal};
|
||||
/// use ratatui::widgets::Paragraph;
|
||||
/// let backend = TestBackend::new(30, 5);
|
||||
/// let mut terminal = Terminal::new(backend).unwrap();
|
||||
/// {
|
||||
/// let mut frame = terminal.get_frame();
|
||||
/// frame.render_widget(Paragraph::new("Hello"), frame.area());
|
||||
/// }
|
||||
/// // When not using `draw`, present the buffer manually:
|
||||
/// terminal.flush().unwrap();
|
||||
/// terminal.swap_buffers();
|
||||
/// terminal.backend_mut().flush().unwrap();
|
||||
/// ```
|
||||
pub const fn get_frame(&mut self) -> Frame<'_> {
|
||||
let count = self.frame_count;
|
||||
Frame {
|
||||
cursor_position: None,
|
||||
viewport_area: self.viewport_area,
|
||||
buffer: self.current_buffer_mut(),
|
||||
count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the current buffer as a mutable reference.
|
||||
pub const fn current_buffer_mut(&mut self) -> &mut Buffer {
|
||||
&mut self.buffers[self.current]
|
||||
}
|
||||
|
||||
/// Gets the backend
|
||||
pub const fn backend(&self) -> &B {
|
||||
&self.backend
|
||||
}
|
||||
|
||||
/// Gets the backend as a mutable reference
|
||||
pub const 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) -> Result<(), B::Error> {
|
||||
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 = Position { x: *col, y: *row };
|
||||
}
|
||||
self.backend.draw(updates.into_iter())
|
||||
}
|
||||
|
||||
/// Updates the Terminal so that internal buffers match the requested area.
|
||||
///
|
||||
/// Requested area will be saved to remain consistent when rendering. This leads to a full clear
|
||||
/// of the screen.
|
||||
pub fn resize(&mut self, area: Rect) -> Result<(), B::Error> {
|
||||
let next_area = match self.viewport {
|
||||
Viewport::Inline(height) => {
|
||||
let offset_in_previous_viewport = self
|
||||
.last_known_cursor_pos
|
||||
.y
|
||||
.saturating_sub(self.viewport_area.top());
|
||||
compute_inline_size(
|
||||
&mut self.backend,
|
||||
height,
|
||||
area.as_size(),
|
||||
offset_in_previous_viewport,
|
||||
)?
|
||||
.0
|
||||
}
|
||||
Viewport::Fixed(_) | Viewport::Fullscreen => area,
|
||||
};
|
||||
self.set_viewport_area(next_area);
|
||||
self.clear()?;
|
||||
|
||||
self.last_known_area = area;
|
||||
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) -> Result<(), B::Error> {
|
||||
// fixed viewports do not get autoresized
|
||||
if matches!(self.viewport, Viewport::Fullscreen | Viewport::Inline(_)) {
|
||||
let area = self.size()?.into();
|
||||
if area != self.last_known_area {
|
||||
self.resize(area)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draws a single frame to the terminal.
|
||||
///
|
||||
/// Returns a [`CompletedFrame`] if successful, otherwise a [`std::io::Error`].
|
||||
///
|
||||
/// If the render callback passed to this method can fail, use [`try_draw`] instead.
|
||||
///
|
||||
/// Applications should call `draw` or [`try_draw`] in a loop to continuously render the
|
||||
/// terminal. These methods are the main entry points for drawing to the terminal.
|
||||
///
|
||||
/// [`try_draw`]: Terminal::try_draw
|
||||
///
|
||||
/// This method will:
|
||||
///
|
||||
/// - autoresize the terminal if necessary
|
||||
/// - call the render callback, passing it a [`Frame`] reference to render to
|
||||
/// - flush the current internal state by copying the current buffer to the backend
|
||||
/// - move the cursor to the last known position if it was set during the rendering closure
|
||||
/// - return a [`CompletedFrame`] with the current buffer and the area of the terminal
|
||||
///
|
||||
/// The [`CompletedFrame`] returned by this method can be useful for debugging or testing
|
||||
/// purposes, but it is often not used in regular applications.
|
||||
///
|
||||
/// The render callback should fully render the entire frame when called, including areas that
|
||||
/// are unchanged from the previous frame. This is because each frame is compared to the
|
||||
/// previous frame to determine what has changed, and only the changes are written to the
|
||||
/// terminal. If the render callback does not fully render the frame, the terminal will not be
|
||||
/// in a consistent state.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// # let backend = ratatui::backend::TestBackend::new(10, 10);
|
||||
/// # let mut terminal = ratatui::Terminal::new(backend)?;
|
||||
/// use ratatui::{layout::Position, widgets::Paragraph};
|
||||
///
|
||||
/// // with a closure
|
||||
/// terminal.draw(|frame| {
|
||||
/// let area = frame.area();
|
||||
/// frame.render_widget(Paragraph::new("Hello World!"), area);
|
||||
/// frame.set_cursor_position(Position { x: 0, y: 0 });
|
||||
/// })?;
|
||||
///
|
||||
/// // or with a function
|
||||
/// terminal.draw(render)?;
|
||||
///
|
||||
/// fn render(frame: &mut ratatui::Frame) {
|
||||
/// frame.render_widget(Paragraph::new("Hello World!"), frame.area());
|
||||
/// }
|
||||
/// # std::io::Result::Ok(())
|
||||
/// ```
|
||||
pub fn draw<F>(&mut self, render_callback: F) -> Result<CompletedFrame<'_>, B::Error>
|
||||
where
|
||||
F: FnOnce(&mut Frame),
|
||||
{
|
||||
self.try_draw(|frame| {
|
||||
render_callback(frame);
|
||||
Ok::<(), B::Error>(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Tries to draw a single frame to the terminal.
|
||||
///
|
||||
/// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise
|
||||
/// [`Result::Err`] containing the [`std::io::Error`] that caused the failure.
|
||||
///
|
||||
/// This is the equivalent of [`Terminal::draw`] but the render callback is a function or
|
||||
/// closure that returns a `Result` instead of nothing.
|
||||
///
|
||||
/// Applications should call `try_draw` or [`draw`] in a loop to continuously render the
|
||||
/// terminal. These methods are the main entry points for drawing to the terminal.
|
||||
///
|
||||
/// [`draw`]: Terminal::draw
|
||||
///
|
||||
/// This method will:
|
||||
///
|
||||
/// - autoresize the terminal if necessary
|
||||
/// - call the render callback, passing it a [`Frame`] reference to render to
|
||||
/// - flush the current internal state by copying the current buffer to the backend
|
||||
/// - move the cursor to the last known position if it was set during the rendering closure
|
||||
/// - return a [`CompletedFrame`] with the current buffer and the area of the terminal
|
||||
///
|
||||
/// The render callback passed to `try_draw` can return any [`Result`] with an error type that
|
||||
/// can be converted into an [`std::io::Error`] using the [`Into`] trait. This makes it possible
|
||||
/// to use the `?` operator to propagate errors that occur during rendering. If the render
|
||||
/// callback returns an error, the error will be returned from `try_draw` as an
|
||||
/// [`std::io::Error`] and the terminal will not be updated.
|
||||
///
|
||||
/// The [`CompletedFrame`] returned by this method can be useful for debugging or testing
|
||||
/// purposes, but it is often not used in regular applications.
|
||||
///
|
||||
/// The render callback should fully render the entire frame when called, including areas that
|
||||
/// are unchanged from the previous frame. This is because each frame is compared to the
|
||||
/// previous frame to determine what has changed, and only the changes are written to the
|
||||
/// terminal. If the render function does not fully render the frame, the terminal will not be
|
||||
/// in a consistent state.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use ratatui::layout::Position;;
|
||||
/// # let backend = ratatui::backend::TestBackend::new(10, 10);
|
||||
/// # let mut terminal = ratatui::Terminal::new(backend)?;
|
||||
/// use std::io;
|
||||
///
|
||||
/// use ratatui::widgets::Paragraph;
|
||||
///
|
||||
/// // with a closure
|
||||
/// terminal.try_draw(|frame| {
|
||||
/// let value: u8 = "not a number".parse().map_err(io::Error::other)?;
|
||||
/// let area = frame.area();
|
||||
/// frame.render_widget(Paragraph::new("Hello World!"), area);
|
||||
/// frame.set_cursor_position(Position { x: 0, y: 0 });
|
||||
/// io::Result::Ok(())
|
||||
/// })?;
|
||||
///
|
||||
/// // or with a function
|
||||
/// terminal.try_draw(render)?;
|
||||
///
|
||||
/// fn render(frame: &mut ratatui::Frame) -> io::Result<()> {
|
||||
/// let value: u8 = "not a number".parse().map_err(io::Error::other)?;
|
||||
/// frame.render_widget(Paragraph::new("Hello World!"), frame.area());
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// # io::Result::Ok(())
|
||||
/// ```
|
||||
pub fn try_draw<F, E>(&mut self, render_callback: F) -> Result<CompletedFrame<'_>, B::Error>
|
||||
where
|
||||
F: FnOnce(&mut Frame) -> Result<(), E>,
|
||||
E: Into<B::Error>,
|
||||
{
|
||||
// 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();
|
||||
|
||||
render_callback(&mut frame).map_err(Into::into)?;
|
||||
|
||||
// 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
|
||||
// Buffer. 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(position) => {
|
||||
self.show_cursor()?;
|
||||
self.set_cursor_position(position)?;
|
||||
}
|
||||
}
|
||||
|
||||
self.swap_buffers();
|
||||
|
||||
// Flush
|
||||
self.backend.flush()?;
|
||||
|
||||
let completed_frame = CompletedFrame {
|
||||
buffer: &self.buffers[1 - self.current],
|
||||
area: self.last_known_area,
|
||||
count: self.frame_count,
|
||||
};
|
||||
|
||||
// increment frame count before returning from draw
|
||||
self.frame_count = self.frame_count.wrapping_add(1);
|
||||
|
||||
Ok(completed_frame)
|
||||
}
|
||||
|
||||
/// Hides the cursor.
|
||||
pub fn hide_cursor(&mut self) -> Result<(), B::Error> {
|
||||
self.backend.hide_cursor()?;
|
||||
self.hidden_cursor = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows the cursor.
|
||||
pub fn show_cursor(&mut self) -> Result<(), B::Error> {
|
||||
self.backend.show_cursor()?;
|
||||
self.hidden_cursor = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets the current cursor position.
|
||||
///
|
||||
/// This is the position of the cursor after the last draw call and is returned as a tuple of
|
||||
/// `(x, y)` coordinates.
|
||||
#[deprecated = "use `get_cursor_position()` instead which returns `Result<Position>`"]
|
||||
pub fn get_cursor(&mut self) -> Result<(u16, u16), B::Error> {
|
||||
let Position { x, y } = self.get_cursor_position()?;
|
||||
Ok((x, y))
|
||||
}
|
||||
|
||||
/// Sets the cursor position.
|
||||
#[deprecated = "use `set_cursor_position((x, y))` instead which takes `impl Into<Position>`"]
|
||||
pub fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), B::Error> {
|
||||
self.set_cursor_position(Position { x, y })
|
||||
}
|
||||
|
||||
/// Gets the current cursor position.
|
||||
///
|
||||
/// This is the position of the cursor after the last draw call.
|
||||
pub fn get_cursor_position(&mut self) -> Result<Position, B::Error> {
|
||||
self.backend.get_cursor_position()
|
||||
}
|
||||
|
||||
/// Sets the cursor position.
|
||||
pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), B::Error> {
|
||||
let position = position.into();
|
||||
self.backend.set_cursor_position(position)?;
|
||||
self.last_known_cursor_pos = position;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the terminal and force a full redraw on the next draw call.
|
||||
pub fn clear(&mut self) -> Result<(), B::Error> {
|
||||
match self.viewport {
|
||||
Viewport::Fullscreen => self.backend.clear_region(ClearType::All)?,
|
||||
Viewport::Inline(_) => {
|
||||
self.backend
|
||||
.set_cursor_position(self.viewport_area.as_position())?;
|
||||
self.backend.clear_region(ClearType::AfterCursor)?;
|
||||
}
|
||||
Viewport::Fixed(_) => {
|
||||
let area = self.viewport_area;
|
||||
for y in area.top()..area.bottom() {
|
||||
self.backend.set_cursor_position(Position { x: 0, y })?;
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Clears the inactive buffer and swaps it with the current buffer
|
||||
pub fn swap_buffers(&mut self) {
|
||||
self.buffers[1 - self.current].reset();
|
||||
self.current = 1 - self.current;
|
||||
}
|
||||
|
||||
/// Queries the real size of the backend.
|
||||
pub fn size(&self) -> Result<Size, B::Error> {
|
||||
self.backend.size()
|
||||
}
|
||||
|
||||
/// Insert some content before the current inline viewport. This has no effect when the
|
||||
/// viewport is not inline.
|
||||
///
|
||||
/// The `draw_fn` closure will be called to draw into a writable `Buffer` that is `height`
|
||||
/// lines tall. The content of that `Buffer` will then be inserted before the viewport.
|
||||
///
|
||||
/// If the viewport isn't yet at the bottom of the screen, inserted lines will push it towards
|
||||
/// the bottom. Once the viewport is at the bottom of the screen, inserted lines will scroll
|
||||
/// the area of the screen above the viewport upwards.
|
||||
///
|
||||
/// Before:
|
||||
/// ```ignore
|
||||
/// +---------------------+
|
||||
/// | pre-existing line 1 |
|
||||
/// | pre-existing line 2 |
|
||||
/// +---------------------+
|
||||
/// | viewport |
|
||||
/// +---------------------+
|
||||
/// | |
|
||||
/// | |
|
||||
/// +---------------------+
|
||||
/// ```
|
||||
///
|
||||
/// After inserting 2 lines:
|
||||
/// ```ignore
|
||||
/// +---------------------+
|
||||
/// | pre-existing line 1 |
|
||||
/// | pre-existing line 2 |
|
||||
/// | inserted line 1 |
|
||||
/// | inserted line 2 |
|
||||
/// +---------------------+
|
||||
/// | viewport |
|
||||
/// +---------------------+
|
||||
/// +---------------------+
|
||||
/// ```
|
||||
///
|
||||
/// After inserting 2 more lines:
|
||||
/// ```ignore
|
||||
/// +---------------------+
|
||||
/// | pre-existing line 2 |
|
||||
/// | inserted line 1 |
|
||||
/// | inserted line 2 |
|
||||
/// | inserted line 3 |
|
||||
/// | inserted line 4 |
|
||||
/// +---------------------+
|
||||
/// | viewport |
|
||||
/// +---------------------+
|
||||
/// ```
|
||||
///
|
||||
/// If more lines are inserted than there is space on the screen, then the top lines will go
|
||||
/// directly into the terminal's scrollback buffer. At the limit, if the viewport takes up the
|
||||
/// whole screen, all lines will be inserted directly into the scrollback buffer.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ## Insert a single line before the current viewport
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use ratatui::{
|
||||
/// backend::TestBackend,
|
||||
/// style::{Color, Style},
|
||||
/// text::{Line, Span},
|
||||
/// widgets::{Paragraph, Widget},
|
||||
/// Terminal,
|
||||
/// };
|
||||
/// # let backend = TestBackend::new(10, 10);
|
||||
/// # let mut terminal = Terminal::new(backend).unwrap();
|
||||
/// terminal.insert_before(1, |buf| {
|
||||
/// Paragraph::new(Line::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) -> Result<(), B::Error>
|
||||
where
|
||||
F: FnOnce(&mut Buffer),
|
||||
{
|
||||
match self.viewport {
|
||||
#[cfg(feature = "scrolling-regions")]
|
||||
Viewport::Inline(_) => self.insert_before_scrolling_regions(height, draw_fn),
|
||||
#[cfg(not(feature = "scrolling-regions"))]
|
||||
Viewport::Inline(_) => self.insert_before_no_scrolling_regions(height, draw_fn),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement `Self::insert_before` using standard backend capabilities.
|
||||
#[cfg(not(feature = "scrolling-regions"))]
|
||||
fn insert_before_no_scrolling_regions(
|
||||
&mut self,
|
||||
height: u16,
|
||||
draw_fn: impl FnOnce(&mut Buffer),
|
||||
) -> Result<(), B::Error> {
|
||||
// The approach of this function is to first render all of the lines to insert into a
|
||||
// temporary buffer, and then to loop drawing chunks from the buffer to the screen. drawing
|
||||
// this buffer onto the screen.
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: self.viewport_area.width,
|
||||
height,
|
||||
};
|
||||
let mut buffer = Buffer::empty(area);
|
||||
draw_fn(&mut buffer);
|
||||
let mut buffer = buffer.content.as_slice();
|
||||
|
||||
// Use i32 variables so we don't have worry about overflowed u16s when adding, or about
|
||||
// negative results when subtracting.
|
||||
let mut drawn_height: i32 = self.viewport_area.top().into();
|
||||
let mut buffer_height: i32 = height.into();
|
||||
let viewport_height: i32 = self.viewport_area.height.into();
|
||||
let screen_height: i32 = self.last_known_area.height.into();
|
||||
|
||||
// The algorithm here is to loop, drawing large chunks of text (up to a screen-full at a
|
||||
// time), until the remainder of the buffer plus the viewport fits on the screen. We choose
|
||||
// this loop condition because it guarantees that we can write the remainder of the buffer
|
||||
// with just one call to Self::draw_lines().
|
||||
while buffer_height + viewport_height > screen_height {
|
||||
// We will draw as much of the buffer as possible on this iteration in order to make
|
||||
// forward progress. So we have:
|
||||
//
|
||||
// to_draw = min(buffer_height, screen_height)
|
||||
//
|
||||
// We may need to scroll the screen up to make room to draw. We choose the minimal
|
||||
// possible scroll amount so we don't end up with the viewport sitting in the middle of
|
||||
// the screen when this function is done. The amount to scroll by is:
|
||||
//
|
||||
// scroll_up = max(0, drawn_height + to_draw - screen_height)
|
||||
//
|
||||
// We want `scroll_up` to be enough so that, after drawing, we have used the whole
|
||||
// screen (drawn_height - scroll_up + to_draw = screen_height). However, there might
|
||||
// already be enough room on the screen to draw without scrolling (drawn_height +
|
||||
// to_draw <= screen_height). In this case, we just don't scroll at all.
|
||||
let to_draw = buffer_height.min(screen_height);
|
||||
let scroll_up = 0.max(drawn_height + to_draw - screen_height);
|
||||
self.scroll_up(scroll_up as u16)?;
|
||||
buffer = self.draw_lines((drawn_height - scroll_up) as u16, to_draw as u16, buffer)?;
|
||||
drawn_height += to_draw - scroll_up;
|
||||
buffer_height -= to_draw;
|
||||
}
|
||||
|
||||
// There is now enough room on the screen for the remaining buffer plus the viewport,
|
||||
// though we may still need to scroll up some of the existing text first. It's possible
|
||||
// that by this point we've drained the buffer, but we may still need to scroll up to make
|
||||
// room for the viewport.
|
||||
//
|
||||
// We want to scroll up the exact amount that will leave us completely filling the screen.
|
||||
// However, it's possible that the viewport didn't start on the bottom of the screen and
|
||||
// the added lines weren't enough to push it all the way to the bottom. We deal with this
|
||||
// case by just ensuring that our scroll amount is non-negative.
|
||||
//
|
||||
// We want:
|
||||
// screen_height = drawn_height - scroll_up + buffer_height + viewport_height
|
||||
// Or, equivalently:
|
||||
// scroll_up = drawn_height + buffer_height + viewport_height - screen_height
|
||||
let scroll_up = 0.max(drawn_height + buffer_height + viewport_height - screen_height);
|
||||
self.scroll_up(scroll_up as u16)?;
|
||||
self.draw_lines(
|
||||
(drawn_height - scroll_up) as u16,
|
||||
buffer_height as u16,
|
||||
buffer,
|
||||
)?;
|
||||
drawn_height += buffer_height - scroll_up;
|
||||
|
||||
self.set_viewport_area(Rect {
|
||||
y: drawn_height as u16,
|
||||
..self.viewport_area
|
||||
});
|
||||
|
||||
// Clear the viewport off the screen. We didn't clear earlier for two reasons. First, it
|
||||
// wasn't necessary because the buffer we drew out of isn't sparse, so it overwrote
|
||||
// whatever was on the screen. Second, there is a weird bug with tmux where a full screen
|
||||
// clear plus immediate scrolling causes some garbage to go into the scrollback.
|
||||
self.clear()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Implement `Self::insert_before` using scrolling regions.
|
||||
///
|
||||
/// If a terminal supports scrolling regions, it means that we can define a subset of rows of
|
||||
/// the screen, and then tell the terminal to scroll up or down just within that region. The
|
||||
/// rows outside of the region are not affected.
|
||||
///
|
||||
/// This function utilizes this feature to avoid having to redraw the viewport. This is done
|
||||
/// either by splitting the screen at the top of the viewport, and then creating a gap by
|
||||
/// either scrolling the viewport down, or scrolling the area above it up. The lines to insert
|
||||
/// are then drawn into the gap created.
|
||||
#[cfg(feature = "scrolling-regions")]
|
||||
fn insert_before_scrolling_regions(
|
||||
&mut self,
|
||||
mut height: u16,
|
||||
draw_fn: impl FnOnce(&mut Buffer),
|
||||
) -> Result<(), B::Error> {
|
||||
// The approach of this function is to first render all of the lines to insert into a
|
||||
// temporary buffer, and then to loop drawing chunks from the buffer to the screen. drawing
|
||||
// this buffer onto the screen.
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: self.viewport_area.width,
|
||||
height,
|
||||
};
|
||||
let mut buffer = Buffer::empty(area);
|
||||
draw_fn(&mut buffer);
|
||||
let mut buffer = buffer.content.as_slice();
|
||||
|
||||
// Handle the special case where the viewport takes up the whole screen.
|
||||
if self.viewport_area.height == self.last_known_area.height {
|
||||
// "Borrow" the top line of the viewport. Draw over it, then immediately scroll it into
|
||||
// scrollback. Do this repeatedly until the whole buffer has been put into scrollback.
|
||||
let mut first = true;
|
||||
while !buffer.is_empty() {
|
||||
buffer = if first {
|
||||
self.draw_lines(0, 1, buffer)?
|
||||
} else {
|
||||
self.draw_lines_over_cleared(0, 1, buffer)?
|
||||
};
|
||||
first = false;
|
||||
self.backend.scroll_region_up(0..1, 1)?;
|
||||
}
|
||||
|
||||
// Redraw the top line of the viewport.
|
||||
let width = self.viewport_area.width as usize;
|
||||
let top_line = self.buffers[1 - self.current].content[0..width].to_vec();
|
||||
self.draw_lines_over_cleared(0, 1, &top_line)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Handle the case where the viewport isn't yet at the bottom of the screen.
|
||||
{
|
||||
let viewport_top = self.viewport_area.top();
|
||||
let viewport_bottom = self.viewport_area.bottom();
|
||||
let screen_bottom = self.last_known_area.bottom();
|
||||
if viewport_bottom < screen_bottom {
|
||||
let to_draw = height.min(screen_bottom - viewport_bottom);
|
||||
self.backend
|
||||
.scroll_region_down(viewport_top..viewport_bottom + to_draw, to_draw)?;
|
||||
buffer = self.draw_lines_over_cleared(viewport_top, to_draw, buffer)?;
|
||||
self.set_viewport_area(Rect {
|
||||
y: viewport_top + to_draw,
|
||||
..self.viewport_area
|
||||
});
|
||||
height -= to_draw;
|
||||
}
|
||||
}
|
||||
|
||||
let viewport_top = self.viewport_area.top();
|
||||
while height > 0 {
|
||||
let to_draw = height.min(viewport_top);
|
||||
self.backend.scroll_region_up(0..viewport_top, to_draw)?;
|
||||
buffer = self.draw_lines_over_cleared(viewport_top - to_draw, to_draw, buffer)?;
|
||||
height -= to_draw;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw lines at the given vertical offset. The slice of cells must contain enough cells
|
||||
/// for the requested lines. A slice of the unused cells are returned.
|
||||
fn draw_lines<'a>(
|
||||
&mut self,
|
||||
y_offset: u16,
|
||||
lines_to_draw: u16,
|
||||
cells: &'a [Cell],
|
||||
) -> Result<&'a [Cell], B::Error> {
|
||||
let width: usize = self.last_known_area.width.into();
|
||||
let (to_draw, remainder) = cells.split_at(width * lines_to_draw as usize);
|
||||
if lines_to_draw > 0 {
|
||||
let iter = to_draw
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| ((i % width) as u16, y_offset + (i / width) as u16, c));
|
||||
self.backend.draw(iter)?;
|
||||
self.backend.flush()?;
|
||||
}
|
||||
Ok(remainder)
|
||||
}
|
||||
|
||||
/// Draw lines at the given vertical offset, assuming that the lines they are replacing on the
|
||||
/// screen are cleared. The slice of cells must contain enough cells for the requested lines. A
|
||||
/// slice of the unused cells are returned.
|
||||
#[cfg(feature = "scrolling-regions")]
|
||||
fn draw_lines_over_cleared<'a>(
|
||||
&mut self,
|
||||
y_offset: u16,
|
||||
lines_to_draw: u16,
|
||||
cells: &'a [Cell],
|
||||
) -> Result<&'a [Cell], B::Error> {
|
||||
let width: usize = self.last_known_area.width.into();
|
||||
let (to_draw, remainder) = cells.split_at(width * lines_to_draw as usize);
|
||||
if lines_to_draw > 0 {
|
||||
let area = Rect::new(0, y_offset, width as u16, y_offset + lines_to_draw);
|
||||
let old = Buffer::empty(area);
|
||||
let new = Buffer {
|
||||
area,
|
||||
content: to_draw.to_vec(),
|
||||
};
|
||||
self.backend.draw(old.diff(&new).into_iter())?;
|
||||
self.backend.flush()?;
|
||||
}
|
||||
Ok(remainder)
|
||||
}
|
||||
|
||||
/// Scroll the whole screen up by the given number of lines.
|
||||
#[cfg(not(feature = "scrolling-regions"))]
|
||||
fn scroll_up(&mut self, lines_to_scroll: u16) -> Result<(), B::Error> {
|
||||
if lines_to_scroll > 0 {
|
||||
self.set_cursor_position(Position::new(
|
||||
0,
|
||||
self.last_known_area.height.saturating_sub(1),
|
||||
))?;
|
||||
self.backend.append_lines(lines_to_scroll)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_inline_size<B: Backend>(
|
||||
backend: &mut B,
|
||||
height: u16,
|
||||
size: Size,
|
||||
offset_in_previous_viewport: u16,
|
||||
) -> Result<(Rect, Position), B::Error> {
|
||||
let pos = backend.get_cursor_position()?;
|
||||
let mut row = pos.y;
|
||||
|
||||
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,
|
||||
))
|
||||
}
|
||||
@@ -2,31 +2,67 @@ use core::fmt;
|
||||
|
||||
use crate::layout::Rect;
|
||||
|
||||
/// Represents the viewport of the terminal. The viewport is the area of the terminal that is
|
||||
/// currently visible to the user. It can be either fullscreen, inline or fixed.
|
||||
/// The area of the terminal that Ratatui draws into.
|
||||
///
|
||||
/// When the viewport is fullscreen, the whole terminal is used to draw the application.
|
||||
/// A [`Viewport`] controls where widgets render and what [`Frame::area`] returns.
|
||||
///
|
||||
/// When the viewport is inline, it is drawn inline with the rest of the terminal. The height of
|
||||
/// the viewport is fixed, but the width is the same as the terminal width.
|
||||
/// For a higher-level overview of viewports in the context of an application (including
|
||||
/// examples), see [`Terminal`].
|
||||
///
|
||||
/// When the viewport is fixed, it is drawn in a fixed area of the terminal. The area is specified
|
||||
/// by a [`Rect`].
|
||||
/// Most applications use [`Viewport::Fullscreen`]. Use [`Viewport::Inline`] when you want to embed
|
||||
/// a UI into a larger CLI flow (for example: print some text, then start an interactive UI below
|
||||
/// it). Use [`Viewport::Fixed`] when you want Ratatui to render into a specific region of the
|
||||
/// terminal.
|
||||
///
|
||||
/// See [`Terminal::with_options`] for more information.
|
||||
/// In fullscreen mode, the viewport starts at (0, 0). In inline and fixed mode, the viewport may
|
||||
/// have a non-zero `x`/`y` origin; prefer using `Frame::area()` as your root layout rectangle.
|
||||
///
|
||||
/// See [`Terminal::with_options`] for how to select a viewport, and [`Terminal::resize`] /
|
||||
/// [`Terminal::autoresize`] for resize behavior.
|
||||
///
|
||||
/// [`Frame::area`]: crate::terminal::Frame::area
|
||||
/// [`Terminal`]: crate::terminal::Terminal
|
||||
/// [`Terminal::with_options`]: crate::terminal::Terminal::with_options
|
||||
/// [`Terminal::resize`]: crate::terminal::Terminal::resize
|
||||
/// [`Terminal::autoresize`]: crate::terminal::Terminal::autoresize
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
|
||||
pub enum Viewport {
|
||||
/// The viewport is fullscreen
|
||||
/// Draw into the entire terminal.
|
||||
///
|
||||
/// This is the default viewport used by [`Terminal::new`].
|
||||
///
|
||||
/// When the terminal size changes, Ratatui automatically resizes internal buffers during
|
||||
/// [`Terminal::draw`].
|
||||
///
|
||||
/// `Frame::area()` always starts at (0, 0).
|
||||
///
|
||||
/// [`Terminal::new`]: crate::terminal::Terminal::new
|
||||
/// [`Terminal::draw`]: crate::terminal::Terminal::draw
|
||||
#[default]
|
||||
Fullscreen,
|
||||
/// The viewport is inline with the rest of the terminal.
|
||||
/// Draw the application inline with the rest of the terminal output.
|
||||
///
|
||||
/// The viewport's height is fixed and specified in number of lines. The width is the same as
|
||||
/// the terminal's width. The viewport is drawn below the cursor position.
|
||||
/// The viewport spans the full terminal width and its top-left corner is anchored to column 0
|
||||
/// of the current cursor row when the terminal is created (and when it is resized). Ratatui
|
||||
/// reserves space for the requested height; if the cursor is near the bottom of the screen,
|
||||
/// this may scroll the terminal so the viewport remains fully visible.
|
||||
///
|
||||
/// The height is specified in rows and is clamped to the current terminal height.
|
||||
Inline(u16),
|
||||
/// The viewport is drawn in a fixed area of the terminal. The area is specified by a [`Rect`].
|
||||
/// Draw into a fixed region of the terminal.
|
||||
///
|
||||
/// This can be useful when Ratatui is responsible for only part of the screen (for example, a
|
||||
/// status panel beside another renderer), or when you want to manage the overall layout
|
||||
/// yourself.
|
||||
///
|
||||
/// Fixed viewports are not automatically resized. If the region should change (for example, on
|
||||
/// terminal resize), call [`Terminal::resize`] yourself.
|
||||
///
|
||||
/// The area is specified as a [`Rect`] in terminal coordinates.
|
||||
///
|
||||
/// `Frame::area()` returns this rectangle as-is (including its `x`/`y` offset).
|
||||
///
|
||||
/// [`Terminal::resize`]: crate::terminal::Terminal::resize
|
||||
Fixed(Rect),
|
||||
}
|
||||
|
||||
|
||||
@@ -803,7 +803,7 @@ fn spans_after_width<'a>(
|
||||
/// A trait for converting a value to a [`Line`].
|
||||
///
|
||||
/// This trait is automatically implemented for any type that implements the [`Display`] trait. As
|
||||
/// such, `ToLine` shouln't be implemented directly: [`Display`] should be implemented instead, and
|
||||
/// such, `ToLine` shouldn't be implemented directly: [`Display`] should be implemented instead, and
|
||||
/// you get the `ToLine` implementation for free.
|
||||
///
|
||||
/// [`Display`]: std::fmt::Display
|
||||
|
||||
@@ -474,7 +474,7 @@ impl Widget for &Span<'_> {
|
||||
/// A trait for converting a value to a [`Span`].
|
||||
///
|
||||
/// This trait is automatically implemented for any type that implements the [`Display`] trait. As
|
||||
/// such, `ToSpan` shouln't be implemented directly: [`Display`] should be implemented instead, and
|
||||
/// such, `ToSpan` shouldn't be implemented directly: [`Display`] should be implemented instead, and
|
||||
/// you get the `ToSpan` implementation for free.
|
||||
///
|
||||
/// [`Display`]: std::fmt::Display
|
||||
|
||||
@@ -423,6 +423,79 @@ impl IntoCrossterm<CrosstermColor> for Color {
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoCrossterm<ContentStyle> for Style {
|
||||
fn into_crossterm(self) -> ContentStyle {
|
||||
let mut attributes = CrosstermAttributes::default();
|
||||
|
||||
// Add modifiers
|
||||
if self.add_modifier.contains(Modifier::BOLD) {
|
||||
attributes.set(CrosstermAttribute::Bold);
|
||||
}
|
||||
if self.add_modifier.contains(Modifier::DIM) {
|
||||
attributes.set(CrosstermAttribute::Dim);
|
||||
}
|
||||
if self.add_modifier.contains(Modifier::ITALIC) {
|
||||
attributes.set(CrosstermAttribute::Italic);
|
||||
}
|
||||
if self.add_modifier.contains(Modifier::UNDERLINED) {
|
||||
attributes.set(CrosstermAttribute::Underlined);
|
||||
}
|
||||
if self.add_modifier.contains(Modifier::SLOW_BLINK) {
|
||||
attributes.set(CrosstermAttribute::SlowBlink);
|
||||
}
|
||||
if self.add_modifier.contains(Modifier::RAPID_BLINK) {
|
||||
attributes.set(CrosstermAttribute::RapidBlink);
|
||||
}
|
||||
if self.add_modifier.contains(Modifier::REVERSED) {
|
||||
attributes.set(CrosstermAttribute::Reverse);
|
||||
}
|
||||
if self.add_modifier.contains(Modifier::HIDDEN) {
|
||||
attributes.set(CrosstermAttribute::Hidden);
|
||||
}
|
||||
if self.add_modifier.contains(Modifier::CROSSED_OUT) {
|
||||
attributes.set(CrosstermAttribute::CrossedOut);
|
||||
}
|
||||
|
||||
// Sub modifiers (remove modifiers)
|
||||
if self.sub_modifier.contains(Modifier::BOLD) {
|
||||
attributes.set(CrosstermAttribute::NoBold);
|
||||
}
|
||||
if self.sub_modifier.contains(Modifier::DIM) {
|
||||
attributes.set(CrosstermAttribute::NormalIntensity);
|
||||
}
|
||||
if self.sub_modifier.contains(Modifier::ITALIC) {
|
||||
attributes.set(CrosstermAttribute::NoItalic);
|
||||
}
|
||||
if self.sub_modifier.contains(Modifier::UNDERLINED) {
|
||||
attributes.set(CrosstermAttribute::NoUnderline);
|
||||
}
|
||||
if self.sub_modifier.contains(Modifier::SLOW_BLINK)
|
||||
|| self.sub_modifier.contains(Modifier::RAPID_BLINK)
|
||||
{
|
||||
attributes.set(CrosstermAttribute::NoBlink);
|
||||
}
|
||||
if self.sub_modifier.contains(Modifier::REVERSED) {
|
||||
attributes.set(CrosstermAttribute::NoReverse);
|
||||
}
|
||||
if self.sub_modifier.contains(Modifier::HIDDEN) {
|
||||
attributes.set(CrosstermAttribute::NoHidden);
|
||||
}
|
||||
if self.sub_modifier.contains(Modifier::CROSSED_OUT) {
|
||||
attributes.set(CrosstermAttribute::NotCrossedOut);
|
||||
}
|
||||
|
||||
ContentStyle {
|
||||
foreground_color: self.fg.map(IntoCrossterm::into_crossterm),
|
||||
background_color: self.bg.map(IntoCrossterm::into_crossterm),
|
||||
#[cfg(feature = "underline-color")]
|
||||
underline_color: self.underline_color.map(IntoCrossterm::into_crossterm),
|
||||
#[cfg(not(feature = "underline-color"))]
|
||||
underline_color: None,
|
||||
attributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromCrossterm<CrosstermColor> for Color {
|
||||
fn from_crossterm(value: CrosstermColor) -> Self {
|
||||
match value {
|
||||
@@ -876,4 +949,176 @@ mod tests {
|
||||
Style::default().underline_color(Color::Red)
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(Style::default(), ContentStyle::default())]
|
||||
#[case(
|
||||
Style::default().fg(Color::Yellow),
|
||||
ContentStyle {
|
||||
foreground_color: Some(CrosstermColor::DarkYellow),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().bg(Color::Yellow),
|
||||
ContentStyle {
|
||||
background_color: Some(CrosstermColor::DarkYellow),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::Bold),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().remove_modifier(Modifier::BOLD),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::NoBold),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().add_modifier(Modifier::ITALIC),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::Italic),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().remove_modifier(Modifier::ITALIC),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::NoItalic),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().add_modifier(Modifier::UNDERLINED),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::Underlined),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().remove_modifier(Modifier::UNDERLINED),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::NoUnderline),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::Dim),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().remove_modifier(Modifier::DIM),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::NormalIntensity),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().add_modifier(Modifier::SLOW_BLINK),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::SlowBlink),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().add_modifier(Modifier::RAPID_BLINK),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::RapidBlink),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().remove_modifier(Modifier::SLOW_BLINK),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::NoBlink),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().add_modifier(Modifier::REVERSED),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::Reverse),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().remove_modifier(Modifier::REVERSED),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::NoReverse),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().add_modifier(Modifier::HIDDEN),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::Hidden),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().remove_modifier(Modifier::HIDDEN),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::NoHidden),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().add_modifier(Modifier::CROSSED_OUT),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::CrossedOut),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default().remove_modifier(Modifier::CROSSED_OUT),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(CrosstermAttribute::NotCrossedOut),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(
|
||||
[CrosstermAttribute::Bold, CrosstermAttribute::Italic].as_ref()
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
Style::default()
|
||||
.remove_modifier(Modifier::BOLD)
|
||||
.remove_modifier(Modifier::ITALIC),
|
||||
ContentStyle {
|
||||
attributes: CrosstermAttributes::from(
|
||||
[CrosstermAttribute::NoBold, CrosstermAttribute::NoItalic].as_ref()
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
)]
|
||||
fn into_crossterm_content_style(#[case] style: Style, #[case] content_style: ContentStyle) {
|
||||
assert_eq!(style.into_crossterm(), content_style);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "underline-color")]
|
||||
fn into_crossterm_content_style_underline() {
|
||||
let style = Style::default().underline_color(Color::Red);
|
||||
let content_style = ContentStyle {
|
||||
underline_color: Some(CrosstermColor::DarkRed),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(style.into_crossterm(), content_style);
|
||||
}
|
||||
}
|
||||
|
||||
111
ratatui-widgets/src/as_ref.rs
Normal file
111
ratatui-widgets/src/as_ref.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
/// Implement `AsRef<Self>` for widget types to enable `as_ref()` in generic contexts.
|
||||
///
|
||||
/// This keeps widget rendering ergonomic when APIs accept `AsRef<WidgetType>` bounds, avoiding
|
||||
/// the need for `(&widget).render(...)` just to satisfy a trait bound.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use ratatui_widgets::block::Block;
|
||||
///
|
||||
/// let block = Block::default();
|
||||
/// let block_ref: &Block<'_> = block.as_ref();
|
||||
/// ```
|
||||
///
|
||||
/// # Generated impls
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// // Non-generic widgets (e.g. Clear, RatatuiLogo).
|
||||
/// impl AsRef<Clear> for Clear {
|
||||
/// fn as_ref(&self) -> &Clear {
|
||||
/// self
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // Generic widgets (e.g. Block with a lifetime, Canvas with a lifetime + type parameter).
|
||||
/// impl<'a> AsRef<Block<'a>> for Block<'a> {
|
||||
/// fn as_ref(&self) -> &Block<'a> {
|
||||
/// self
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// impl<'a, F> AsRef<Canvas<'a, F>> for Canvas<'a, F>
|
||||
/// where
|
||||
/// F: Fn(&mut Context),
|
||||
/// {
|
||||
/// fn as_ref(&self) -> &Canvas<'a, F> {
|
||||
/// self
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
macro_rules! impl_as_ref {
|
||||
($type:ty, <$($gen:tt),+> $(where $($bounds:tt)+)?) => {
|
||||
impl<$($gen),+> AsRef<$type> for $type $(where $($bounds)+)? {
|
||||
fn as_ref(&self) -> &$type {
|
||||
self
|
||||
}
|
||||
}
|
||||
};
|
||||
($type:ty) => {
|
||||
impl AsRef<$type> for $type {
|
||||
fn as_ref(&self) -> &$type {
|
||||
self
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_as_ref!(crate::barchart::BarChart<'a>, <'a>);
|
||||
impl_as_ref!(crate::block::Block<'a>, <'a>);
|
||||
impl_as_ref!(crate::canvas::Canvas<'a, F>, <'a, F> where F: Fn(&mut crate::canvas::Context));
|
||||
impl_as_ref!(crate::chart::Chart<'a>, <'a>);
|
||||
impl_as_ref!(crate::clear::Clear);
|
||||
impl_as_ref!(crate::gauge::Gauge<'a>, <'a>);
|
||||
impl_as_ref!(crate::gauge::LineGauge<'a>, <'a>);
|
||||
impl_as_ref!(crate::list::List<'a>, <'a>);
|
||||
impl_as_ref!(crate::logo::RatatuiLogo);
|
||||
impl_as_ref!(crate::mascot::RatatuiMascot);
|
||||
impl_as_ref!(crate::paragraph::Paragraph<'a>, <'a>);
|
||||
impl_as_ref!(crate::scrollbar::Scrollbar<'a>, <'a>);
|
||||
impl_as_ref!(crate::sparkline::Sparkline<'a>, <'a>);
|
||||
impl_as_ref!(crate::table::Table<'a>, <'a>);
|
||||
impl_as_ref!(crate::tabs::Tabs<'a>, <'a>);
|
||||
#[cfg(feature = "calendar")]
|
||||
impl_as_ref!(
|
||||
crate::calendar::Monthly<'a, DS>,
|
||||
<'a, DS> where DS: crate::calendar::DateStyler
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use alloc::vec;
|
||||
|
||||
#[test]
|
||||
fn widgets_implement_as_ref() {
|
||||
let _ = crate::barchart::BarChart::default().as_ref();
|
||||
let _ = crate::block::Block::new().as_ref();
|
||||
let _ = crate::canvas::Canvas::default().paint(|_| {}).as_ref();
|
||||
let _ = crate::chart::Chart::new(vec![]).as_ref();
|
||||
let _ = crate::clear::Clear.as_ref();
|
||||
let _ = crate::gauge::Gauge::default().as_ref();
|
||||
let _ = crate::gauge::LineGauge::default().as_ref();
|
||||
let _ = crate::list::List::new(["foo"]).as_ref();
|
||||
let _ = crate::logo::RatatuiLogo::default().as_ref();
|
||||
let _ = crate::mascot::RatatuiMascot::default().as_ref();
|
||||
let _ = crate::paragraph::Paragraph::new("").as_ref();
|
||||
let _ = crate::scrollbar::Scrollbar::default().as_ref();
|
||||
let _ = crate::sparkline::Sparkline::default().as_ref();
|
||||
let _ = crate::table::Table::default().as_ref();
|
||||
let _ = crate::tabs::Tabs::default().as_ref();
|
||||
}
|
||||
|
||||
#[cfg(feature = "calendar")]
|
||||
#[test]
|
||||
fn calendar_widget_implements_as_ref() {
|
||||
use time::{Date, Month};
|
||||
|
||||
let date = Date::from_calendar_date(2024, Month::January, 1).unwrap();
|
||||
let _ = crate::calendar::Monthly::new(date, crate::calendar::CalendarEventStore::default())
|
||||
.as_ref();
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ impl MapResolution {
|
||||
}
|
||||
}
|
||||
|
||||
/// A world map
|
||||
/// A world map. It represents the world using the [EPSG:4326 coordinate reference system](https://en.wikipedia.org/wiki/EPSG_Geodetic_Parameter_Dataset).
|
||||
///
|
||||
/// A world map can be rendered with different [resolutions](MapResolution) and [colors](Color).
|
||||
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
|
||||
|
||||
@@ -130,5 +130,6 @@ pub mod tabs;
|
||||
mod polyfills;
|
||||
mod reflow;
|
||||
|
||||
mod as_ref;
|
||||
#[cfg(feature = "calendar")]
|
||||
pub mod calendar;
|
||||
|
||||
@@ -403,7 +403,7 @@ impl<'a> List<'a> {
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// A padding value of 1 will keep 1 item above and 1 item bellow visible if possible
|
||||
/// A padding value of 1 will keep 1 item above and 1 item below visible if possible
|
||||
///
|
||||
/// ```rust
|
||||
/// use ratatui::widgets::List;
|
||||
|
||||
@@ -220,7 +220,7 @@ impl List<'_> {
|
||||
let last_valid_index = self.items.len().saturating_sub(1);
|
||||
let selected = selected?.min(last_valid_index);
|
||||
|
||||
// The bellow loop handles situations where the list item sizes may not be consistent,
|
||||
// The below loop handles situations where the list item sizes may not be consistent,
|
||||
// where the offset would have excluded some items that we want to include, or could
|
||||
// cause the offset value to be set to an inconsistent value each time we render.
|
||||
// The padding value will be reduced in case any of these issues would occur
|
||||
@@ -1219,7 +1219,7 @@ mod tests {
|
||||
assert_eq!(buffer, Buffer::with_lines(expected));
|
||||
}
|
||||
|
||||
// Tests to make sure when it's pushing back the first visible index value that it doesnt
|
||||
// Tests to make sure when it's pushing back the first visible index value that it doesn't
|
||||
// include an item that's too large
|
||||
#[test]
|
||||
fn padding_offset_pushback_break() {
|
||||
|
||||
@@ -776,7 +776,7 @@ impl StatefulWidget for &Table<'_> {
|
||||
|
||||
self.render_header(header_area, buf, &column_widths);
|
||||
|
||||
self.render_rows(rows_area, buf, state, selection_width, &column_widths);
|
||||
self.render_rows(rows_area, buf, selection_width, state, &column_widths);
|
||||
|
||||
self.render_footer(footer_area, buf, &column_widths);
|
||||
}
|
||||
@@ -806,31 +806,47 @@ impl Table<'_> {
|
||||
(header_area, rows_area, footer_area)
|
||||
}
|
||||
|
||||
fn render_header(&self, area: Rect, buf: &mut Buffer, column_widths: &[(u16, u16)]) {
|
||||
/// Render the header cells, if they are not `None`
|
||||
///
|
||||
/// The `x` and `width` fields of each `Rect` in `column_widths` denote the starting
|
||||
/// x-coordinate and width of each column in the table.
|
||||
fn render_header(&self, area: Rect, buf: &mut Buffer, column_widths: &[Rect]) {
|
||||
if let Some(ref header) = self.header {
|
||||
buf.set_style(area, header.style);
|
||||
for ((x, width), cell) in column_widths.iter().zip(header.cells.iter()) {
|
||||
cell.render(Rect::new(area.x + x, area.y, *width, area.height), buf);
|
||||
for (cell_area, cell) in column_widths.iter().zip(header.cells.iter()) {
|
||||
let new_x = area.x + cell_area.x;
|
||||
let area_to_render = Rect::new(new_x, area.y, cell_area.width, area.height);
|
||||
cell.render(area_to_render, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_footer(&self, area: Rect, buf: &mut Buffer, column_widths: &[(u16, u16)]) {
|
||||
/// Render the footer cells, if they are not `None`
|
||||
///
|
||||
/// The `x` and `width` fields of each `Rect` in `column_widths` denote the starting
|
||||
/// x-coordinate and width of each column in the table.
|
||||
fn render_footer(&self, area: Rect, buf: &mut Buffer, column_widths: &[Rect]) {
|
||||
if let Some(ref footer) = self.footer {
|
||||
buf.set_style(area, footer.style);
|
||||
for ((x, width), cell) in column_widths.iter().zip(footer.cells.iter()) {
|
||||
cell.render(Rect::new(area.x + x, area.y, *width, area.height), buf);
|
||||
for (cell_area, cell) in column_widths.iter().zip(footer.cells.iter()) {
|
||||
let new_x = area.x + cell_area.x;
|
||||
let area_to_render = Rect::new(new_x, area.y, cell_area.width, area.height);
|
||||
cell.render(area_to_render, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the table rows
|
||||
///
|
||||
/// The `x` and `width` fields of each `Rect` in `column_widths` denote the starting
|
||||
/// x-coordinate and width of each column in the table.
|
||||
fn render_rows(
|
||||
&self,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
state: &mut TableState,
|
||||
selection_width: u16,
|
||||
columns_widths: &[(u16, u16)],
|
||||
state: &mut TableState,
|
||||
columns_widths: &[Rect],
|
||||
) {
|
||||
if self.rows.is_empty() {
|
||||
return;
|
||||
@@ -856,19 +872,9 @@ impl Table<'_> {
|
||||
|
||||
let is_selected = state.selected.is_some_and(|index| index == i);
|
||||
if selection_width > 0 && is_selected {
|
||||
let selection_area = Rect {
|
||||
width: selection_width,
|
||||
..row_area
|
||||
};
|
||||
buf.set_style(selection_area, row.style);
|
||||
(&self.highlight_symbol).render(selection_area, buf);
|
||||
}
|
||||
for ((x, width), cell) in columns_widths.iter().zip(row.cells.iter()) {
|
||||
cell.render(
|
||||
Rect::new(row_area.x + x, row_area.y, *width, row_area.height),
|
||||
buf,
|
||||
);
|
||||
self.set_selection_style(buf, selection_width, row_area, row);
|
||||
}
|
||||
self.render_row_cells(buf, columns_widths.iter().collect(), &row.cells, row_area);
|
||||
if is_selected {
|
||||
selected_row_area = Some(row_area);
|
||||
}
|
||||
@@ -878,9 +884,9 @@ impl Table<'_> {
|
||||
let selected_column_area = state.selected_column.and_then(|s| {
|
||||
// The selection is clamped by the column count. Since a user can manually specify an
|
||||
// incorrect number of widths, we should use panic free methods.
|
||||
columns_widths.get(s).map(|(x, width)| Rect {
|
||||
x: x + area.x,
|
||||
width: *width,
|
||||
columns_widths.get(s).map(|cell_area| Rect {
|
||||
x: cell_area.x + area.x,
|
||||
width: cell_area.width,
|
||||
..area
|
||||
})
|
||||
});
|
||||
@@ -902,6 +908,83 @@ impl Table<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render cells into the columns of a row
|
||||
///
|
||||
/// Render `Cell`s from `cells` into columns specified by `column_widths`, stopping
|
||||
/// if either of these iterators are finished. Each `Cell` gets rendered across
|
||||
/// [`Cell::get_column_span`] columns plus the gaps between them, if this value is > 1.
|
||||
fn render_row_cells(
|
||||
&self,
|
||||
buf: &mut Buffer,
|
||||
column_widths: Vec<&Rect>,
|
||||
cells: &Vec<Cell>,
|
||||
row_area: Rect,
|
||||
) {
|
||||
let mut column_widths_iterator = column_widths.into_iter();
|
||||
for current_cell in cells {
|
||||
if let Some(cell_area) = Self::get_cell_area(
|
||||
&mut column_widths_iterator,
|
||||
current_cell.column_span,
|
||||
self.column_spacing,
|
||||
) {
|
||||
let new_x = row_area.x + cell_area.x;
|
||||
let area_to_render = Rect::new(new_x, row_area.y, cell_area.width, row_area.height);
|
||||
current_cell.render(area_to_render, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the row style and render the highlight symbol
|
||||
fn set_selection_style(
|
||||
&self,
|
||||
buf: &mut Buffer,
|
||||
selection_width: u16,
|
||||
row_area: Rect,
|
||||
row: &Row,
|
||||
) {
|
||||
let selection_area = Rect {
|
||||
width: selection_width,
|
||||
..row_area
|
||||
};
|
||||
buf.set_style(selection_area, row.style);
|
||||
(&self.highlight_symbol).render(selection_area, buf);
|
||||
}
|
||||
|
||||
/// Return the area that a [`Cell`] should occupy, taking into account its
|
||||
/// [`Cell::column_span`].
|
||||
///
|
||||
/// Returns `None` when there are no more columns for the [`Cell`] to occupy.
|
||||
///
|
||||
/// Otherwise, returns `Some(Rect{x, y = 0, width, height = 0})`, representing the start
|
||||
/// x-coordinate and width of the [`Cell`].
|
||||
///
|
||||
/// This function consumes `cell_column_span` `Rect`s from `column_widths_iterator` (or all the
|
||||
/// `Rects` if the iterator is less than `cell_column_span` `Rect`s long). This function adds
|
||||
/// the width of each `Rect` plus `column_spacing` to a running total of the final width. The
|
||||
/// return value is the original x coordinate and the final width, or `None` if
|
||||
/// `column_widths_iterator` is empty or `cell_column_span` is `0`.
|
||||
fn get_cell_area<'a, T>(
|
||||
column_widths_iterator: &mut T,
|
||||
cell_column_span: u16,
|
||||
column_spacing: u16,
|
||||
) -> Option<Rect>
|
||||
where
|
||||
T: Iterator<Item = &'a Rect>,
|
||||
{
|
||||
if cell_column_span == 0 {
|
||||
return None;
|
||||
}
|
||||
let first = column_widths_iterator.next()?;
|
||||
let (n_columns_taken, all_columns_width) = column_widths_iterator
|
||||
.take((cell_column_span - 1).into())
|
||||
.map(|rect| (1, rect.width))
|
||||
.fold((1, first.width), |so_far, next_column| {
|
||||
(next_column.0 + so_far.0, next_column.1 + so_far.1)
|
||||
});
|
||||
let width = all_columns_width + (n_columns_taken - 1) * column_spacing;
|
||||
Some(Rect::new(first.x, first.y, width, 1))
|
||||
}
|
||||
|
||||
/// Return the indexes of the visible rows.
|
||||
///
|
||||
/// The algorithm works as follows:
|
||||
@@ -960,7 +1043,7 @@ impl Table<'_> {
|
||||
max_width: u16,
|
||||
selection_width: u16,
|
||||
col_count: usize,
|
||||
) -> Vec<(u16, u16)> {
|
||||
) -> Vec<Rect> {
|
||||
let widths = if self.widths.is_empty() {
|
||||
// Divide the space between each column equally
|
||||
vec![Constraint::Length(max_width / col_count.max(1) as u16); col_count]
|
||||
@@ -975,7 +1058,10 @@ impl Table<'_> {
|
||||
.flex(self.flex)
|
||||
.spacing(self.column_spacing)
|
||||
.split(columns_area);
|
||||
rects.iter().map(|c| (c.x, c.width)).collect()
|
||||
rects
|
||||
.iter()
|
||||
.map(|c| Rect::new(c.x, 0, c.width, 1))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn column_count(&self) -> usize {
|
||||
@@ -1352,6 +1438,101 @@ mod tests {
|
||||
assert_eq!(buf, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(15, 5, vec![
|
||||
Row::new(vec![
|
||||
Cell::new("Cell1").column_span(1),
|
||||
Cell::new("Cell2").column_span(1),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Cell::new("Cell3").column_span(1),
|
||||
Cell::new("Cell4").column_span(1),
|
||||
]),
|
||||
],
|
||||
&Buffer::with_lines(["Cell1 Cell2 ", "Cell3 Cell4 "]))]
|
||||
#[case(15, 5, vec![
|
||||
Row::new(vec![
|
||||
Cell::new("Cell1").column_span(0),
|
||||
Cell::new("Cell2").column_span(1),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Cell::new("Cell3").column_span(1),
|
||||
Cell::new("Cell4").column_span(1),
|
||||
]),
|
||||
], &Buffer::with_lines(["Cell2 ", "Cell3 Cell4 "]))]
|
||||
#[case(15, 5, vec![
|
||||
Row::new(vec![
|
||||
Cell::new("Cell1").column_span(2),
|
||||
Cell::new("Cell2").column_span(1),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Cell::new("Cell3").column_span(1),
|
||||
Cell::new("Cell4").column_span(1),
|
||||
]),
|
||||
], &Buffer::with_lines(["Cell1 ", "Cell3 Cell4 "]))]
|
||||
fn test_colspans_2_cols<'rows, Rows>(
|
||||
#[case] width: u16,
|
||||
#[case] column_width: u16,
|
||||
#[case] rows: Rows,
|
||||
#[case] expected: &Buffer,
|
||||
) where
|
||||
Rows: IntoIterator<Item = Row<'rows>>,
|
||||
{
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, 2));
|
||||
let table = Table::new(rows, [Constraint::Length(column_width); 2]);
|
||||
Widget::render(table, Rect::new(0, 0, width, 2), &mut buf);
|
||||
assert_eq!(buf, *expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(17, 5, vec![
|
||||
Row::new(vec![
|
||||
Cell::new("Cell1").column_span(2),
|
||||
Cell::new("Cell2").column_span(1),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Cell::new("Cell3").column_span(1),
|
||||
Cell::new("Cell4").column_span(1),
|
||||
Cell::new("Cell5").column_span(1),
|
||||
]),
|
||||
], &Buffer::with_lines(["Cell1 Cell2", "Cell3 Cell4 Cell5"]))]
|
||||
#[case(17, 5, vec![
|
||||
Row::new(vec![
|
||||
Cell::new("Cell1").column_span(1),
|
||||
Cell::new("Cell2").column_span(2),
|
||||
Cell::new("Cell3").column_span(1),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Cell::new("Cell4").column_span(1),
|
||||
Cell::new("Cell5").column_span(1),
|
||||
Cell::new("Cell6").column_span(1),
|
||||
]),
|
||||
], &Buffer::with_lines(["Cell1 Cell2 ", "Cell4 Cell5 Cell6"]))]
|
||||
#[case(15, 5, vec![
|
||||
Row::new(vec![
|
||||
Cell::new("11111111111111111111").column_span(2),
|
||||
Cell::new("22222222222222222222").column_span(1),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Cell::new("33333333333333333333").column_span(1),
|
||||
Cell::new("44444444444444444444").column_span(2),
|
||||
Cell::new("55555555555555555555").column_span(1),
|
||||
]),
|
||||
], &Buffer::with_lines(["1111111111 2222", "3333 4444444444"]))]
|
||||
fn test_colspans_3_cols<'rows, Rows>(
|
||||
#[case] width: u16,
|
||||
#[case] column_width: u16,
|
||||
#[case] rows: Rows,
|
||||
#[case] expected: &Buffer,
|
||||
) where
|
||||
Rows: IntoIterator<Item = Row<'rows>>,
|
||||
{
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, 2));
|
||||
let table = Table::new(rows, [Constraint::Length(column_width); 3]);
|
||||
Widget::render(table, Rect::new(0, 0, width, 2), &mut buf);
|
||||
assert_eq!(buf, *expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_with_header() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 15, 3));
|
||||
@@ -1676,15 +1857,24 @@ mod tests {
|
||||
fn length_constraint() {
|
||||
// without selection, more than needed width
|
||||
let table = Table::default().widths([Length(4), Length(4)]);
|
||||
assert_eq!(table.get_column_widths(20, 0, 0), [(0, 4), (5, 4)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(20, 0, 0),
|
||||
[Rect::new(0, 0, 4, 1), Rect::new(5, 0, 4, 1),]
|
||||
);
|
||||
|
||||
// with selection, more than needed width
|
||||
let table = Table::default().widths([Length(4), Length(4)]);
|
||||
assert_eq!(table.get_column_widths(20, 3, 0), [(3, 4), (8, 4)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(20, 3, 0),
|
||||
[Rect::new(3, 0, 4, 1), Rect::new(8, 0, 4, 1)]
|
||||
);
|
||||
|
||||
// without selection, less than needed width
|
||||
let table = Table::default().widths([Length(4), Length(4)]);
|
||||
assert_eq!(table.get_column_widths(7, 0, 0), [(0, 3), (4, 3)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(7, 0, 0),
|
||||
[Rect::new(0, 0, 3, 1), Rect::new(4, 0, 3, 1)]
|
||||
);
|
||||
|
||||
// with selection, less than needed width
|
||||
// <--------7px-------->
|
||||
@@ -1693,26 +1883,41 @@ mod tests {
|
||||
// └────────┘x└────────┘
|
||||
// column spacing (i.e. `x`) is always prioritized
|
||||
let table = Table::default().widths([Length(4), Length(4)]);
|
||||
assert_eq!(table.get_column_widths(7, 3, 0), [(3, 2), (6, 1)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(7, 3, 0),
|
||||
[Rect::new(3, 0, 2, 1), Rect::new(6, 0, 1, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_constraint() {
|
||||
// without selection, more than needed width
|
||||
let table = Table::default().widths([Max(4), Max(4)]);
|
||||
assert_eq!(table.get_column_widths(20, 0, 0), [(0, 4), (5, 4)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(20, 0, 0),
|
||||
[Rect::new(0, 0, 4, 1), Rect::new(5, 0, 4, 1)]
|
||||
);
|
||||
|
||||
// with selection, more than needed width
|
||||
let table = Table::default().widths([Max(4), Max(4)]);
|
||||
assert_eq!(table.get_column_widths(20, 3, 0), [(3, 4), (8, 4)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(20, 3, 0),
|
||||
[Rect::new(3, 0, 4, 1), Rect::new(8, 0, 4, 1)]
|
||||
);
|
||||
|
||||
// without selection, less than needed width
|
||||
let table = Table::default().widths([Max(4), Max(4)]);
|
||||
assert_eq!(table.get_column_widths(7, 0, 0), [(0, 3), (4, 3)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(7, 0, 0),
|
||||
[Rect::new(0, 0, 3, 1), Rect::new(4, 0, 3, 1)]
|
||||
);
|
||||
|
||||
// with selection, less than needed width
|
||||
let table = Table::default().widths([Max(4), Max(4)]);
|
||||
assert_eq!(table.get_column_widths(7, 3, 0), [(3, 2), (6, 1)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(7, 3, 0),
|
||||
[Rect::new(3, 0, 2, 1), Rect::new(6, 0, 1, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1723,42 +1928,66 @@ mod tests {
|
||||
|
||||
// without selection, more than needed width
|
||||
let table = Table::default().widths([Min(4), Min(4)]);
|
||||
assert_eq!(table.get_column_widths(20, 0, 0), [(0, 10), (11, 9)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(20, 0, 0),
|
||||
[Rect::new(0, 0, 10, 1), Rect::new(11, 0, 9, 1)]
|
||||
);
|
||||
|
||||
// with selection, more than needed width
|
||||
let table = Table::default().widths([Min(4), Min(4)]);
|
||||
assert_eq!(table.get_column_widths(20, 3, 0), [(3, 8), (12, 8)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(20, 3, 0),
|
||||
[Rect::new(3, 0, 8, 1), Rect::new(12, 0, 8, 1)]
|
||||
);
|
||||
|
||||
// without selection, less than needed width
|
||||
// allocates spacer
|
||||
let table = Table::default().widths([Min(4), Min(4)]);
|
||||
assert_eq!(table.get_column_widths(7, 0, 0), [(0, 3), (4, 3)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(7, 0, 0),
|
||||
[Rect::new(0, 0, 3, 1), Rect::new(4, 0, 3, 1)]
|
||||
);
|
||||
|
||||
// with selection, less than needed width
|
||||
// always allocates selection and spacer
|
||||
let table = Table::default().widths([Min(4), Min(4)]);
|
||||
assert_eq!(table.get_column_widths(7, 3, 0), [(3, 2), (6, 1)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(7, 3, 0),
|
||||
[Rect::new(3, 0, 2, 1), Rect::new(6, 0, 1, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentage_constraint() {
|
||||
// without selection, more than needed width
|
||||
let table = Table::default().widths([Percentage(30), Percentage(30)]);
|
||||
assert_eq!(table.get_column_widths(20, 0, 0), [(0, 6), (7, 6)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(20, 0, 0),
|
||||
[Rect::new(0, 0, 6, 1), Rect::new(7, 0, 6, 1)]
|
||||
);
|
||||
|
||||
// with selection, more than needed width
|
||||
let table = Table::default().widths([Percentage(30), Percentage(30)]);
|
||||
assert_eq!(table.get_column_widths(20, 3, 0), [(3, 5), (9, 5)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(20, 3, 0),
|
||||
[Rect::new(3, 0, 5, 1), Rect::new(9, 0, 5, 1)]
|
||||
);
|
||||
|
||||
// without selection, less than needed width
|
||||
// rounds from positions: [0.0, 0.0, 2.1, 3.1, 5.2, 7.0]
|
||||
let table = Table::default().widths([Percentage(30), Percentage(30)]);
|
||||
assert_eq!(table.get_column_widths(7, 0, 0), [(0, 2), (3, 2)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(7, 0, 0),
|
||||
[Rect::new(0, 0, 2, 1), Rect::new(3, 0, 2, 1)]
|
||||
);
|
||||
|
||||
// with selection, less than needed width
|
||||
// rounds from positions: [0.0, 3.0, 5.1, 6.1, 7.0, 7.0]
|
||||
let table = Table::default().widths([Percentage(30), Percentage(30)]);
|
||||
assert_eq!(table.get_column_widths(7, 3, 0), [(3, 1), (5, 1)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(7, 3, 0),
|
||||
[Rect::new(3, 0, 1, 1), Rect::new(5, 0, 1, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1766,22 +1995,34 @@ mod tests {
|
||||
// without selection, more than needed width
|
||||
// rounds from positions: [0.00, 0.00, 6.67, 7.67, 14.33]
|
||||
let table = Table::default().widths([Ratio(1, 3), Ratio(1, 3)]);
|
||||
assert_eq!(table.get_column_widths(20, 0, 0), [(0, 7), (8, 6)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(20, 0, 0),
|
||||
[Rect::new(0, 0, 7, 1), Rect::new(8, 0, 6, 1)]
|
||||
);
|
||||
|
||||
// with selection, more than needed width
|
||||
// rounds from positions: [0.00, 3.00, 10.67, 17.33, 20.00]
|
||||
let table = Table::default().widths([Ratio(1, 3), Ratio(1, 3)]);
|
||||
assert_eq!(table.get_column_widths(20, 3, 0), [(3, 6), (10, 5)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(20, 3, 0),
|
||||
[Rect::new(3, 0, 6, 1), Rect::new(10, 0, 5, 1)]
|
||||
);
|
||||
|
||||
// without selection, less than needed width
|
||||
// rounds from positions: [0.00, 2.33, 3.33, 5.66, 7.00]
|
||||
let table = Table::default().widths([Ratio(1, 3), Ratio(1, 3)]);
|
||||
assert_eq!(table.get_column_widths(7, 0, 0), [(0, 2), (3, 3)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(7, 0, 0),
|
||||
[Rect::new(0, 0, 2, 1), Rect::new(3, 0, 3, 1)]
|
||||
);
|
||||
|
||||
// with selection, less than needed width
|
||||
// rounds from positions: [0.00, 3.00, 5.33, 6.33, 7.00, 7.00]
|
||||
let table = Table::default().widths([Ratio(1, 3), Ratio(1, 3)]);
|
||||
assert_eq!(table.get_column_widths(7, 3, 0), [(3, 1), (5, 2)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(7, 3, 0),
|
||||
[Rect::new(3, 0, 1, 1), Rect::new(5, 0, 2, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
/// When more width is available than requested, the behavior is controlled by flex
|
||||
@@ -1790,7 +2031,11 @@ mod tests {
|
||||
let table = Table::default().widths([Min(10), Min(10), Min(1)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(62, 0, 0),
|
||||
&[(0, 20), (21, 20), (42, 20)]
|
||||
&[
|
||||
Rect::new(0, 0, 20, 1),
|
||||
Rect::new(21, 0, 20, 1),
|
||||
Rect::new(42, 0, 20, 1)
|
||||
]
|
||||
);
|
||||
|
||||
let table = Table::default()
|
||||
@@ -1798,7 +2043,11 @@ mod tests {
|
||||
.flex(Flex::Legacy);
|
||||
assert_eq!(
|
||||
table.get_column_widths(62, 0, 0),
|
||||
&[(0, 10), (11, 10), (22, 40)]
|
||||
&[
|
||||
Rect::new(0, 0, 10, 1),
|
||||
Rect::new(11, 0, 10, 1),
|
||||
Rect::new(22, 0, 40, 1)
|
||||
]
|
||||
);
|
||||
|
||||
let table = Table::default()
|
||||
@@ -1806,7 +2055,11 @@ mod tests {
|
||||
.flex(Flex::SpaceBetween);
|
||||
assert_eq!(
|
||||
table.get_column_widths(62, 0, 0),
|
||||
&[(0, 20), (21, 20), (42, 20)]
|
||||
&[
|
||||
Rect::new(0, 0, 20, 1),
|
||||
Rect::new(21, 0, 20, 1),
|
||||
Rect::new(42, 0, 20, 1)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1815,7 +2068,11 @@ mod tests {
|
||||
let table = Table::default().widths([Min(10), Min(10), Min(1)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(62, 0, 0),
|
||||
&[(0, 20), (21, 20), (42, 20)]
|
||||
&[
|
||||
Rect::new(0, 0, 20, 1),
|
||||
Rect::new(21, 0, 20, 1),
|
||||
Rect::new(42, 0, 20, 1)
|
||||
]
|
||||
);
|
||||
|
||||
let table = Table::default()
|
||||
@@ -1823,7 +2080,11 @@ mod tests {
|
||||
.flex(Flex::Legacy);
|
||||
assert_eq!(
|
||||
table.get_column_widths(62, 0, 0),
|
||||
&[(0, 10), (11, 10), (22, 40)]
|
||||
&[
|
||||
Rect::new(0, 0, 10, 1),
|
||||
Rect::new(11, 0, 10, 1),
|
||||
Rect::new(22, 0, 40, 1)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1840,7 +2101,11 @@ mod tests {
|
||||
.column_spacing(0);
|
||||
assert_eq!(
|
||||
table.get_column_widths(30, 0, 3),
|
||||
&[(0, 10), (10, 10), (20, 10)]
|
||||
&[
|
||||
Rect::new(0, 0, 10, 1),
|
||||
Rect::new(10, 0, 10, 1),
|
||||
Rect::new(20, 0, 10, 1)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1850,7 +2115,10 @@ mod tests {
|
||||
.rows(vec![])
|
||||
.header(Row::new(vec!["f", "g"]))
|
||||
.column_spacing(0);
|
||||
assert_eq!(table.get_column_widths(10, 0, 2), [(0, 5), (5, 5)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(10, 0, 2),
|
||||
[Rect::new(0, 0, 5, 1), Rect::new(5, 0, 5, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1859,7 +2127,10 @@ mod tests {
|
||||
.rows(vec![])
|
||||
.footer(Row::new(vec!["h", "i"]))
|
||||
.column_spacing(0);
|
||||
assert_eq!(table.get_column_widths(10, 0, 2), [(0, 5), (5, 5)]);
|
||||
assert_eq!(
|
||||
table.get_column_widths(10, 0, 2),
|
||||
[Rect::new(0, 0, 5, 1), Rect::new(5, 0, 5, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
@@ -2302,4 +2573,145 @@ mod tests {
|
||||
// This should not panic, even if the buffer has zero size.
|
||||
Widget::render(table, buffer.area, &mut buffer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_area_for_column_span_one_no_more_columns() {
|
||||
let columns = [];
|
||||
let column_span = Table::get_cell_area(&mut columns.iter(), 1, 1);
|
||||
assert!(column_span.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_area_for_column_span_two_no_more_columns() {
|
||||
let columns = [];
|
||||
let column_span = Table::get_cell_area(&mut columns.iter(), 2, 1);
|
||||
assert!(column_span.is_none());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(&[Rect{x: 3, width: 2, y: 0, height: 1}, Rect{x: 3, width: 2, y: 0, height: 1}, Rect{x: 3, width: 2, y: 0, height: 1}], 2, 5)]
|
||||
#[case(&[Rect{x: 3, width: 2, y: 0, height: 1}, Rect{x: 3, width: 2, y: 0, height: 1}], 2, 5,)]
|
||||
#[case(&[Rect{x: 3, width: 2, y: 0, height: 1}, Rect{x: 3, width: 2, y: 0, height: 1}], 1, 2)]
|
||||
#[case(&[Rect{x: 3, width: 2, y: 0, height: 1}, Rect{x: 3, width: 2, y: 0, height: 1}], 3, 5)]
|
||||
#[case(&[Rect{x: 3, width: 2, y: 0, height: 1}], 1, 2)]
|
||||
#[case(&[Rect{x: 3, width: 2, y: 0, height: 1}], 2, 2)]
|
||||
#[case(&[
|
||||
Rect{x: 3, width: 2, y: 0, height: 1},
|
||||
Rect{x: 3, width: 2, y: 0, height: 1},
|
||||
Rect{x: 3, width: 2, y: 0, height: 1},
|
||||
Rect{x: 3, width: 2, y: 0, height: 1},
|
||||
], 3, 8)]
|
||||
#[case(&[
|
||||
Rect{x: 3, width: 2, y: 0, height: 1},
|
||||
Rect{x: 3, width: 2, y: 0, height: 1},
|
||||
Rect{x: 3, width: 2, y: 0, height: 1},
|
||||
], 3, 8)]
|
||||
fn test_colspan_width_single_column_spacing(
|
||||
#[case] columns: &[Rect],
|
||||
#[case] column_span: u16,
|
||||
#[case] expected_column_width: u16,
|
||||
) {
|
||||
let column_span = Table::get_cell_area(&mut columns.iter(), column_span, 1);
|
||||
assert!(column_span.is_some());
|
||||
assert_eq!(column_span.unwrap().width, expected_column_width);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(&[Rect{x: 3, width: 2, y: 0, height: 1}, Rect{x: 3, width: 2, y: 0, height: 1}, Rect{x: 3, width: 2, y: 0, height: 1}], 3, 10)]
|
||||
#[case(&[Rect{x: 3, width: 2, y: 0, height: 1}], 3, 2)]
|
||||
fn test_colspan_width_two_column_spacing(
|
||||
#[case] columns: &[Rect],
|
||||
#[case] column_span: u16,
|
||||
#[case] expected_column_width: u16,
|
||||
) {
|
||||
let column_span = Table::get_cell_area(&mut columns.iter(), column_span, 2);
|
||||
assert!(column_span.is_some());
|
||||
assert_eq!(column_span.unwrap().width, expected_column_width);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(
|
||||
HighlightSpacing::Always,
|
||||
15, // width
|
||||
1, // spacing
|
||||
None, // selection
|
||||
[
|
||||
Cell::new("ABCDEFGHIJK").column_span(2),
|
||||
Cell::new("12345678901"),
|
||||
Cell::new("XYZXYZXYZXY"),
|
||||
],
|
||||
[
|
||||
" ABCDEFGH 123",
|
||||
" ", // row 2
|
||||
" ", // row 3
|
||||
])]
|
||||
#[case(
|
||||
HighlightSpacing::Always,
|
||||
15, // width
|
||||
1, // spacing
|
||||
Some(0), // selection
|
||||
[
|
||||
Cell::new("ABCDEFGHIJK").column_span(2),
|
||||
Cell::new("12345678901"),
|
||||
Cell::new("XYZXYZXYZXY"),
|
||||
],
|
||||
[
|
||||
">>>ABCDEFGH 123",
|
||||
" ", // row 2
|
||||
" ", // row 3
|
||||
])]
|
||||
#[case(
|
||||
HighlightSpacing::WhenSelected,
|
||||
15, // width
|
||||
1, // spacing
|
||||
None, // selection
|
||||
[
|
||||
Cell::new("ABCDEFGHIJK").column_span(2),
|
||||
Cell::new("12345678901"),
|
||||
Cell::new("XYZXYZXYZXY"),
|
||||
],
|
||||
[
|
||||
"ABCDEFGHIJ 1234",
|
||||
" ", // row 2
|
||||
" ", // row 3
|
||||
])]
|
||||
#[case(
|
||||
HighlightSpacing::WhenSelected,
|
||||
15, // width
|
||||
1, // spacing
|
||||
Some(0), // selection
|
||||
[
|
||||
Cell::new("ABCDEFGHIJK").column_span(2),
|
||||
Cell::new("12345678901"),
|
||||
Cell::new("XYZXYZXYZXY"),
|
||||
],
|
||||
[
|
||||
">>>ABCDEFGH 123",
|
||||
" ", // row 2
|
||||
" ", // row 3
|
||||
])]
|
||||
fn test_table_with_selection_and_column_spans<'line, 'cell, Lines, Cells>(
|
||||
#[case] highlight_spacing: HighlightSpacing,
|
||||
#[case] columns: u16,
|
||||
#[case] spacing: u16,
|
||||
#[case] selection: Option<usize>,
|
||||
#[case] cells: Cells,
|
||||
#[case] expected: Lines,
|
||||
) where
|
||||
Cells: IntoIterator,
|
||||
Cells::Item: Into<Cell<'cell>>,
|
||||
Lines: IntoIterator,
|
||||
Lines::Item: Into<Line<'line>>,
|
||||
{
|
||||
let table = Table::default()
|
||||
.rows(vec![Row::new(cells)])
|
||||
.highlight_spacing(highlight_spacing)
|
||||
.highlight_symbol(">>>")
|
||||
.column_spacing(spacing);
|
||||
let area = Rect::new(0, 0, columns, 3);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut state = TableState::default().with_selected(selection);
|
||||
StatefulWidget::render(table, area, &mut buf, &mut state);
|
||||
assert_eq!(buf, Buffer::with_lines(expected));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ use ratatui_core::widgets::Widget;
|
||||
pub struct Cell<'a> {
|
||||
content: Text<'a>,
|
||||
style: Style,
|
||||
/// The number of columns this cell will extend over
|
||||
pub(crate) column_span: u16,
|
||||
}
|
||||
|
||||
impl<'a> Cell<'a> {
|
||||
@@ -80,6 +82,7 @@ impl<'a> Cell<'a> {
|
||||
Self {
|
||||
content: content.into(),
|
||||
style: Style::default(),
|
||||
column_span: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +116,26 @@ impl<'a> Cell<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the `column_span` of this cell
|
||||
///
|
||||
/// This is a fluent setter method which must be chained or used as it consumes self
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use ratatui::widgets::{Cell, Row};
|
||||
/// let rows = vec![
|
||||
/// Row::new(vec![Cell::new("12345").column_span(2)]),
|
||||
/// Row::new(vec![Cell::new("xx"), Cell::new("yy")]),
|
||||
/// ];
|
||||
/// // "12345",
|
||||
/// // "xx yy",
|
||||
/// ```
|
||||
#[must_use = "method moves the value of self and returns the modified value"]
|
||||
pub const fn column_span(mut self, column_span: u16) -> Self {
|
||||
self.column_span = column_span;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the `Style` of this cell
|
||||
///
|
||||
/// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
|
||||
@@ -167,6 +190,7 @@ where
|
||||
Self {
|
||||
content: content.into(),
|
||||
style: Style::default(),
|
||||
column_span: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user