Compare commits

...

49 Commits

Author SHA1 Message Date
Florian Dehau
c3efcc992e chore: bump MSRV 2021-01-16 21:20:09 +01:00
Florian Dehau
a8c75508e3 feat: automatic state management 2021-01-16 21:20:09 +01:00
Simas Toleikis
8832281dcf Update crossterm to 0.19. 2021-01-04 23:26:04 +01:00
Florian Dehau
853f3d9200 feat(terminal): add a read-only view of the terminal state after the draw call 2021-01-04 22:18:28 +01:00
Florian Dehau
67e996c5f4 feat(examples): add third tab to demo to show colors 2021-01-01 15:41:49 +01:00
Florian Dehau
f09863faa0 Release v0.14.0 2021-01-01 14:51:08 +01:00
Florian Dehau
eb1e3be722 fix(widgets/block): make Block::inner return more accurate results on small areas 2020-12-13 17:21:10 +01:00
Sagie Gur-Ari
4ec902b96f chore: make run-examples available on all platforms (#429)
* Make examples available for all platforms
* limit windows to crossterm_demo only and make q exit demos work
2020-12-13 15:29:31 +01:00
Vadim Chekan
74243394d9 fix(widgets/table): draw table header and border even if rows are empty (#426) 2020-12-08 21:49:16 +01:00
Florian Dehau
e7f263efa7 chore(ci): fix cargo-make cache on windows runner 2020-12-07 23:58:27 +01:00
Florian Dehau
0991145c58 chore(ci): simplify ci workflow (#428)
* chore(ci): simplify ci workflow

* use more up to date action
* restrict actions allowed to run
* cache cargo-make
2020-12-06 18:31:54 +01:00
Florian Dehau
01d2a8588a chore(ci): reduce the number of triggered jobs 2020-12-06 16:58:07 +01:00
Florian Dehau
45431a2649 chore: add first contributing guidelines 2020-12-06 16:58:07 +01:00
Florian Dehau
0b78fb9201 chore: use cargo-make in the CI as well 2020-12-06 16:58:07 +01:00
Florian Dehau
9cdff275cb chore: replace make with cargo-make
`cargo-make` make it easier to provide developers of all platforms an unified build workflow.
2020-12-06 16:58:07 +01:00
Arne Beer
77c6e106e4 doc(examples): Add comments to "list" example and fix list direction (#425)
* Add docs to list example and fix list direction

* List example: review adjustments and typo fixes
2020-12-06 16:33:31 +01:00
Florian Dehau
efdd6bfb19 feat(tests): add tests covering new table features 2020-11-29 23:47:58 +01:00
Florian Dehau
117098d2d2 refactor(examples): add missing margin at the bottom of the header of table in the demo 2020-11-29 23:47:58 +01:00
Florian Dehau
f933d892aa chore: update CHANGELOG 2020-11-29 23:47:58 +01:00
Florian Dehau
5ea54792c0 refactor(widgets/table): more flexible table
- control over the style of each cell and its content using the styling capabilities of Text.
- rows with multiple lines.
- fix panics on small areas.
- less generic type parameters.
2020-11-29 23:47:58 +01:00
Tom Forbes
23a9280db7 chore: add gping to the lists of apps using tui (#422)
* Add gping to the lists of apps using tui
2020-11-29 19:28:53 +01:00
Florian Dehau
79e27b1778 refactor(widgets/gauge): stop using unicode blocks by default 2020-11-29 19:27:34 +01:00
DashEightMate
0a05579a1c feat(widgets/gauge): allow gauge to use unicode block for more descriptive progress (#377)
* gauge now uses unicode blocks for more descriptive progress

* removed unnecessary if

* changed function name to better reflect unicode

* standardized block symbols, added no unicode option, added tests

* formatting

* improved readability

* gauge tests now check color

* formatted
2020-11-29 19:20:08 +01:00
Tom Forbes
0030eb4a13 fix(tests): remove clippy warnings about single char push (#424) 2020-11-29 18:35:52 +01:00
pm100
5bf40343eb fix(widgets/paragraph): handle trailing nbsp in wrapped text (#405) 2020-11-15 20:03:33 +01:00
Florian Dehau
1e35f983c4 doc(style): improve documentation of Style 2020-11-14 21:29:56 +01:00
Florian Dehau
a15ac8870b feat(style): add a method to create a style that reset all properties until that point 2020-11-14 21:29:56 +01:00
Florian Dehau
8a27036a54 fix(widgets/block): allow Block to render on small areas 2020-11-14 20:32:10 +01:00
Florian Dehau
8543523f18 Release v0.13.0 2020-11-14 17:37:21 +01:00
acheronfail
5a9b59866b feat(widgets/listitem): derive PartialEq 2020-11-14 16:51:19 +01:00
Dheepak Krishnamurthy
dc76956215 chore: add taskwarrior-tui to the list of apps using tui-rs (#403) 2020-11-14 16:48:43 +01:00
Kemyt
98fb5e4bbd fix(widgets/table): take borders into account when percentage and ration constraints are used (#385)
* Fix percentage and ratio constraints for table to take borders into account

Percentage and ratio constraints don't take borders into account, which causes
the table to not be correctly aligned. This is easily seen when using 50/50
percentage split with bordered table. However fixing this causes the last column
of table to not get printed, so there is probably another problem with columns
width determination.

* Fix rounding of cassowary solved widths to eliminate imprecisions

* Fix formatting to fit convention

Co-authored-by: Kemyt <kemyt4@gmail.com>
2020-11-14 16:45:36 +01:00
Sebastian Thiel
25ff2e5e61 upgrade crossterm to v0.18
It reduces the amount of dependencies, among other improvements.
2020-09-29 23:28:45 +02:00
Florian Dehau
5050f1ce1c feat(widgets/gauge): add LineGauge variant of Gauge 2020-09-28 00:40:19 +02:00
Florian Dehau
51b691e7ac Release v0.12.0 2020-09-27 19:55:45 +02:00
Florian Dehau
c4cd0a5f31 fix(widgets/chart): use the correct style to draw legend and axis titles
Before this change, the style of the points drawn in the graph area could reused to draw the
title of the axis and the legend. Now the style of these components put on top of the graph area
is solely based on the widget style.
2020-09-27 19:12:35 +02:00
Florian Dehau
41142732ec feat(buffer): add a method to build a Style out of an existing Cell 2020-09-27 19:12:35 +02:00
Kemyt
62495c3bd1 fix(widgets/barchart): fix chart filled more than actual (#383)
* Fix barchart incorrectly showing chart filled more than actual

Determination of how filled the bar should be was incorrectly taking the
entire chart height into account, when in fact it should take height-1, because
of extra line with label. Because of that the chart appeared fuller than it
should and was full before reaching maximum value.

* Add a test case for checking if barchart fills correctly up to max value

Co-authored-by: Kemyt <kemyt4@gmail.com>
2020-09-27 17:15:44 +02:00
Brooks Rady
d00184a7c3 feat(text): extend Text to be stylable and extendable (#361)
* Extend `Text` to be extendable
* Add some documentation
2020-09-27 14:20:14 +02:00
Brooks Rady
ce32d5537d chore: clippy fixes (#386) 2020-09-27 13:41:28 +02:00
Luis Enrique Muñoz Martín
25921fa91a chore: added termchat to "apps using tui" (#371) 2020-09-20 18:23:10 +02:00
luak
932a496c3c chore: add rkm to the list of apps using tui (#376) 2020-09-20 16:43:02 +02:00
Florian Dehau
57862eeda6 Release v0.11.0 2020-09-20 16:34:44 +02:00
Florian Dehau
11df94d601 fix(examples): avoid panic when computing event poll timeout in crossterm demo (#380) 2020-09-20 15:51:44 +02:00
Brooks Rady
0abaa20de9 refactor: clean up some folds (#362) 2020-09-20 15:02:06 +02:00
Amjad Alsharafi
c35a1dd79f feat(widgets/canvas): added type Block in canvas markers (#350)
This allows for clearer colors than using Dot, especially when
decreasing the size of the terminal font in order to increase the
resolution of the canvas
2020-09-20 14:57:35 +02:00
alvinhochun
e0b2572eba refactor(backend/crossterm): support more style modifiers on Windows and fix build with Crossterm 0.17.8 (#368)
* Support more style modifiers on Windows
* Change Crossterm backend to write directly to buffer instead of String

Crossterm might actually do WinAPI calls instead of writing ANSI excape
codes so writing to an intermediate String may cause issues on older
versions of Windows. It also fails to compile with Crossterm 0.17.8 due
to Crossterm now expecting the writer to support `flush`, which String
doesn't.

Fixes #373
2020-09-20 14:54:16 +02:00
Cory Forsstrom
aada695b3f chore: add tickrs to apps using tui (#351) 2020-08-23 21:20:30 +02:00
Florian Dehau
90f3858eff feat(backend): keep the internal buffers in sync when the terminal is cleared. 2020-08-23 21:13:12 +02:00
46 changed files with 3148 additions and 999 deletions

View File

@@ -1,81 +1,85 @@
on: [push, pull_request]
on:
push:
branches:
- master
pull_request:
branches:
- master
name: CI
env:
CI_CARGO_MAKE_VERSION: 0.32.9
jobs:
linux:
name: Linux
runs-on: ubuntu-latest
strategy:
matrix:
rust: ["1.44.0", "stable"]
rust: ["1.46.0", "stable"]
steps:
- name: "Install dependencies"
run: sudo apt-get install libncurses5-dev
- uses: hecrj/setup-rust-action@967aec96c6a27a0ce15c1dac3aaba332d60565e2
with:
rust-version: ${{ matrix.rust }}
components: rustfmt,clippy
- uses: actions/checkout@v1
- uses: actions-rs/toolchain@v1
- name: "Get cargo bin directory"
id: cargo-bin-dir
run: echo "::set-output name=dir::$HOME/.cargo/bin"
- name: "Cache cargo make"
id: cache-cargo-make
uses: actions/cache@v2
with:
profile: default
toolchain: ${{ matrix.rust }}
override: true
path: ${{ steps.cargo-bin-dir.outputs.dir }}/cargo-make
key: ${{ runner.os }}-${{ matrix.rust }}-cargo-make-${{ env.CI_CARGO_MAKE_VERSION }}
- name: "Install cargo-make"
if: steps.cache-cargo-make.outputs.cache-hit != 'true'
run: cargo install cargo-make --version ${{ env.CI_CARGO_MAKE_VERSION }}
- name: "Format"
uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
run: cargo make fmt
- name: "Check"
uses: actions-rs/cargo@v1
with:
command: check
args: --examples
- name: "Check (crossterm)"
uses: actions-rs/cargo@v1
with:
command: check
args: --no-default-features --features=crossterm --example crossterm_demo
- name: "Check (rustbox)"
uses: actions-rs/cargo@v1
with:
command: test
args: --no-default-features --features=rustbox --example rustbox_demo
- name: "Check (curses)"
uses: actions-rs/cargo@v1
with:
command: check
args: --no-default-features --features=curses --example curses_demo
run: cargo make check
- name: "Test"
uses: actions-rs/cargo@v1
with:
command: test
run: cargo make test
env:
RUST_BACKTRACE: full
- name: "Clippy"
uses: actions-rs/cargo@v1
with:
command: clippy
args: --all-targets --all-features -- -D warnings
run: cargo make clippy
windows:
name: Windows
runs-on: windows-latest
strategy:
matrix:
rust: ["1.44.0", "stable"]
rust: ["1.46.0", "stable"]
steps:
- uses: actions/checkout@v1
- uses: actions-rs/toolchain@v1
- uses: hecrj/setup-rust-action@967aec96c6a27a0ce15c1dac3aaba332d60565e2
with:
profile: default
toolchain: ${{ matrix.rust }}
override: true
- name: "Check (crossterm)"
uses: actions-rs/cargo@v1
rust-version: ${{ matrix.rust }}
components: rustfmt,clippy
- uses: actions/checkout@v1
- name: "Get cargo bin directory"
id: cargo-bin-dir
run: echo "::set-output name=dir::$HOME\.cargo\bin"
- name: "Cache cargo make"
id: cache-cargo-make
uses: actions/cache@v2
with:
command: check
args: --no-default-features --features=crossterm --example crossterm_demo
- name: "Test (crossterm)"
uses: actions-rs/cargo@v1
with:
command: test
args: --no-default-features --features=crossterm --tests --examples
path: ${{ steps.cargo-bin-dir.outputs.dir }}\cargo-make.exe
key: ${{ runner.os }}-${{ matrix.rust }}-cargo-make-${{ env.CI_CARGO_MAKE_VERSION }}
- name: "Install cargo-make"
if: steps.cache-cargo-make.outputs.cache-hit != 'true'
run: cargo install cargo-make --version ${{ env.CI_CARGO_MAKE_VERSION }}
- name: "Format"
run: cargo make fmt
- name: "Check"
run: cargo make check
- name: "Test"
run: cargo make test
env:
RUST_BACKTRACE: full
- name: "Clippy"
run: cargo make clippy

View File

@@ -2,8 +2,112 @@
## To be released
## v0.14.0 - 2021-01-01
### Breaking changes
#### New API for the Table widget
The `Table` widget got a lot of improvements that should make it easier to work with:
* It should not longer panic when rendered on small areas.
* `Row`s are now a collection of `Cell`s, themselves wrapping a `Text`. This means you can style
the entire `Table`, an entire `Row`, an entire `Cell` and rely on the styling capabilities of
`Text` to get full control over the look of your `Table`.
* `Row`s can have multiple lines.
* The header is now optional and is just another `Row` always visible at the top.
* `Row`s can have a bottom margin.
* The header alignment is no longer off when an item is selected.
Taking the example of the code in `examples/demo/ui.rs`, this is what you may have to change:
```diff
let failure_style = Style::default()
.fg(Color::Red)
.add_modifier(Modifier::RAPID_BLINK | Modifier::CROSSED_OUT);
- let header = ["Server", "Location", "Status"];
let rows = app.servers.iter().map(|s| {
let style = if s.status == "Up" {
up_style
} else {
failure_style
};
- Row::StyledData(vec![s.name, s.location, s.status].into_iter(), style)
+ Row::new(vec![s.name, s.location, s.status]).style(style)
});
- let table = Table::new(header.iter(), rows)
+ let table = Table::new(rows)
+ .header(
+ Row::new(vec!["Server", "Location", "Status"])
+ .style(Style::default().fg(Color::Yellow))
+ .bottom_margin(1),
+ )
.block(Block::default().title("Servers").borders(Borders::ALL))
- .header_style(Style::default().fg(Color::Yellow))
.widths(&[
Constraint::Length(15),
Constraint::Length(15),
```
Here, we had to:
- Change the way we construct [`Row`](https://docs.rs/tui/*/tui/widgets/struct.Row.html) which is no
longer an `enum` but a `struct`. It accepts anything that can be converted to an iterator of things
that can be converted to a [`Cell`](https://docs.rs/tui/*/tui/widgets/struct.Cell.html)
- The header is no longer a required parameter so we use
[`Table::header`](https://docs.rs/tui/*/tui/widgets/struct.Table.html#method.header) to set it.
`Table::header_style` has been removed since the style can be directly set using
[`Row::style`](https://docs.rs/tui/*/tui/widgets/struct.Row.html#method.style). In addition, we want
to preserve the old margin between the header and the rest of the rows so we add a bottom margin to
the header using
[`Row::bottom_margin`](https://docs.rs/tui/*/tui/widgets/struct.Row.html#method.bottom_margin).
You may want to look at the documentation of the different types to get a better understanding:
- [`Table`](https://docs.rs/tui/*/tui/widgets/struct.Table.html)
- [`Row`](https://docs.rs/tui/*/tui/widgets/struct.Row.html)
- [`Cell`](https://docs.rs/tui/*/tui/widgets/struct.Cell.html)
### Fixes
- Fix handling of Non Breaking Space (NBSP) in wrapped text in `Paragraph` widget.
### Features
- Add `Style::reset` to create a `Style` resetting all styling properties when applied.
- Add an option to render the `Gauge` widget with unicode blocks.
- Manage common project tasks with `cargo-make` rather than `make` for easier on-boarding.
## v0.13.0 - 2020-11-14
### Features
* Add `LineGauge` widget which is a more compact variant of the existing `Gauge`.
* Bump `crossterm` to 0.18
### Fixes
* Take into account the borders of the `Table` widget when the widths of columns is controlled by
`Percentage` and `Ratio` constraints.
## v0.12.0 - 2020-09-27
### Features
* Make it easier to work with string with multiple lines in `Text` (#361).
### Fixes
* Fix a style leak in `Graph` so components drawn on top of the plotted data (i.e legend and axis
titles) are not affected by the style of the `Dataset`s (#388).
* Make sure `BarChart` shows bars with the max height only when the plotted data is actually equal
to the max (#383).
## v0.11.0 - 2020-09-20
### Features
* Add the dot character as a new type of canvas marker (#350).
* Support more style modifiers on Windows (#368).
### Fixes
* Clearing the terminal through `Terminal::clear` will cause the whole UI to be redrawn (#380).
* Fix incorrect output when the first diff to draw is on the second cell of the terminal (#347).
## v0.10.0 - 2020-07-17

26
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,26 @@
# Contributing
[cargo-make]: https://github.com/sagiegurari/cargo-make "cargo-make"
`tui` is an ordinary Rust project where common tasks are managed with [cargo-make]. It wraps common
`cargo` commands with sane defaults depending on your platform of choice. Bulding the project should
be as easy as running `cargo make`.
## Continous Integration
We use Github Actions for the CI where we perform the following checks:
- The code should compile on `stable` and the Minimum Supported Rust Version (MSRV).
- The tests (docs, lib, tests and examples) should pass.
- The code should conform to the default format enforced by `rustfmt`.
- The code should not contain common style issues `clippy`.
You can also check most of those things yourself locally using `cargo make ci` which will offer you
a shorter feedback loop.
## Tests
The test coverage of the crate is far from being ideal but we already have a fair amount of tests in
place. Beside the usal doc and unit tests, one of the most valuable test you can write for `tui` is
a test again the `TestBackend` which allows you to assert the content of the output buffer that
would have been flushed to the termminal after a given draw call (see `widgets_block_renders` in
[tests/widgets_block.rs](./tests/widget_block.rs) for an example).

View File

@@ -1,15 +1,16 @@
[package]
name = "tui"
version = "0.10.0"
version = "0.14.0"
authors = ["Florian Dehau <work@fdehau.com>"]
description = """
A library to build rich terminal user interfaces or dashboards
"""
documentation = "https://docs.rs/tui/0.10.0/tui/"
documentation = "https://docs.rs/tui/0.14.0/tui/"
keywords = ["tui", "terminal", "dashboard"]
repository = "https://github.com/fdehau/tui-rs"
readme = "README.md"
license = "MIT"
exclude = ["assets/*", ".github"]
exclude = ["assets/*", ".github", "Makefile.toml", "CONTRIBUTING.md", "*.log", "tags"]
autoexamples = true
edition = "2018"
@@ -26,7 +27,7 @@ unicode-segmentation = "1.2"
unicode-width = "0.1"
termion = { version = "1.5", optional = true }
rustbox = { version = "0.11", optional = true }
crossterm = { version = "0.17", optional = true }
crossterm = { version = "0.19", optional = true }
easycurses = { version = "0.12.2", optional = true }
pancurses = { version = "0.16.1", optional = true, features = ["win32a"] }
serde = { version = "1", "optional" = true, features = ["derive"]}

116
Makefile
View File

@@ -1,116 +0,0 @@
SHELL=/bin/bash
# ================================ Cargo ======================================
RUST_CHANNEL ?= stable
CARGO_FLAGS =
RUSTUP_INSTALLED = $(shell command -v rustup 2> /dev/null)
TEST_FILTER ?=
ifndef RUSTUP_INSTALLED
CARGO = cargo
else
ifdef CI
CARGO = cargo
else
CARGO = rustup run $(RUST_CHANNEL) cargo
endif
endif
# ================================ Help =======================================
.PHONY: help
help: ## Print all the available commands
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
# =============================== Build =======================================
.PHONY: check
check: ## Validate the project code
$(CARGO) check
.PHONY: build
build: ## Build the project in debug mode
$(CARGO) build $(CARGO_FLAGS)
.PHONY: release
release: CARGO_FLAGS += --release
release: build ## Build the project in release mode
# ================================ Lint =======================================
.PHONY: lint
lint: fmt clippy ## Lint project files
.PHONY: fmt
fmt: ## Check the format of the source code
cargo fmt --all -- --check
.PHONY: clippy
clippy: ## Check the style of the source code and catch common errors
$(CARGO) clippy --all-targets --all-features -- -D warnings
# ================================ Test =======================================
.PHONY: test
test: ## Run the tests
$(CARGO) test --all-features $(TEST_FILTER)
# =============================== Examples ====================================
.PHONY: build-examples
build-examples: ## Build all examples
@$(CARGO) build --release --examples --all-features
.PHONY: run-examples
run-examples: build-examples ## Run all examples
@for file in examples/*.rs; do \
name=$$(basename $${file/.rs/}); \
$(CARGO) run --all-features --release --example $$name; \
done;
# ================================ Doc ========================================
.PHONY: doc
doc: RUST_CHANNEL = nightly
doc: ## Build the documentation (available at ./target/doc)
$(CARGO) doc
# ================================= Watch =====================================
# Requires watchman and watchman-make (https://facebook.github.io/watchman/docs/install.html)
.PHONY: watch
watch: ## Watch file changes and build the project if any
watchman-make -p 'src/**/*.rs' -t check build
.PHONY: watch-test
watch-test: ## Watch files changes and run the tests if any
watchman-make -p 'src/**/*.rs' 'tests/**/*.rs' 'examples/**/*.rs' -t test
.PHONY: watch-doc
watch-doc: RUST_CHANNEL = nightly
watch-doc: ## Watch file changes and rebuild the documentation if any
$(CARGO) watch -x doc -x 'test --doc'
# ================================= Pipelines =================================
.PHONY: stable
stable: RUST_CHANNEL = stable
stable: build lint test ## Run build and tests for stable
.PHONY: beta
beta: RUST_CHANNEL = beta
beta: build lint test ## Run build and tests for beta
.PHONY: nightly
nightly: RUST_CHANNEL = nightly
nightly: build lint test ## Run build, lint and tests for nightly

142
Makefile.toml Normal file
View File

@@ -0,0 +1,142 @@
[config]
skip_core_tasks = true
[env.TUI_FEATURES]
source = "${CARGO_MAKE_RUST_TARGET_OS}"
default_value = "unknown"
[env.TUI_FEATURES.mapping]
linux = "serde,crossterm,termion,rustbox,curses"
macos = "serde,crossterm,termion,rustbox,curses"
windows = "serde,crossterm"
[tasks.default]
dependencies = [
"check",
]
[tasks.ci]
dependencies = [
"fmt",
"check",
"test",
"clippy",
]
[tasks.fmt]
command = "cargo"
args = [
"fmt",
"--all",
"--",
"--check",
]
[tasks.check]
command = "cargo"
args = [
"check",
"--no-default-features",
"--features",
"${TUI_FEATURES}",
"--all-targets",
]
[tasks.build]
command = "cargo"
args = [
"build",
"--no-default-features",
"--features",
"${TUI_FEATURES}",
"--all-targets",
]
[tasks.clippy]
command = "cargo"
args = [
"clippy",
"--no-default-features",
"--features",
"${TUI_FEATURES}",
"--",
"-D",
"warnings",
]
[tasks.test]
linux_alias = "test-unix"
mac_alias = "test-unix"
windows_alias = "test-windows"
[tasks.test-unix]
command = "cargo"
args = [
"test",
"--no-default-features",
"--features",
"${TUI_FEATURES}"
]
# Documentation tests cannot be run on Windows for now
[tasks.test-windows]
command = "cargo"
args = [
"test",
"--no-default-features",
"--features",
"${TUI_FEATURES}",
"--lib",
"--tests",
"--examples",
]
[tasks.run-example]
private = true
condition = { env_set = ["TUI_EXAMPLE_NAME", "TUI_FEATURES"] }
command = "cargo"
args = [
"run",
"--features",
"${TUI_FEATURES}",
"--release",
"--example",
"${TUI_EXAMPLE_NAME}"
]
[tasks.run-example-windows]
private = true
condition = { env = {"TUI_EXAMPLE_NAME" = "crossterm_demo"} }
run_task = "run-example"
[tasks.run-example-router]
private = true
run_task = [
{ name = "run-example-windows", condition = { platforms = ["window"] } },
{ name = "run-example" }
]
[tasks.build-examples]
condition = { env_set = ["TUI_FEATURES"] }
command = "cargo"
args = [
"build",
"--examples",
"--features",
"${TUI_FEATURES}",
"--release"
]
[tasks.run-examples]
dependencies = ["build-examples"]
script = '''
#!@duckscript
files = glob_array ./examples/*.rs
for file in ${files}
name = basename ${file}
name = substring ${name} -3
set_env TUI_EXAMPLE_NAME ${name}
cm_run_task run-example-router
end
'''

View File

@@ -104,6 +104,11 @@ You can run all examples by running `make run-examples`.
* [rust-sadari-cli](https://github.com/24seconds/rust-sadari-cli)
* [desed](https://github.com/SoptikHa2/desed)
* [diskonaut](https://github.com/imsnif/diskonaut)
* [tickrs](https://github.com/tarkah/tickrs)
* [rusty-krab-manager](https://github.com/aryakaul/rusty-krab-manager)
* [termchat](https://github.com/lemunozm/termchat)
* [taskwarrior-tui](https://github.com/kdheepak/taskwarrior-tui)
* [gping](https://github.com/orf/gping/)
### Alternatives

View File

@@ -12,7 +12,7 @@ use crossterm::{
};
use std::{
error::Error,
io::{stdout, Write},
io::stdout,
sync::mpsc,
thread,
time::{Duration, Instant},
@@ -55,7 +55,10 @@ fn main() -> Result<(), Box<dyn Error>> {
let mut last_tick = Instant::now();
loop {
// poll for tick rate duration, if no events, sent tick event.
if event::poll(tick_rate - last_tick.elapsed()).unwrap() {
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if event::poll(timeout).unwrap() {
if let CEvent::Key(key) = event::read().unwrap() {
tx.send(Event::Input(key)).unwrap();
}

View File

@@ -5,7 +5,10 @@ use crate::util::event::{Event, Events};
use std::{error::Error, io};
use termion::{event::Key, input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen};
use tui::{
backend::TermionBackend, buffer::Buffer, layout::Rect, style::Style, widgets::Widget, Terminal,
backend::TermionBackend,
style::Style,
widgets::{RenderContext, Widget},
Terminal,
};
struct Label<'a> {
@@ -19,8 +22,11 @@ impl<'a> Default for Label<'a> {
}
impl<'a> Widget for Label<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
buf.set_string(area.left(), area.top(), self.text, Style::default());
type State = ();
fn render(self, ctx: &mut RenderContext<Self::State>) {
ctx.buffer
.set_string(ctx.area.left(), ctx.area.top(), self.text, Style::default());
}
}

View File

@@ -1,4 +1,4 @@
use crate::util::{RandomSignal, SinSignal, StatefulList, TabsState};
use crate::util::{RandomSignal, SelectableList, SinSignal, TabsState};
const TASKS: [&str; 24] = [
"Item1", "Item2", "Item3", "Item4", "Item5", "Item6", "Item7", "Item8", "Item9", "Item10",
@@ -110,8 +110,8 @@ pub struct App<'a> {
pub show_chart: bool,
pub progress: f64,
pub sparkline: Signal<RandomSignal>,
pub tasks: StatefulList<&'a str>,
pub logs: StatefulList<(&'a str, &'a str)>,
pub tasks: SelectableList<&'a str>,
pub logs: Vec<(&'a str, &'a str)>,
pub signals: Signals,
pub barchart: Vec<(&'a str, u64)>,
pub servers: Vec<Server<'a>>,
@@ -129,7 +129,7 @@ impl<'a> App<'a> {
App {
title,
should_quit: false,
tabs: TabsState::new(vec!["Tab0", "Tab1"]),
tabs: TabsState::new(vec!["Tab0", "Tab1", "Tab2"]),
show_chart: true,
progress: 0.0,
sparkline: Signal {
@@ -137,8 +137,8 @@ impl<'a> App<'a> {
points: sparkline_points,
tick_rate: 1,
},
tasks: StatefulList::with_items(TASKS.to_vec()),
logs: StatefulList::with_items(LOGS.to_vec()),
tasks: SelectableList::with_items(TASKS.to_vec()),
logs: Vec::from(LOGS),
signals: Signals {
sin1: Signal {
source: sin_signal,
@@ -221,8 +221,8 @@ impl<'a> App<'a> {
self.sparkline.on_tick();
self.signals.on_tick();
let log = self.logs.items.pop().unwrap();
self.logs.items.insert(0, log);
let log = self.logs.pop().unwrap();
self.logs.insert(0, log);
let event = self.barchart.pop().unwrap();
self.barchart.insert(0, event);

View File

@@ -7,8 +7,8 @@ use tui::{
text::{Span, Spans},
widgets::canvas::{Canvas, Line, Map, MapResolution, Rectangle},
widgets::{
Axis, BarChart, Block, Borders, Chart, Dataset, Gauge, List, ListItem, Paragraph, Row,
Sparkline, Table, Tabs, Wrap,
Axis, BarChart, Block, Borders, Cell, Chart, Dataset, Gauge, LineGauge, List, ListItem,
Paragraph, Row, Sparkline, Table, Tabs, Wrap,
},
Frame,
};
@@ -31,6 +31,7 @@ pub fn draw<B: Backend>(f: &mut Frame<B>, app: &mut App) {
match app.tabs.index {
0 => draw_first_tab(f, app, chunks[1]),
1 => draw_second_tab(f, app, chunks[1]),
2 => draw_third_tab(f, app, chunks[1]),
_ => {}
};
}
@@ -42,8 +43,8 @@ where
let chunks = Layout::default()
.constraints(
[
Constraint::Length(7),
Constraint::Min(7),
Constraint::Length(9),
Constraint::Min(8),
Constraint::Length(7),
]
.as_ref(),
@@ -59,7 +60,14 @@ where
B: Backend,
{
let chunks = Layout::default()
.constraints([Constraint::Length(2), Constraint::Length(3)].as_ref())
.constraints(
[
Constraint::Length(2),
Constraint::Length(3),
Constraint::Length(1),
]
.as_ref(),
)
.margin(1)
.split(area);
let block = Block::default().borders(Borders::ALL).title("Graphs");
@@ -88,6 +96,17 @@ where
symbols::bar::THREE_LEVELS
});
f.render_widget(sparkline, chunks[1]);
let line_gauge = LineGauge::default()
.block(Block::default().title("LineGauge:"))
.gauge_style(Style::default().fg(Color::Magenta))
.line_set(if app.enhanced_graphics {
symbols::line::THICK
} else {
symbols::line::NORMAL
})
.ratio(app.progress);
f.render_widget(line_gauge, chunks[2]);
}
fn draw_charts<B>(f: &mut Frame<B>, app: &mut App, area: Rect)
@@ -121,10 +140,11 @@ where
.map(|i| ListItem::new(vec![Spans::from(Span::raw(*i))]))
.collect();
let tasks = List::new(tasks)
.block(Block::default().borders(Borders::ALL).title("List"))
.select(app.tasks.selected)
.block(Block::default().borders(Borders::ALL).title("Tasks"))
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
f.render_stateful_widget(tasks, chunks[0], &mut app.tasks.state);
f.render_widget(tasks, chunks[0]);
// Draw logs
let info_style = Style::default().fg(Color::Blue);
@@ -133,7 +153,6 @@ where
let critical_style = Style::default().fg(Color::Red);
let logs: Vec<ListItem> = app
.logs
.items
.iter()
.map(|&(evt, level)| {
let s = match level {
@@ -149,8 +168,8 @@ where
ListItem::new(content)
})
.collect();
let logs = List::new(logs).block(Block::default().borders(Borders::ALL).title("List"));
f.render_stateful_widget(logs, chunks[1], &mut app.logs.state);
let logs = List::new(logs).block(Block::default().borders(Borders::ALL).title("Logs"));
f.render_widget(logs, chunks[1]);
}
let barchart = BarChart::default()
@@ -290,18 +309,21 @@ where
let failure_style = Style::default()
.fg(Color::Red)
.add_modifier(Modifier::RAPID_BLINK | Modifier::CROSSED_OUT);
let header = ["Server", "Location", "Status"];
let rows = app.servers.iter().map(|s| {
let style = if s.status == "Up" {
up_style
} else {
failure_style
};
Row::StyledData(vec![s.name, s.location, s.status].into_iter(), style)
Row::new(vec![s.name, s.location, s.status]).style(style)
});
let table = Table::new(header.iter(), rows)
let table = Table::new(rows)
.header(
Row::new(vec!["Server", "Location", "Status"])
.style(Style::default().fg(Color::Yellow))
.bottom_margin(1),
)
.block(Block::default().title("Servers").borders(Borders::ALL))
.header_style(Style::default().fg(Color::Yellow))
.widths(&[
Constraint::Length(15),
Constraint::Length(15),
@@ -353,3 +375,51 @@ where
.y_bounds([-90.0, 90.0]);
f.render_widget(map, chunks[1]);
}
fn draw_third_tab<B>(f: &mut Frame<B>, _app: &mut App, area: Rect)
where
B: Backend,
{
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(area);
let colors = [
Color::Reset,
Color::Black,
Color::Red,
Color::Green,
Color::Yellow,
Color::Blue,
Color::Magenta,
Color::Cyan,
Color::Gray,
Color::DarkGray,
Color::LightRed,
Color::LightGreen,
Color::LightYellow,
Color::LightBlue,
Color::LightMagenta,
Color::LightCyan,
Color::White,
];
let items: Vec<Row> = colors
.iter()
.map(|c| {
let cells = vec![
Cell::from(Span::raw(format!("{:?}: ", c))),
Cell::from(Span::styled("Foreground", Style::default().fg(*c))),
Cell::from(Span::styled("Background", Style::default().bg(*c))),
];
Row::new(cells)
})
.collect();
let table = Table::new(items)
.block(Block::default().title("Colors").borders(Borders::ALL))
.widths(&[
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
]);
f.render_widget(table, chunks[0]);
}

View File

@@ -106,7 +106,8 @@ fn main() -> Result<(), Box<dyn Error>> {
.add_modifier(Modifier::ITALIC),
)
.percent(app.progress4)
.label(label);
.label(label)
.use_unicode(true);
f.render_widget(gauge, chunks[3]);
})?;

View File

@@ -3,7 +3,7 @@ mod util;
use crate::util::{
event::{Event, Events},
StatefulList,
SelectableList,
};
use std::{error::Error, io};
use termion::{event::Key, input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen};
@@ -16,15 +16,21 @@ use tui::{
Terminal,
};
/// This struct holds the current state of the app. In particular, it has the `items` field which is a wrapper
/// around `ListState`. Keeping track of the items state let us render the associated widget with its state
/// and have access to features such as natural scrolling.
///
/// Check the event handling at the bottom to see how to change the state on incoming events.
/// Check the drawing logic for items on how to specify the highlighting style for selected items.
struct App<'a> {
items: StatefulList<(&'a str, usize)>,
items: SelectableList<(&'a str, usize)>,
events: Vec<(&'a str, &'a str)>,
}
impl<'a> App<'a> {
fn new() -> App<'a> {
App {
items: StatefulList::with_items(vec![
items: SelectableList::with_items(vec![
("Item0", 1),
("Item1", 2),
("Item2", 1),
@@ -82,9 +88,11 @@ impl<'a> App<'a> {
}
}
/// Rotate through the event list.
/// This only exists to simulate some kind of "progress"
fn advance(&mut self) {
let event = self.events.pop().unwrap();
self.events.insert(0, event);
let event = self.events.remove(0);
self.events.push(event);
}
}
@@ -98,16 +106,18 @@ fn main() -> Result<(), Box<dyn Error>> {
let events = Events::new();
// App
// Create a new app with some exapmle state
let mut app = App::new();
loop {
terminal.draw(|f| {
// Create two chunks with equal horizontal screen space
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(f.size());
// Iterate through all elements in the `items` app and append some debug text to it.
let items: Vec<ListItem> = app
.items
.items
@@ -123,20 +133,29 @@ fn main() -> Result<(), Box<dyn Error>> {
ListItem::new(lines).style(Style::default().fg(Color::Black).bg(Color::White))
})
.collect();
// Create a List from all list items and highlight the currently selected one
let items = List::new(items)
.block(Block::default().borders(Borders::ALL).title("List"))
.select(app.items.selected)
.highlight_style(
Style::default()
.bg(Color::LightGreen)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
f.render_stateful_widget(items, chunks[0], &mut app.items.state);
// We can now render the item list
f.render_widget(items, chunks[0]);
// Let's do the same for the events.
// The event list doesn't have any state and only displays the current state of the list.
let events: Vec<ListItem> = app
.events
.iter()
.map(|&(evt, level)| {
.rev()
.map(|&(event, level)| {
// Colorcode the level depending on its type
let s = match level {
"CRITICAL" => Style::default().fg(Color::Red),
"ERROR" => Style::default().fg(Color::Magenta),
@@ -144,6 +163,7 @@ fn main() -> Result<(), Box<dyn Error>> {
"INFO" => Style::default().fg(Color::Blue),
_ => Style::default(),
};
// Add a example datetime and apply proper spacing between them
let header = Spans::from(vec![
Span::styled(format!("{:<9}", level), s),
Span::raw(" "),
@@ -152,7 +172,14 @@ fn main() -> Result<(), Box<dyn Error>> {
Style::default().add_modifier(Modifier::ITALIC),
),
]);
let log = Spans::from(vec![Span::raw(evt)]);
// The event gets it's own line
let log = Spans::from(vec![Span::raw(event)]);
// Here several things happen:
// 1. Add a `---` spacing line above the final list entry
// 2. Add the Level + datetime
// 3. Add a spacer line
// 4. Add the actual event
ListItem::new(vec![
Spans::from("-".repeat(chunks[1].width as usize)),
header,
@@ -167,6 +194,10 @@ fn main() -> Result<(), Box<dyn Error>> {
f.render_widget(events_list, chunks[1]);
})?;
// This is a simple example on how to handle events
// 1. This breaks the loop and exits the program on `q` button press.
// 2. The `up`/`down` keys change the currently selected item in the App's `items` list.
// 3. `left` unselects the current item.
match events.next()? {
Event::Input(input) => match input {
Key::Char('q') => {

View File

@@ -8,26 +8,26 @@ use tui::{
backend::TermionBackend,
layout::{Constraint, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Row, Table, TableState},
widgets::{Block, Borders, Cell, Row, Table},
Terminal,
};
pub struct StatefulTable<'a> {
state: TableState,
pub struct SelectableTable<'a> {
selected: Option<usize>,
items: Vec<Vec<&'a str>>,
}
impl<'a> StatefulTable<'a> {
fn new() -> StatefulTable<'a> {
StatefulTable {
state: TableState::default(),
impl<'a> SelectableTable<'a> {
fn new() -> SelectableTable<'a> {
SelectableTable {
selected: None,
items: vec![
vec!["Row11", "Row12", "Row13"],
vec!["Row21", "Row22", "Row23"],
vec!["Row31", "Row32", "Row33"],
vec!["Row41", "Row42", "Row43"],
vec!["Row51", "Row52", "Row53"],
vec!["Row61", "Row62", "Row63"],
vec!["Row61", "Row62\nTest", "Row63"],
vec!["Row71", "Row72", "Row73"],
vec!["Row81", "Row82", "Row83"],
vec!["Row91", "Row92", "Row93"],
@@ -44,32 +44,19 @@ impl<'a> StatefulTable<'a> {
],
}
}
pub fn next(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i >= self.items.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.state.select(Some(i));
self.selected = self
.selected
.map(|i| if i >= self.items.len() - 1 { 0 } else { i + 1 })
.or(Some(0));
}
pub fn previous(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.items.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.state.select(Some(i));
self.selected = self
.selected
.map(|i| if i == 0 { self.items.len() - 1 } else { i - 1 })
.or(Some(0));
}
}
@@ -83,7 +70,7 @@ fn main() -> Result<(), Box<dyn Error>> {
let events = Events::new();
let mut table = StatefulTable::new();
let mut table = SelectableTable::new();
// Input
loop {
@@ -93,25 +80,37 @@ fn main() -> Result<(), Box<dyn Error>> {
.margin(5)
.split(f.size());
let selected_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let normal_style = Style::default().fg(Color::White);
let header = ["Header1", "Header2", "Header3"];
let rows = table
.items
let selected_style = Style::default().add_modifier(Modifier::REVERSED);
let normal_style = Style::default().bg(Color::Blue);
let header_cells = ["Header1", "Header2", "Header3"]
.iter()
.map(|i| Row::StyledData(i.iter(), normal_style));
let t = Table::new(header.iter(), rows)
.map(|h| Cell::from(*h).style(Style::default().fg(Color::Red)));
let header = Row::new(header_cells)
.style(normal_style)
.height(1)
.bottom_margin(1);
let rows = table.items.iter().map(|item| {
let height = item
.iter()
.map(|content| content.chars().filter(|c| *c == '\n').count())
.max()
.unwrap_or(0)
+ 1;
let cells = item.iter().map(|c| Cell::from(*c));
Row::new(cells).height(height as u16).bottom_margin(1)
});
let t = Table::new(rows)
.header(header)
.block(Block::default().borders(Borders::ALL).title("Table"))
.highlight_style(selected_style)
.highlight_symbol(">> ")
.select(table.selected)
.widths(&[
Constraint::Percentage(50),
Constraint::Length(30),
Constraint::Max(10),
]);
f.render_stateful_widget(t, rects[0], &mut table.state);
f.render_widget(t, rects[0]);
})?;
if let Event::Input(key) = events.next()? {

View File

@@ -3,7 +3,6 @@ pub mod event;
use rand::distributions::{Distribution, Uniform};
use rand::rngs::ThreadRng;
use tui::widgets::ListState;
#[derive(Clone)]
pub struct RandomSignal {
@@ -77,55 +76,53 @@ impl<'a> TabsState<'a> {
}
}
pub struct StatefulList<T> {
pub state: ListState,
pub struct SelectableList<T> {
pub selected: Option<usize>,
pub items: Vec<T>,
}
impl<T> StatefulList<T> {
pub fn new() -> StatefulList<T> {
StatefulList {
state: ListState::default(),
impl<T> SelectableList<T> {
pub fn new() -> SelectableList<T> {
Self {
selected: None,
items: Vec::new(),
}
}
pub fn with_items(items: Vec<T>) -> StatefulList<T> {
StatefulList {
state: ListState::default(),
pub fn with_items(items: Vec<T>) -> SelectableList<T> {
Self {
selected: None,
items,
}
}
pub fn next(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i >= self.items.len() - 1 {
0
} else {
self.selected = self
.selected
.map(|i| {
if i < self.items.len().saturating_sub(1) {
i + 1
} else {
0
}
}
None => 0,
};
self.state.select(Some(i));
})
.or(Some(0));
}
pub fn previous(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.items.len() - 1
} else {
self.selected = self
.selected
.map(|i| {
if i > 0 {
i - 1
} else {
self.items.len().saturating_sub(1)
}
}
None => 0,
};
self.state.select(Some(i));
})
.or(Some(0));
}
pub fn unselect(&mut self) {
self.state.select(None);
self.selected = None;
}
}

View File

@@ -13,10 +13,7 @@ use crossterm::{
},
terminal::{self, Clear, ClearType},
};
use std::{
fmt,
io::{self, Write},
};
use std::io::{self, Write};
pub struct CrosstermBackend<W: Write> {
buffer: W,
@@ -52,9 +49,6 @@ where
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
use fmt::Write;
let mut string = String::with_capacity(content.size_hint().0 * 3);
let mut fg = Color::Reset;
let mut bg = Color::Reset;
let mut modifier = Modifier::empty();
@@ -62,7 +56,7 @@ where
for (x, y, cell) in content {
// Move the cursor if the previous location was not (x - 1, y)
if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) {
map_error(queue!(string, MoveTo(x, y)))?;
map_error(queue!(self.buffer, MoveTo(x, y)))?;
}
last_pos = Some((x, y));
if cell.modifier != modifier {
@@ -70,26 +64,25 @@ where
from: modifier,
to: cell.modifier,
};
diff.queue(&mut string)?;
diff.queue(&mut self.buffer)?;
modifier = cell.modifier;
}
if cell.fg != fg {
let color = CColor::from(cell.fg);
map_error(queue!(string, SetForegroundColor(color)))?;
map_error(queue!(self.buffer, SetForegroundColor(color)))?;
fg = cell.fg;
}
if cell.bg != bg {
let color = CColor::from(cell.bg);
map_error(queue!(string, SetBackgroundColor(color)))?;
map_error(queue!(self.buffer, SetBackgroundColor(color)))?;
bg = cell.bg;
}
string.push_str(&cell.symbol);
map_error(queue!(self.buffer, Print(&cell.symbol)))?;
}
map_error(queue!(
self.buffer,
Print(string),
SetForegroundColor(CColor::Reset),
SetBackgroundColor(CColor::Reset),
SetAttribute(CAttribute::Reset)
@@ -165,11 +158,10 @@ struct ModifierDiff {
pub to: Modifier,
}
#[cfg(unix)]
impl ModifierDiff {
fn queue<W>(&self, mut w: W) -> io::Result<()>
where
W: fmt::Write,
W: io::Write,
{
//use crossterm::Attribute;
let removed = self.from - self.to;
@@ -227,28 +219,3 @@ impl ModifierDiff {
Ok(())
}
}
#[cfg(windows)]
impl ModifierDiff {
fn queue<W>(&self, mut w: W) -> io::Result<()>
where
W: fmt::Write,
{
let removed = self.from - self.to;
if removed.contains(Modifier::BOLD) {
map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?;
}
if removed.contains(Modifier::UNDERLINED) {
map_error(queue!(w, SetAttribute(CAttribute::NoUnderline)))?;
}
let added = self.to - self.from;
if added.contains(Modifier::BOLD) {
map_error(queue!(w, SetAttribute(CAttribute::Bold)))?;
}
if added.contains(Modifier::UNDERLINED) {
map_error(queue!(w, SetAttribute(CAttribute::Underlined)))?;
}
Ok(())
}
}

View File

@@ -40,7 +40,7 @@ fn buffer_view(buffer: &Buffer) -> String {
)
.unwrap();
}
view.push_str("\n");
view.push('\n');
}
view
}
@@ -60,6 +60,12 @@ impl TestBackend {
&self.buffer
}
pub fn resize(&mut self, width: u16, height: u16) {
self.buffer.resize(Rect::new(0, 0, width, height));
self.width = width;
self.height = height;
}
pub fn assert_buffer(&self, expected: &Buffer) {
assert_eq!(expected.area, self.buffer.area);
let diff = expected.diff(&self.buffer);
@@ -68,20 +74,20 @@ impl TestBackend {
}
let mut debug_info = String::from("Buffers are not equal");
debug_info.push_str("\n");
debug_info.push('\n');
debug_info.push_str("Expected:");
debug_info.push_str("\n");
debug_info.push('\n');
let expected_view = buffer_view(expected);
debug_info.push_str(&expected_view);
debug_info.push_str("\n");
debug_info.push('\n');
debug_info.push_str("Got:");
debug_info.push_str("\n");
debug_info.push('\n');
let view = buffer_view(&self.buffer);
debug_info.push_str(&view);
debug_info.push_str("\n");
debug_info.push('\n');
debug_info.push_str("Diff:");
debug_info.push_str("\n");
debug_info.push('\n');
let nice_diff = diff
.iter()
.enumerate()

View File

@@ -51,6 +51,13 @@ impl Cell {
self
}
pub fn style(&self) -> Style {
Style::default()
.fg(self.fg)
.bg(self.bg)
.add_modifier(self.modifier)
}
pub fn reset(&mut self) {
self.symbol.clear();
self.symbol.push(' ');
@@ -139,9 +146,11 @@ impl Buffer {
S: AsRef<str>,
{
let height = lines.len() as u16;
let width = lines.iter().fold(0, |acc, item| {
std::cmp::max(acc, item.as_ref().width() as u16)
});
let width = lines
.iter()
.map(|i| i.as_ref().width() as u16)
.max()
.unwrap_or_default();
let mut buffer = Buffer::empty(Rect {
x: 0,
y: 0,

View File

@@ -401,7 +401,7 @@ impl Rect {
}
pub fn right(self) -> u16 {
self.x + self.width
self.x.saturating_add(self.width)
}
pub fn top(self) -> u16 {
@@ -409,7 +409,7 @@ impl Rect {
}
pub fn bottom(self) -> u16 {
self.y + self.height
self.y.saturating_add(self.height)
}
pub fn inner(self, margin: &Margin) -> Rect {

View File

@@ -9,7 +9,7 @@
//!
//! ```toml
//! [dependencies]
//! tui = "0.10"
//! tui = "0.14"
//! termion = "1.5"
//! ```
//!
@@ -19,8 +19,8 @@
//!
//! ```toml
//! [dependencies]
//! crossterm = "0.17"
//! tui = { version = "0.10", default-features = false, features = ['crossterm'] }
//! crossterm = "0.19"
//! tui = { version = "0.14", default-features = false, features = ['crossterm'] }
//! ```
//!
//! The same logic applies for all other available backends.
@@ -94,7 +94,8 @@
//! .title("Block")
//! .borders(Borders::ALL);
//! f.render_widget(block, size);
//! })
//! })?;
//! Ok(())
//! }
//! ```
//!
@@ -136,7 +137,8 @@
//! .title("Block 2")
//! .borders(Borders::ALL);
//! f.render_widget(block, chunks[1]);
//! })
//! })?;
//! Ok(())
//! }
//! ```
//!
@@ -154,4 +156,4 @@ pub mod terminal;
pub mod text;
pub mod widgets;
pub use self::terminal::{Frame, Terminal, TerminalOptions, Viewport};
pub use self::terminal::{Frame, RenderArgs, Terminal, TerminalOptions, Viewport};

View File

@@ -54,8 +54,6 @@ bitflags! {
/// Style let you control the main characteristics of the displayed elements.
///
/// ## Examples
///
/// ```rust
/// # use tui::style::{Color, Modifier, Style};
/// Style::default()
@@ -63,6 +61,60 @@ bitflags! {
/// .bg(Color::Green)
/// .add_modifier(Modifier::ITALIC | Modifier::BOLD);
/// ```
///
/// It represents an incremental change. If you apply the styles S1, S2, S3 to a cell of the
/// terminal buffer, the style of this cell will be the result of the merge of S1, S2 and S3, not
/// just S3.
///
/// ```rust
/// # use tui::style::{Color, Modifier, Style};
/// # use tui::buffer::Buffer;
/// # use tui::layout::Rect;
/// let styles = [
/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC),
/// Style::default().bg(Color::Red),
/// Style::default().fg(Color::Yellow).remove_modifier(Modifier::ITALIC),
/// ];
/// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
/// for style in &styles {
/// buffer.get_mut(0, 0).set_style(*style);
/// }
/// assert_eq!(
/// Style {
/// fg: Some(Color::Yellow),
/// bg: Some(Color::Red),
/// add_modifier: Modifier::BOLD,
/// sub_modifier: Modifier::empty(),
/// },
/// buffer.get(0, 0).style(),
/// );
/// ```
///
/// The default implementation returns a `Style` that does not modify anything. If you wish to
/// reset all properties until that point use [`Style::reset`].
///
/// ```
/// # use tui::style::{Color, Modifier, Style};
/// # use tui::buffer::Buffer;
/// # use tui::layout::Rect;
/// let styles = [
/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC),
/// Style::reset().fg(Color::Yellow),
/// ];
/// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
/// for style in &styles {
/// buffer.get_mut(0, 0).set_style(*style);
/// }
/// assert_eq!(
/// Style {
/// fg: Some(Color::Yellow),
/// bg: Some(Color::Reset),
/// add_modifier: Modifier::empty(),
/// sub_modifier: Modifier::empty(),
/// },
/// buffer.get(0, 0).style(),
/// );
/// ```
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Style {
@@ -84,6 +136,16 @@ impl Default for Style {
}
impl Style {
/// Returns a `Style` resetting all properties.
pub fn reset() -> Style {
Style {
fg: Some(Color::Reset),
bg: Some(Color::Reset),
add_modifier: Modifier::empty(),
sub_modifier: Modifier::all(),
}
}
/// Changes the foreground color.
///
/// ## Examples

View File

@@ -224,8 +224,10 @@ pub mod braille {
/// Marker to use when plotting data points
#[derive(Debug, Clone, Copy)]
pub enum Marker {
/// One point per cell
/// One point per cell in shape of dot
Dot,
/// One point per cell in shape of a block
Block,
/// Up to 8 points per cell
Braille,
}

View File

@@ -2,9 +2,9 @@ use crate::{
backend::Backend,
buffer::Buffer,
layout::Rect,
widgets::{StatefulWidget, Widget},
widgets::{RenderContext, Widget},
};
use std::io;
use std::{any::Any, collections::HashMap, hash::Hash, io, panic::Location};
#[derive(Debug, Clone, PartialEq)]
/// UNSTABLE
@@ -30,15 +30,54 @@ impl Viewport {
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct CallLocation(&'static Location<'static>);
impl CallLocation {
fn as_ptr(&self) -> *const Location<'static> {
self.0
}
}
impl Hash for CallLocation {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.as_ptr().hash(state)
}
}
impl PartialEq for CallLocation {
fn eq(&self, other: &Self) -> bool {
self.as_ptr() == other.as_ptr()
}
}
impl Eq for CallLocation {}
/// StateEntry is used to link a [`Frame::render_widget`] to [`Widget::State`].
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct StateKey {
/// Location of the call to [`Frame::render_widget`].
call_location: CallLocation,
/// Optional id that can be used to have multiple widgets state at the same call location.
id: Option<String>,
}
/// StateEntry holds the state of a [`Widget`].
struct StateEntry {
/// State of a [`Widget`].
state: Box<dyn Any>,
/// Index of the frame where the state was used for the last time.
frame_index: usize,
}
#[derive(Debug, Clone, PartialEq)]
/// Options to pass to [`Terminal::draw_with_options`]
/// Options to pass to [`Terminal::with_options`]
pub struct TerminalOptions {
/// Viewport used to draw to the terminal
pub viewport: Viewport,
}
/// Interface to the terminal backed by Termion
#[derive(Debug)]
pub struct Terminal<B>
where
B: Backend,
@@ -53,6 +92,11 @@ where
hidden_cursor: bool,
/// Viewport
viewport: Viewport,
/// State of the widgets rendered in the previous frame.
widget_states: HashMap<StateKey, StateEntry>,
/// Index of the current frame. Incremented each time [`Terminal::draw`] is called and wraps
/// when it is greater than [`std::usize::MAX`].
frame_index: usize,
}
/// Represents a consistent terminal interface for rendering.
@@ -69,6 +113,31 @@ where
cursor_position: Option<(u16, u16)>,
}
/// RenderArgs are the arguments required to render a [`Widget`].
pub struct RenderArgs {
/// Area where the widget will be rendered.
area: Rect,
/// Optional id that can be used to uniquely identify the provided [`Widget`].
id: Option<String>,
}
impl From<Rect> for RenderArgs {
fn from(area: Rect) -> RenderArgs {
RenderArgs { area, id: None }
}
}
impl RenderArgs {
/// Set the [`Widget`] id.
pub fn id<S>(mut self, id: S) -> Self
where
S: Into<String>,
{
self.id = Some(id.into());
self
}
}
impl<'a, B> Frame<'a, B>
where
B: Backend,
@@ -96,45 +165,79 @@ where
/// let mut frame = terminal.get_frame();
/// frame.render_widget(block, area);
/// ```
pub fn render_widget<W>(&mut self, widget: W, area: Rect)
where
W: Widget,
{
widget.render(area, self.terminal.current_buffer_mut());
}
/// Render a [`StatefulWidget`] to the current buffer using [`StatefulWidget::render`].
///
/// The last argument should be an instance of the [`StatefulWidget::State`] associated to the
/// given [`StatefulWidget`].
///
/// # Examples
/// If you happen to render two or more widgets using the same render call, you may want to
/// associate them with a unique id so they do not share any internal state.
///
/// For example, let say your app shows a list of songs of a given album:
/// ```rust,no_run
/// # use std::io;
/// # use tui::Terminal;
/// # use std::{collections::HashMap, io};
/// # use tui::{Terminal, RenderArgs};
/// # use tui::backend::TermionBackend;
/// # use tui::layout::Rect;
/// # use tui::widgets::{List, ListItem, ListState};
/// # use tui::widgets::{Block, List, ListItem};
/// # let stdout = io::stdout();
/// # let backend = TermionBackend::new(stdout);
/// # let mut terminal = Terminal::new(backend).unwrap();
/// let mut state = ListState::default();
/// state.select(Some(1));
/// let items = vec![
/// ListItem::new("Item 1"),
/// ListItem::new("Item 2"),
/// ];
/// let list = List::new(items);
/// let area = Rect::new(0, 0, 5, 5);
/// let mut frame = terminal.get_frame();
/// frame.render_stateful_widget(list, area, &mut state);
/// struct App {
/// albums: HashMap<String, Vec<String>>,
/// selected_album: String
/// }
/// # let app = App {
/// # albums: HashMap::new(),
/// # selected_album: String::new(),
/// # };
/// terminal.draw(|f| {
/// let songs: Vec<ListItem> = app.albums[&app.selected_album]
/// .iter()
/// .map(|song| ListItem::new(song.as_ref()))
/// .collect();
/// let song_list = List::new(songs)
/// .block(Block::default().title(app.selected_album.as_ref()));
/// // Giving a unique id here makes sure the list state is reset whenever the album
/// // currently displayed changes.
/// let args = RenderArgs::from(f.size()).id(app.selected_album.clone());
/// f.render_widget(song_list, args);
/// });
/// ```
pub fn render_stateful_widget<W>(&mut self, widget: W, area: Rect, state: &mut W::State)
#[track_caller]
pub fn render_widget<W, R>(&mut self, widget: W, args: R)
where
W: StatefulWidget,
W: Widget,
W::State: 'static + Default,
R: Into<RenderArgs>,
{
widget.render(area, self.terminal.current_buffer_mut(), state);
// Fetch the previous internal state of the widget (or initialize it with a default value).
let args: RenderArgs = args.into();
let location = Location::caller();
let key = StateKey {
call_location: CallLocation(location),
id: args.id,
};
let entry = self
.terminal
.widget_states
.entry(key)
.or_insert_with(|| StateEntry {
state: Box::new(<W::State>::default()),
frame_index: 0,
});
let state: &mut W::State = entry
.state
.downcast_mut()
.expect("The state associated to a widget is not of an expected type");
// Update the frame index to communicate that it was used during the current draw call.
entry.frame_index = self.terminal.frame_index;
// Render the widget
let buffer = &mut self.terminal.buffers[self.terminal.current];
let mut context = RenderContext {
area: args.area,
buffer,
state,
};
widget.render(&mut context);
}
/// After drawing this frame, make the cursor visible and put it at the specified (x, y)
@@ -148,6 +251,14 @@ where
}
}
/// CompletedFrame represents the state of the terminal after all changes performed in the last
/// [`Terminal::draw`] call have been applied. Therefore, it is only valid until the next call to
/// [`Terminal::draw`].
pub struct CompletedFrame<'a> {
pub buffer: &'a Buffer,
pub area: Rect,
}
impl<B> Drop for Terminal<B>
where
B: Backend,
@@ -192,6 +303,8 @@ where
current: 0,
hidden_cursor: false,
viewport: options.viewport,
widget_states: HashMap::new(),
frame_index: 0,
})
}
@@ -229,10 +342,9 @@ where
/// This leads to a full clear of the screen.
pub fn resize(&mut self, area: Rect) -> io::Result<()> {
self.buffers[self.current].resize(area);
self.buffers[1 - self.current].reset();
self.buffers[1 - self.current].resize(area);
self.viewport.area = area;
self.backend.clear()
self.clear()
}
/// Queries the backend for size and resizes if it doesn't match the previous size.
@@ -248,7 +360,7 @@ where
/// Synchronizes terminal size, calls the rendering closure, flushes the current internal state
/// and prepares for the next draw call.
pub fn draw<F>(&mut self, f: F) -> io::Result<()>
pub fn draw<F>(&mut self, f: F) -> io::Result<CompletedFrame>
where
F: FnOnce(&mut Frame<B>),
{
@@ -278,9 +390,18 @@ where
self.buffers[1 - self.current].reset();
self.current = 1 - self.current;
// Clean states that were not used in this frame
let frame_index = self.frame_index;
self.widget_states
.retain(|_, v| v.frame_index == frame_index);
self.frame_index = self.frame_index.wrapping_add(1);
// Flush
self.backend.flush()?;
Ok(())
Ok(CompletedFrame {
buffer: &self.buffers[1 - self.current],
area: self.viewport.area,
})
}
pub fn hide_cursor(&mut self) -> io::Result<()> {
@@ -303,8 +424,12 @@ where
self.backend.set_cursor(x, y)
}
/// Clear the terminal and force a full redraw on the next draw call.
pub fn clear(&mut self) -> io::Result<()> {
self.backend.clear()
self.backend.clear()?;
// Reset the back buffer to make sure the next update will redraw everything.
self.buffers[1 - self.current].reset();
Ok(())
}
/// Queries the real size of the backend.

View File

@@ -47,7 +47,7 @@
//! ]);
//! ```
use crate::style::Style;
use std::{borrow::Cow, cmp::max};
use std::borrow::Cow;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
@@ -218,7 +218,7 @@ impl<'a> Spans<'a> {
/// assert_eq!(7, spans.width());
/// ```
pub fn width(&self) -> usize {
self.0.iter().fold(0, |acc, s| acc + s.width())
self.0.iter().map(Span::width).sum()
}
}
@@ -257,6 +257,28 @@ impl<'a> From<Spans<'a>> for String {
/// A string split over multiple lines where each line is composed of several clusters, each with
/// their own style.
///
/// A [`Text`], like a [`Span`], can be constructed using one of the many `From` implementations
/// or via the [`Text::raw`] and [`Text::styled`] methods. Helpfully, [`Text`] also implements
/// [`core::iter::Extend`] which enables the concatenation of several [`Text`] blocks.
///
/// ```rust
/// # use tui::text::Text;
/// # use tui::style::{Color, Modifier, Style};
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
///
/// // An initial two lines of `Text` built from a `&str`
/// let mut text = Text::from("The first line\nThe second line");
/// assert_eq!(2, text.height());
///
/// // Adding two more unstyled lines
/// text.extend(Text::raw("These are two\nmore lines!"));
/// assert_eq!(4, text.height());
///
/// // Adding a final two styled lines
/// text.extend(Text::styled("Some more lines\nnow with more style!", style));
/// assert_eq!(6, text.height());
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct Text<'a> {
pub lines: Vec<Spans<'a>>,
@@ -269,6 +291,47 @@ impl<'a> Default for Text<'a> {
}
impl<'a> Text<'a> {
/// Create some text (potentially multiple lines) with no style.
///
/// ## Examples
///
/// ```rust
/// # use tui::text::Text;
/// Text::raw("The first line\nThe second line");
/// Text::raw(String::from("The first line\nThe second line"));
/// ```
pub fn raw<T>(content: T) -> Text<'a>
where
T: Into<Cow<'a, str>>,
{
Text {
lines: match content.into() {
Cow::Borrowed(s) => s.lines().map(Spans::from).collect(),
Cow::Owned(s) => s.lines().map(|l| Spans::from(l.to_owned())).collect(),
},
}
}
/// Create some text (potentially multiple lines) with a style.
///
/// # Examples
///
/// ```rust
/// # use tui::text::Text;
/// # use tui::style::{Color, Modifier, Style};
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
/// Text::styled("The first line\nThe second line", style);
/// Text::styled(String::from("The first line\nThe second line"), style);
/// ```
pub fn styled<T>(content: T, style: Style) -> Text<'a>
where
T: Into<Cow<'a, str>>,
{
let mut text = Text::raw(content);
text.patch_style(style);
text
}
/// Returns the max width of all the lines.
///
/// ## Examples
@@ -279,7 +342,11 @@ impl<'a> Text<'a> {
/// assert_eq!(15, text.width());
/// ```
pub fn width(&self) -> usize {
self.lines.iter().fold(0, |acc, l| max(acc, l.width()))
self.lines
.iter()
.map(Spans::width)
.max()
.unwrap_or_default()
}
/// Returns the height.
@@ -295,6 +362,21 @@ impl<'a> Text<'a> {
self.lines.len()
}
/// Apply a new style to existing text.
///
/// # Examples
///
/// ```rust
/// # use tui::text::Text;
/// # use tui::style::{Color, Modifier, Style};
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
/// let mut raw_text = Text::raw("The first line\nThe second line");
/// let styled_text = Text::styled(String::from("The first line\nThe second line"), style);
/// assert_ne!(raw_text, styled_text);
///
/// raw_text.patch_style(style);
/// assert_eq!(raw_text, styled_text);
/// ```
pub fn patch_style(&mut self, style: Style) {
for line in &mut self.lines {
for span in &mut line.0 {
@@ -304,11 +386,15 @@ impl<'a> Text<'a> {
}
}
impl<'a> From<String> for Text<'a> {
fn from(s: String) -> Text<'a> {
Text::raw(s)
}
}
impl<'a> From<&'a str> for Text<'a> {
fn from(s: &'a str) -> Text<'a> {
Text {
lines: s.lines().map(Spans::from).collect(),
}
Text::raw(s)
}
}
@@ -331,3 +417,18 @@ impl<'a> From<Vec<Spans<'a>>> for Text<'a> {
Text { lines }
}
}
impl<'a> IntoIterator for Text<'a> {
type Item = Spans<'a>;
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.lines.into_iter()
}
}
impl<'a> Extend<Spans<'a>> for Text<'a> {
fn extend<T: IntoIterator<Item = Spans<'a>>>(&mut self, iter: T) {
self.lines.extend(iter);
}
}

View File

@@ -1,11 +1,9 @@
use crate::{
buffer::Buffer,
layout::Rect,
style::Style,
symbols,
widgets::{Block, Widget},
widgets::{Block, RenderContext, Widget},
};
use std::cmp::{max, min};
use std::cmp::min;
use unicode_width::UnicodeWidthStr;
/// Display multiple bars in a single widgets
@@ -127,16 +125,22 @@ impl<'a> BarChart<'a> {
}
impl<'a> Widget for BarChart<'a> {
fn render(mut self, area: Rect, buf: &mut Buffer) {
buf.set_style(area, self.style);
type State = ();
fn render(mut self, ctx: &mut RenderContext<Self::State>) {
ctx.buffer.set_style(ctx.area, self.style);
let chart_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
let inner_area = b.inner(ctx.area);
b.render(&mut RenderContext {
area: ctx.area,
buffer: ctx.buffer,
state: &mut (),
});
inner_area
}
None => area,
None => ctx.area,
};
if chart_area.height < 2 {
@@ -145,7 +149,7 @@ impl<'a> Widget for BarChart<'a> {
let max = self
.max
.unwrap_or_else(|| self.data.iter().fold(0, |acc, &(_, v)| max(v, acc)));
.unwrap_or_else(|| self.data.iter().map(|t| t.1).max().unwrap_or_default());
let max_index = min(
(chart_area.width / (self.bar_width + self.bar_gap)) as usize,
self.data.len(),
@@ -157,7 +161,7 @@ impl<'a> Widget for BarChart<'a> {
.map(|&(l, v)| {
(
l,
v * u64::from(chart_area.height) * 8 / std::cmp::max(max, 1),
v * u64::from(chart_area.height - 1) * 8 / std::cmp::max(max, 1),
)
})
.collect::<Vec<(&str, u64)>>();
@@ -176,12 +180,13 @@ impl<'a> Widget for BarChart<'a> {
};
for x in 0..self.bar_width {
buf.get_mut(
chart_area.left() + i as u16 * (self.bar_width + self.bar_gap) + x,
chart_area.top() + j,
)
.set_symbol(symbol)
.set_style(self.bar_style);
ctx.buffer
.get_mut(
chart_area.left() + i as u16 * (self.bar_width + self.bar_gap) + x,
chart_area.top() + j,
)
.set_symbol(symbol)
.set_style(self.bar_style);
}
if d.1 > 8 {
@@ -197,7 +202,7 @@ impl<'a> Widget for BarChart<'a> {
let value_label = &self.values[i];
let width = value_label.width() as u16;
if width < self.bar_width {
buf.set_string(
ctx.buffer.set_string(
chart_area.left()
+ i as u16 * (self.bar_width + self.bar_gap)
+ (self.bar_width - width) / 2,
@@ -207,7 +212,7 @@ impl<'a> Widget for BarChart<'a> {
);
}
}
buf.set_stringn(
ctx.buffer.set_stringn(
chart_area.left() + i as u16 * (self.bar_width + self.bar_gap),
chart_area.bottom() - 1,
label,

View File

@@ -1,13 +1,12 @@
use crate::{
buffer::Buffer,
layout::Rect,
style::Style,
symbols::line,
text::{Span, Spans},
widgets::{Borders, Widget},
widgets::{Borders, RenderContext, Widget},
};
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BorderType {
Plain,
Rounded,
@@ -41,7 +40,7 @@ impl BorderType {
/// .border_type(BorderType::Rounded)
/// .style(Style::default().bg(Color::Black));
/// ```
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub struct Block<'a> {
/// Optional title place on the upper left of the block
title: Option<Spans<'a>>,
@@ -111,88 +110,94 @@ impl<'a> Block<'a> {
/// Compute the inner area of a block based on its border visibility rules.
pub fn inner(&self, area: Rect) -> Rect {
if area.width < 2 || area.height < 2 {
return Rect::default();
}
let mut inner = area;
if self.borders.intersects(Borders::LEFT) {
inner.x += 1;
inner.width -= 1;
inner.x = inner.x.saturating_add(1).min(inner.right());
inner.width = inner.width.saturating_sub(1);
}
if self.borders.intersects(Borders::TOP) || self.title.is_some() {
inner.y += 1;
inner.height -= 1;
inner.y = inner.y.saturating_add(1).min(inner.bottom());
inner.height = inner.height.saturating_sub(1);
}
if self.borders.intersects(Borders::RIGHT) {
inner.width -= 1;
inner.width = inner.width.saturating_sub(1);
}
if self.borders.intersects(Borders::BOTTOM) {
inner.height -= 1;
inner.height = inner.height.saturating_sub(1);
}
inner
}
}
impl<'a> Widget for Block<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
buf.set_style(area, self.style);
type State = ();
if area.width < 2 || area.height < 2 {
fn render(self, ctx: &mut RenderContext<Self::State>) {
if ctx.area.area() == 0 {
return;
}
ctx.buffer.set_style(ctx.area, self.style);
let symbols = BorderType::line_symbols(self.border_type);
// Sides
if self.borders.intersects(Borders::LEFT) {
for y in area.top()..area.bottom() {
buf.get_mut(area.left(), y)
for y in ctx.area.top()..ctx.area.bottom() {
ctx.buffer
.get_mut(ctx.area.left(), y)
.set_symbol(symbols.vertical)
.set_style(self.border_style);
}
}
if self.borders.intersects(Borders::TOP) {
for x in area.left()..area.right() {
buf.get_mut(x, area.top())
for x in ctx.area.left()..ctx.area.right() {
ctx.buffer
.get_mut(x, ctx.area.top())
.set_symbol(symbols.horizontal)
.set_style(self.border_style);
}
}
if self.borders.intersects(Borders::RIGHT) {
let x = area.right() - 1;
for y in area.top()..area.bottom() {
buf.get_mut(x, y)
let x = ctx.area.right() - 1;
for y in ctx.area.top()..ctx.area.bottom() {
ctx.buffer
.get_mut(x, y)
.set_symbol(symbols.vertical)
.set_style(self.border_style);
}
}
if self.borders.intersects(Borders::BOTTOM) {
let y = area.bottom() - 1;
for x in area.left()..area.right() {
buf.get_mut(x, y)
let y = ctx.area.bottom() - 1;
for x in ctx.area.left()..ctx.area.right() {
ctx.buffer
.get_mut(x, y)
.set_symbol(symbols.horizontal)
.set_style(self.border_style);
}
}
// Corners
if self.borders.contains(Borders::LEFT | Borders::TOP) {
buf.get_mut(area.left(), area.top())
.set_symbol(symbols.top_left)
if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
ctx.buffer
.get_mut(ctx.area.right() - 1, ctx.area.bottom() - 1)
.set_symbol(symbols.bottom_right)
.set_style(self.border_style);
}
if self.borders.contains(Borders::RIGHT | Borders::TOP) {
buf.get_mut(area.right() - 1, area.top())
ctx.buffer
.get_mut(ctx.area.right() - 1, ctx.area.top())
.set_symbol(symbols.top_right)
.set_style(self.border_style);
}
if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
buf.get_mut(area.left(), area.bottom() - 1)
ctx.buffer
.get_mut(ctx.area.left(), ctx.area.bottom() - 1)
.set_symbol(symbols.bottom_left)
.set_style(self.border_style);
}
if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
buf.get_mut(area.right() - 1, area.bottom() - 1)
.set_symbol(symbols.bottom_right)
if self.borders.contains(Borders::LEFT | Borders::TOP) {
ctx.buffer
.get_mut(ctx.area.left(), ctx.area.top())
.set_symbol(symbols.top_left)
.set_style(self.border_style);
}
@@ -207,8 +212,310 @@ impl<'a> Widget for Block<'a> {
} else {
0
};
let width = area.width - lx - rx;
buf.set_spans(area.left() + lx, area.top(), &title, width);
let width = ctx.area.width.saturating_sub(lx).saturating_sub(rx);
ctx.buffer
.set_spans(ctx.area.left() + lx, ctx.area.top(), &title, width);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::Rect;
#[test]
fn inner_takes_into_account_the_borders() {
// No borders
assert_eq!(
Block::default().inner(Rect::default()),
Rect {
x: 0,
y: 0,
width: 0,
height: 0
},
"no borders, width=0, height=0"
);
assert_eq!(
Block::default().inner(Rect {
x: 0,
y: 0,
width: 1,
height: 1
}),
Rect {
x: 0,
y: 0,
width: 1,
height: 1
},
"no borders, width=1, height=1"
);
// Left border
assert_eq!(
Block::default().borders(Borders::LEFT).inner(Rect {
x: 0,
y: 0,
width: 0,
height: 1
}),
Rect {
x: 0,
y: 0,
width: 0,
height: 1
},
"left, width=0"
);
assert_eq!(
Block::default().borders(Borders::LEFT).inner(Rect {
x: 0,
y: 0,
width: 1,
height: 1
}),
Rect {
x: 1,
y: 0,
width: 0,
height: 1
},
"left, width=1"
);
assert_eq!(
Block::default().borders(Borders::LEFT).inner(Rect {
x: 0,
y: 0,
width: 2,
height: 1
}),
Rect {
x: 1,
y: 0,
width: 1,
height: 1
},
"left, width=2"
);
// Top border
assert_eq!(
Block::default().borders(Borders::TOP).inner(Rect {
x: 0,
y: 0,
width: 1,
height: 0
}),
Rect {
x: 0,
y: 0,
width: 1,
height: 0
},
"top, height=0"
);
assert_eq!(
Block::default().borders(Borders::TOP).inner(Rect {
x: 0,
y: 0,
width: 1,
height: 1
}),
Rect {
x: 0,
y: 1,
width: 1,
height: 0
},
"top, height=1"
);
assert_eq!(
Block::default().borders(Borders::TOP).inner(Rect {
x: 0,
y: 0,
width: 1,
height: 2
}),
Rect {
x: 0,
y: 1,
width: 1,
height: 1
},
"top, height=2"
);
// Right border
assert_eq!(
Block::default().borders(Borders::RIGHT).inner(Rect {
x: 0,
y: 0,
width: 0,
height: 1
}),
Rect {
x: 0,
y: 0,
width: 0,
height: 1
},
"right, width=0"
);
assert_eq!(
Block::default().borders(Borders::RIGHT).inner(Rect {
x: 0,
y: 0,
width: 1,
height: 1
}),
Rect {
x: 0,
y: 0,
width: 0,
height: 1
},
"right, width=1"
);
assert_eq!(
Block::default().borders(Borders::RIGHT).inner(Rect {
x: 0,
y: 0,
width: 2,
height: 1
}),
Rect {
x: 0,
y: 0,
width: 1,
height: 1
},
"right, width=2"
);
// Bottom border
assert_eq!(
Block::default().borders(Borders::BOTTOM).inner(Rect {
x: 0,
y: 0,
width: 1,
height: 0
}),
Rect {
x: 0,
y: 0,
width: 1,
height: 0
},
"bottom, height=0"
);
assert_eq!(
Block::default().borders(Borders::BOTTOM).inner(Rect {
x: 0,
y: 0,
width: 1,
height: 1
}),
Rect {
x: 0,
y: 0,
width: 1,
height: 0
},
"bottom, height=1"
);
assert_eq!(
Block::default().borders(Borders::BOTTOM).inner(Rect {
x: 0,
y: 0,
width: 1,
height: 2
}),
Rect {
x: 0,
y: 0,
width: 1,
height: 1
},
"bottom, height=2"
);
// All borders
assert_eq!(
Block::default()
.borders(Borders::ALL)
.inner(Rect::default()),
Rect {
x: 0,
y: 0,
width: 0,
height: 0
},
"all borders, width=0, height=0"
);
assert_eq!(
Block::default().borders(Borders::ALL).inner(Rect {
x: 0,
y: 0,
width: 1,
height: 1
}),
Rect {
x: 1,
y: 1,
width: 0,
height: 0,
},
"all borders, width=1, height=1"
);
assert_eq!(
Block::default().borders(Borders::ALL).inner(Rect {
x: 0,
y: 0,
width: 2,
height: 2,
}),
Rect {
x: 1,
y: 1,
width: 0,
height: 0,
},
"all borders, width=2, height=2"
);
assert_eq!(
Block::default().borders(Borders::ALL).inner(Rect {
x: 0,
y: 0,
width: 3,
height: 3,
}),
Rect {
x: 1,
y: 1,
width: 1,
height: 1,
},
"all borders, width=3, height=3"
);
}
#[test]
fn inner_takes_into_account_the_title() {
assert_eq!(
Block::default().title("Test").inner(Rect {
x: 0,
y: 0,
width: 0,
height: 1,
}),
Rect {
x: 0,
y: 1,
width: 0,
height: 0,
},
);
}
}

View File

@@ -10,11 +10,9 @@ pub use self::points::Points;
pub use self::rectangle::Rectangle;
use crate::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
symbols,
widgets::{Block, Widget},
widgets::{Block, RenderContext, Widget},
};
use std::fmt::Debug;
@@ -111,26 +109,28 @@ impl Grid for BrailleGrid {
}
#[derive(Debug, Clone)]
struct DotGrid {
struct CharGrid {
width: u16,
height: u16,
cells: Vec<char>,
colors: Vec<Color>,
cell_char: char,
}
impl DotGrid {
fn new(width: u16, height: u16) -> DotGrid {
impl CharGrid {
fn new(width: u16, height: u16, cell_char: char) -> CharGrid {
let length = usize::from(width * height);
DotGrid {
CharGrid {
width,
height,
cells: vec![' '; length],
colors: vec![Color::Reset; length],
cell_char,
}
}
}
impl Grid for DotGrid {
impl Grid for CharGrid {
fn width(&self) -> u16 {
self.width
}
@@ -162,7 +162,7 @@ impl Grid for DotGrid {
fn paint(&mut self, x: usize, y: usize, color: Color) {
let index = y * self.width as usize + x;
if let Some(c) = self.cells.get_mut(index) {
*c = '•';
*c = self.cell_char;
}
if let Some(c) = self.colors.get_mut(index) {
*c = color;
@@ -259,7 +259,8 @@ impl<'a> Context<'a> {
marker: symbols::Marker,
) -> Context<'a> {
let grid: Box<dyn Grid> = match marker {
symbols::Marker::Dot => Box::new(DotGrid::new(width, height)),
symbols::Marker::Dot => Box::new(CharGrid::new(width, height, '•')),
symbols::Marker::Block => Box::new(CharGrid::new(width, height, '▄')),
symbols::Marker::Braille => Box::new(BrailleGrid::new(width, height)),
};
Context {
@@ -396,8 +397,8 @@ where
}
/// Change the type of points used to draw the shapes. By default the braille patterns are used
/// as they provide a more fine grained result but you might want to use the simple dot instead
/// if the targeted terminal does not support those symbols.
/// as they provide a more fine grained result but you might want to use the simple dot or
/// block instead if the targeted terminal does not support those symbols.
///
/// # Examples
///
@@ -407,6 +408,8 @@ where
/// Canvas::default().marker(symbols::Marker::Braille).paint(|ctx| {});
///
/// Canvas::default().marker(symbols::Marker::Dot).paint(|ctx| {});
///
/// Canvas::default().marker(symbols::Marker::Block).paint(|ctx| {});
/// ```
pub fn marker(mut self, marker: symbols::Marker) -> Canvas<'a, F> {
self.marker = marker;
@@ -418,14 +421,20 @@ impl<'a, F> Widget for Canvas<'a, F>
where
F: Fn(&mut Context),
{
fn render(mut self, area: Rect, buf: &mut Buffer) {
type State = ();
fn render(mut self, ctx: &mut RenderContext<Self::State>) {
let canvas_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
let inner_area = b.inner(ctx.area);
b.render(&mut RenderContext {
area: ctx.area,
buffer: ctx.buffer,
state: &mut (),
});
inner_area
}
None => area,
None => ctx.area,
};
let width = canvas_area.width as usize;
@@ -436,7 +445,7 @@ where
};
// Create a blank context that match the size of the canvas
let mut ctx = Context::new(
let mut canvas_ctx = Context::new(
canvas_area.width,
canvas_area.height,
self.x_bounds,
@@ -444,11 +453,11 @@ where
self.marker,
);
// Paint to this context
painter(&mut ctx);
ctx.finish();
painter(&mut canvas_ctx);
canvas_ctx.finish();
// Retreive painted points for each layer
for layer in ctx.layers {
for layer in canvas_ctx.layers {
for (i, (ch, color)) in layer
.string
.chars()
@@ -457,7 +466,8 @@ where
{
if ch != ' ' && ch != '\u{2800}' {
let (x, y) = (i % width, i / width);
buf.get_mut(x as u16 + canvas_area.left(), y as u16 + canvas_area.top())
ctx.buffer
.get_mut(x as u16 + canvas_area.left(), y as u16 + canvas_area.top())
.set_char(ch)
.set_fg(color)
.set_bg(self.background_color);
@@ -478,14 +488,14 @@ where
let height = f64::from(canvas_area.height - 1);
(width, height)
};
for label in ctx
for label in canvas_ctx
.labels
.iter()
.filter(|l| l.x >= left && l.x <= right && l.y <= top && l.y >= bottom)
{
let x = ((label.x - left) * resolution.0 / width) as u16 + canvas_area.left();
let y = ((top - label.y) * resolution.1 / height) as u16 + canvas_area.top();
buf.set_stringn(
ctx.buffer.set_stringn(
x,
y,
label.text,

View File

@@ -1,12 +1,11 @@
use crate::{
buffer::Buffer,
layout::{Constraint, Rect},
style::{Color, Style},
symbols,
text::{Span, Spans},
widgets::{
canvas::{Canvas, Line, Points},
Block, Borders, Widget,
Block, Borders, RenderContext, Widget,
},
};
use std::{borrow::Cow, cmp::max};
@@ -297,10 +296,7 @@ impl<'a> Chart<'a> {
}
if let Some(ref y_labels) = self.y_axis.labels {
let mut max_width = y_labels
.iter()
.fold(0, |acc, l| max(l.content.width(), acc))
as u16;
let mut max_width = y_labels.iter().map(Span::width).max().unwrap_or_default() as u16;
if let Some(ref x_labels) = self.x_axis.labels {
if !x_labels.is_empty() {
max_width = max(max_width, x_labels[0].content.width() as u16);
@@ -336,7 +332,7 @@ impl<'a> Chart<'a> {
if let Some(ref title) = self.y_axis.title {
let w = title.width() as u16;
if w + 1 < layout.graph_area.width && layout.graph_area.height > 2 {
layout.title_y = Some((x + 1, area.top()));
layout.title_y = Some((x, area.top()));
}
}
@@ -368,15 +364,29 @@ impl<'a> Chart<'a> {
}
impl<'a> Widget for Chart<'a> {
fn render(mut self, area: Rect, buf: &mut Buffer) {
buf.set_style(area, self.style);
type State = ();
fn render(mut self, ctx: &mut RenderContext<Self::State>) {
if ctx.area.area() == 0 {
return;
}
ctx.buffer.set_style(ctx.area, self.style);
// Sample the style of the entire widget. This sample will be used to reset the style of
// the cells that are part of the components put on top of the grah area (i.e legend and
// axis names).
let original_style = ctx.buffer.get(ctx.area.left(), ctx.area.top()).style();
let chart_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
let inner_area = b.inner(ctx.area);
b.render(&mut RenderContext {
area: ctx.area,
buffer: ctx.buffer,
state: &mut (),
});
inner_area
}
None => area,
None => ctx.area,
};
let layout = self.layout(chart_area);
@@ -385,23 +395,13 @@ impl<'a> Widget for Chart<'a> {
return;
}
if let Some((x, y)) = layout.title_x {
let title = self.x_axis.title.unwrap();
buf.set_spans(x, y, &title, graph_area.right().saturating_sub(x));
}
if let Some((x, y)) = layout.title_y {
let title = self.y_axis.title.unwrap();
buf.set_spans(x, y, &title, graph_area.right().saturating_sub(x));
}
if let Some(y) = layout.label_x {
let labels = self.x_axis.labels.unwrap();
let total_width = labels.iter().fold(0, |acc, l| l.content.width() + acc) as u16;
let total_width = labels.iter().map(Span::width).sum::<usize>() as u16;
let labels_len = labels.len() as u16;
if total_width < graph_area.width && labels_len > 1 {
for (i, label) in labels.iter().enumerate() {
buf.set_span(
ctx.buffer.set_span(
graph_area.left() + i as u16 * (graph_area.width - 1) / (labels_len - 1)
- label.content.width() as u16,
y,
@@ -418,14 +418,20 @@ impl<'a> Widget for Chart<'a> {
for (i, label) in labels.iter().enumerate() {
let dy = i as u16 * (graph_area.height - 1) / (labels_len - 1);
if dy < graph_area.bottom() {
buf.set_span(x, graph_area.bottom() - 1 - dy, label, label.width() as u16);
ctx.buffer.set_span(
x,
graph_area.bottom() - 1 - dy,
label,
label.width() as u16,
);
}
}
}
if let Some(y) = layout.axis_x {
for x in graph_area.left()..graph_area.right() {
buf.get_mut(x, y)
ctx.buffer
.get_mut(x, y)
.set_symbol(symbols::line::HORIZONTAL)
.set_style(self.x_axis.style);
}
@@ -433,7 +439,8 @@ impl<'a> Widget for Chart<'a> {
if let Some(x) = layout.axis_y {
for y in graph_area.top()..graph_area.bottom() {
buf.get_mut(x, y)
ctx.buffer
.get_mut(x, y)
.set_symbol(symbols::line::VERTICAL)
.set_style(self.y_axis.style);
}
@@ -441,7 +448,8 @@ impl<'a> Widget for Chart<'a> {
if let Some(y) = layout.axis_x {
if let Some(x) = layout.axis_y {
buf.get_mut(x, y)
ctx.buffer
.get_mut(x, y)
.set_symbol(symbols::line::BOTTOM_LEFT)
.set_style(self.x_axis.style);
}
@@ -470,15 +478,24 @@ impl<'a> Widget for Chart<'a> {
}
}
})
.render(graph_area, buf);
.render(&mut RenderContext {
area: graph_area,
buffer: ctx.buffer,
state: &mut (),
});
}
if let Some(legend_area) = layout.legend_area {
ctx.buffer.set_style(legend_area, original_style);
Block::default()
.borders(Borders::ALL)
.render(legend_area, buf);
.render(&mut RenderContext {
area: legend_area,
buffer: ctx.buffer,
state: &mut (),
});
for (i, dataset) in self.datasets.iter().enumerate() {
buf.set_string(
ctx.buffer.set_string(
legend_area.x + 1,
legend_area.y + 1 + i as u16,
&dataset.name,
@@ -486,6 +503,36 @@ impl<'a> Widget for Chart<'a> {
);
}
}
if let Some((x, y)) = layout.title_x {
let title = self.x_axis.title.unwrap();
let width = graph_area.right().saturating_sub(x);
ctx.buffer.set_style(
Rect {
x,
y,
width,
height: 1,
},
original_style,
);
ctx.buffer.set_spans(x, y, &title, width);
}
if let Some((x, y)) = layout.title_y {
let title = self.y_axis.title.unwrap();
let width = graph_area.right().saturating_sub(x);
ctx.buffer.set_style(
Rect {
x,
y,
width,
height: 1,
},
original_style,
);
ctx.buffer.set_spans(x, y, &title, width);
}
}
}

View File

@@ -1,6 +1,4 @@
use crate::buffer::Buffer;
use crate::layout::Rect;
use crate::widgets::Widget;
use crate::widgets::{RenderContext, Widget};
/// A widget to to clear/reset a certain area to allow overdrawing (e.g. for popups)
///
@@ -26,10 +24,12 @@ use crate::widgets::Widget;
pub struct Clear;
impl Widget for Clear {
fn render(self, area: Rect, buf: &mut Buffer) {
for x in area.left()..area.right() {
for y in area.top()..area.bottom() {
buf.get_mut(x, y).reset();
type State = ();
fn render(self, ctx: &mut RenderContext<Self::State>) {
for x in ctx.area.left()..ctx.area.right() {
for y in ctx.area.top()..ctx.area.bottom() {
ctx.buffer.get_mut(x, y).reset();
}
}
}

View File

@@ -1,9 +1,8 @@
use crate::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::Span,
widgets::{Block, Widget},
symbols,
text::{Span, Spans},
widgets::{Block, RenderContext, Widget},
};
/// A widget to display a task progress.
@@ -23,6 +22,7 @@ pub struct Gauge<'a> {
block: Option<Block<'a>>,
ratio: f64,
label: Option<Span<'a>>,
use_unicode: bool,
style: Style,
gauge_style: Style,
}
@@ -33,6 +33,7 @@ impl<'a> Default for Gauge<'a> {
block: None,
ratio: 0.0,
label: None,
use_unicode: false,
style: Style::default(),
gauge_style: Style::default(),
}
@@ -81,27 +82,44 @@ impl<'a> Gauge<'a> {
self.gauge_style = style;
self
}
pub fn use_unicode(mut self, unicode: bool) -> Gauge<'a> {
self.use_unicode = unicode;
self
}
}
impl<'a> Widget for Gauge<'a> {
fn render(mut self, area: Rect, buf: &mut Buffer) {
buf.set_style(area, self.style);
type State = ();
fn render(mut self, ctx: &mut RenderContext<Self::State>) {
ctx.buffer.set_style(ctx.area, self.style);
let gauge_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
let inner_area = b.inner(ctx.area);
b.render(&mut RenderContext {
area: ctx.area,
buffer: ctx.buffer,
state: &mut (),
});
inner_area
}
None => area,
None => ctx.area,
};
buf.set_style(gauge_area, self.gauge_style);
ctx.buffer.set_style(gauge_area, self.gauge_style);
if gauge_area.height < 1 {
return;
}
let center = gauge_area.height / 2 + gauge_area.top();
let width = (f64::from(gauge_area.width) * self.ratio).round() as u16;
let end = gauge_area.left() + width;
let width = f64::from(gauge_area.width) * self.ratio;
//go to regular rounding behavior if we're not using unicode blocks
let end = gauge_area.left()
+ if self.use_unicode {
width.floor() as u16
} else {
width.round() as u16
};
// Label
let ratio = self.ratio;
let label = self
@@ -110,18 +128,32 @@ impl<'a> Widget for Gauge<'a> {
for y in gauge_area.top()..gauge_area.bottom() {
// Gauge
for x in gauge_area.left()..end {
buf.get_mut(x, y).set_symbol(" ");
ctx.buffer.get_mut(x, y).set_symbol(" ");
}
//set unicode block
if self.use_unicode && self.ratio < 1.0 {
ctx.buffer
.get_mut(end, y)
.set_symbol(get_unicode_block(width % 1.0));
}
let mut color_end = end;
if y == center {
let label_width = label.width() as u16;
let middle = (gauge_area.width - label_width) / 2 + gauge_area.left();
buf.set_span(middle, y, &label, gauge_area.right() - middle);
ctx.buffer
.set_span(middle, y, &label, gauge_area.right() - middle);
if self.use_unicode && end >= middle && end < middle + label_width {
color_end = gauge_area.left() + (width.round() as u16); //set color on the label to the rounded gauge level
}
}
// Fix colors
for x in gauge_area.left()..end {
buf.get_mut(x, y)
for x in gauge_area.left()..color_end {
ctx.buffer
.get_mut(x, y)
.set_fg(self.gauge_style.bg.unwrap_or(Color::Reset))
.set_bg(self.gauge_style.fg.unwrap_or(Color::Reset));
}
@@ -129,6 +161,160 @@ impl<'a> Widget for Gauge<'a> {
}
}
fn get_unicode_block<'a>(frac: f64) -> &'a str {
match (frac * 8.0).round() as u16 {
//get how many eighths the fraction is closest to
1 => symbols::block::ONE_EIGHTH,
2 => symbols::block::ONE_QUARTER,
3 => symbols::block::THREE_EIGHTHS,
4 => symbols::block::HALF,
5 => symbols::block::FIVE_EIGHTHS,
6 => symbols::block::THREE_QUARTERS,
7 => symbols::block::SEVEN_EIGHTHS,
8 => symbols::block::FULL,
_ => " ",
}
}
/// A compact widget to display a task progress over a single line.
///
/// # Examples:
///
/// ```
/// # use tui::widgets::{Widget, LineGauge, Block, Borders};
/// # use tui::style::{Style, Color, Modifier};
/// # use tui::symbols;
/// LineGauge::default()
/// .block(Block::default().borders(Borders::ALL).title("Progress"))
/// .gauge_style(Style::default().fg(Color::White).bg(Color::Black).add_modifier(Modifier::BOLD))
/// .line_set(symbols::line::THICK)
/// .ratio(0.4);
/// ```
pub struct LineGauge<'a> {
block: Option<Block<'a>>,
ratio: f64,
label: Option<Spans<'a>>,
line_set: symbols::line::Set,
style: Style,
gauge_style: Style,
}
impl<'a> Default for LineGauge<'a> {
fn default() -> Self {
Self {
block: None,
ratio: 0.0,
label: None,
style: Style::default(),
line_set: symbols::line::NORMAL,
gauge_style: Style::default(),
}
}
}
impl<'a> LineGauge<'a> {
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn ratio(mut self, ratio: f64) -> Self {
assert!(
ratio <= 1.0 && ratio >= 0.0,
"Ratio should be between 0 and 1 inclusively."
);
self.ratio = ratio;
self
}
pub fn line_set(mut self, set: symbols::line::Set) -> Self {
self.line_set = set;
self
}
pub fn label<T>(mut self, label: T) -> Self
where
T: Into<Spans<'a>>,
{
self.label = Some(label.into());
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn gauge_style(mut self, style: Style) -> Self {
self.gauge_style = style;
self
}
}
impl<'a> Widget for LineGauge<'a> {
type State = ();
fn render(mut self, ctx: &mut RenderContext<Self::State>) {
ctx.buffer.set_style(ctx.area, self.style);
let gauge_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(ctx.area);
b.render(&mut RenderContext {
area: ctx.area,
buffer: ctx.buffer,
state: &mut (),
});
inner_area
}
None => ctx.area,
};
if gauge_area.height < 1 {
return;
}
let ratio = self.ratio;
let label = self
.label
.unwrap_or_else(move || Spans::from(format!("{:.0}%", ratio * 100.0)));
let (col, row) = ctx.buffer.set_spans(
gauge_area.left(),
gauge_area.top(),
&label,
gauge_area.width,
);
let start = col + 1;
if start >= gauge_area.right() {
return;
}
let end = start
+ (f64::from(gauge_area.right().saturating_sub(start)) * self.ratio).floor() as u16;
for col in start..end {
ctx.buffer
.get_mut(col, row)
.set_symbol(self.line_set.horizontal)
.set_style(Style {
fg: self.gauge_style.fg,
bg: None,
add_modifier: self.gauge_style.add_modifier,
sub_modifier: self.gauge_style.sub_modifier,
});
}
for col in end..gauge_area.right() {
ctx.buffer
.get_mut(col, row)
.set_symbol(self.line_set.horizontal)
.set_style(Style {
fg: self.gauge_style.bg,
bg: None,
add_modifier: self.gauge_style.add_modifier,
sub_modifier: self.gauge_style.sub_modifier,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,9 +1,8 @@
use crate::{
buffer::Buffer,
layout::{Corner, Rect},
style::Style,
text::Text,
widgets::{Block, StatefulWidget, Widget},
widgets::{Block, RenderContext, Widget},
};
use std::iter::{self, Iterator};
use unicode_width::UnicodeWidthStr;
@@ -11,32 +10,15 @@ use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone)]
pub struct ListState {
offset: usize,
selected: Option<usize>,
}
impl Default for ListState {
fn default() -> ListState {
ListState {
offset: 0,
selected: None,
}
ListState { offset: 0 }
}
}
impl ListState {
pub fn selected(&self) -> Option<usize> {
self.selected
}
pub fn select(&mut self, index: Option<usize>) {
self.selected = index;
if index.is_none() {
self.offset = 0;
}
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub struct ListItem<'a> {
content: Text<'a>,
style: Style,
@@ -88,6 +70,7 @@ pub struct List<'a> {
highlight_style: Style,
/// Symbol in front of the selected item (Shift all items to the right)
highlight_symbol: Option<&'a str>,
selected: Option<usize>,
}
impl<'a> List<'a> {
@@ -102,6 +85,7 @@ impl<'a> List<'a> {
start_corner: Corner::TopLeft,
highlight_style: Style::default(),
highlight_symbol: None,
selected: None,
}
}
@@ -129,20 +113,29 @@ impl<'a> List<'a> {
self.start_corner = corner;
self
}
pub fn select(mut self, index: Option<usize>) -> List<'a> {
self.selected = index;
self
}
}
impl<'a> StatefulWidget for List<'a> {
impl<'a> Widget for List<'a> {
type State = ListState;
fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
buf.set_style(area, self.style);
fn render(mut self, ctx: &mut RenderContext<Self::State>) {
ctx.buffer.set_style(ctx.area, self.style);
let list_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
let inner_area = b.inner(ctx.area);
b.render(&mut RenderContext {
area: ctx.area,
buffer: ctx.buffer,
state: &mut (),
});
inner_area
}
None => area,
None => ctx.area,
};
if list_area.width < 1 || list_area.height < 1 {
@@ -154,10 +147,14 @@ impl<'a> StatefulWidget for List<'a> {
}
let list_height = list_area.height as usize;
let mut start = state.offset;
let mut end = state.offset;
if self.selected.is_none() {
ctx.state.offset = 0;
}
let mut start = ctx.state.offset;
let mut end = ctx.state.offset;
let mut height = 0;
for item in self.items.iter().skip(state.offset) {
for item in self.items.iter().skip(ctx.state.offset) {
if height + item.height() > list_height {
break;
}
@@ -165,7 +162,7 @@ impl<'a> StatefulWidget for List<'a> {
end += 1;
}
let selected = state.selected.unwrap_or(0).min(self.items.len() - 1);
let selected = self.selected.unwrap_or(0).min(self.items.len() - 1);
while selected >= end {
height = height.saturating_add(self.items[end].height());
end += 1;
@@ -182,7 +179,7 @@ impl<'a> StatefulWidget for List<'a> {
height = height.saturating_sub(self.items[end].height());
}
}
state.offset = start;
ctx.state.offset = start;
let highlight_symbol = self.highlight_symbol.unwrap_or("");
let blank_symbol = iter::repeat(" ")
@@ -190,12 +187,12 @@ impl<'a> StatefulWidget for List<'a> {
.collect::<String>();
let mut current_height = 0;
let has_selection = state.selected.is_some();
let has_selection = self.selected.is_some();
for (i, item) in self
.items
.iter_mut()
.enumerate()
.skip(state.offset)
.skip(ctx.state.offset)
.take(end - start)
{
let (x, y) = match self.start_corner {
@@ -216,34 +213,30 @@ impl<'a> StatefulWidget for List<'a> {
height: item.height() as u16,
};
let item_style = self.style.patch(item.style);
buf.set_style(area, item_style);
ctx.buffer.set_style(area, item_style);
let is_selected = state.selected.map(|s| s == i).unwrap_or(false);
let is_selected = self.selected.map(|s| s == i).unwrap_or(false);
let elem_x = if has_selection {
let symbol = if is_selected {
highlight_symbol
} else {
&blank_symbol
};
let (x, _) = buf.set_stringn(x, y, symbol, list_area.width as usize, item_style);
let (x, _) =
ctx.buffer
.set_stringn(x, y, symbol, list_area.width as usize, item_style);
x
} else {
x
};
let max_element_width = (list_area.width - (elem_x - x)) as usize;
for (j, line) in item.content.lines.iter().enumerate() {
buf.set_spans(elem_x, y + j as u16, line, max_element_width as u16);
ctx.buffer
.set_spans(elem_x, y + j as u16, line, max_element_width as u16);
}
if is_selected {
buf.set_style(area, self.highlight_style);
ctx.buffer.set_style(area, self.highlight_style);
}
}
}
}
impl<'a> Widget for List<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let mut state = ListState::default();
StatefulWidget::render(self, area, buf, &mut state);
}
}

View File

@@ -1,4 +1,4 @@
//! `widgets` is a collection of types that implement [`Widget`] or [`StatefulWidget`] or both.
//! `widgets` is a collection of types that implement [`Widget`].
//!
//! All widgets are implemented using the builder pattern and are consumable objects. They are not
//! meant to be stored but used as *commands* to draw common figures in the UI.
@@ -32,11 +32,11 @@ pub use self::barchart::BarChart;
pub use self::block::{Block, BorderType};
pub use self::chart::{Axis, Chart, Dataset, GraphType};
pub use self::clear::Clear;
pub use self::gauge::Gauge;
pub use self::gauge::{Gauge, LineGauge};
pub use self::list::{List, ListItem, ListState};
pub use self::paragraph::{Paragraph, Wrap};
pub use self::sparkline::Sparkline;
pub use self::table::{Row, Table, TableState};
pub use self::table::{Cell, Row, Table, TableState};
pub use self::tabs::Tabs;
use crate::{buffer::Buffer, layout::Rect};
@@ -62,124 +62,30 @@ bitflags! {
/// Base requirements for a Widget
pub trait Widget {
/// Draws the current state of the widget in the given buffer. That the only method required to
/// implement a custom widget.
fn render(self, area: Rect, buf: &mut Buffer);
/// State stores everything that need to be saved between draw calls in order for the widget to
/// implement certain UI patterns.
///
/// For example, the [`List`] widget can highlight the item currently selected. This can be
/// translated in an offset, which is the number of elements to skip in order to have the
/// selected item within the viewport currently allocated to this widget. If the widget had
/// only access to the index of the selected item, it could only implement the following
/// behavior: whenever the selected item is out of the viewport scroll to a predefined position
/// (making the selected item the last viewable item or the one in the middle for example).
/// Nonetheless, if the widget has access to the last computed offset then it can implement a
/// natural scrolling experience where the last offset is reused until the selected item is out
/// of the viewport.
type State;
/// Render the widget in the internal buffer. That the only method required to implement a
/// custom widget.
fn render(self, ctx: &mut RenderContext<Self::State>);
}
/// A `StatefulWidget` is a widget that can take advantage of some local state to remember things
/// between two draw calls.
///
/// Most widgets can be drawn directly based on the input parameters. However, some features may
/// require some kind of associated state to be implemented.
///
/// For example, the [`List`] widget can highlight the item currently selected. This can be
/// translated in an offset, which is the number of elements to skip in order to have the selected
/// item within the viewport currently allocated to this widget. The widget can therefore only
/// provide the following behavior: whenever the selected item is out of the viewport scroll to a
/// predefined position (making the selected item the last viewable item or the one in the middle
/// for example). Nonetheless, if the widget has access to the last computed offset then it can
/// implement a natural scrolling experience where the last offset is reused until the selected
/// item is out of the viewport.
///
/// ## Examples
///
/// ```rust,no_run
/// # use std::io;
/// # use tui::Terminal;
/// # use tui::backend::{Backend, TermionBackend};
/// # use tui::widgets::{Widget, List, ListItem, ListState};
///
/// // Let's say we have some events to display.
/// struct Events {
/// // `items` is the state managed by your application.
/// items: Vec<String>,
/// // `state` is the state that can be modified by the UI. It stores the index of the selected
/// // item as well as the offset computed during the previous draw call (used to implement
/// // natural scrolling).
/// state: ListState
/// }
///
/// impl Events {
/// fn new(items: Vec<String>) -> Events {
/// Events {
/// items,
/// state: ListState::default(),
/// }
/// }
///
/// pub fn set_items(&mut self, items: Vec<String>) {
/// self.items = items;
/// // We reset the state as the associated items have changed. This effectively reset
/// // the selection as well as the stored offset.
/// self.state = ListState::default();
/// }
///
/// // Select the next item. This will not be reflected until the widget is drawn in the
/// // `Terminal::draw` callback using `Frame::render_stateful_widget`.
/// pub fn next(&mut self) {
/// let i = match self.state.selected() {
/// Some(i) => {
/// if i >= self.items.len() - 1 {
/// 0
/// } else {
/// i + 1
/// }
/// }
/// None => 0,
/// };
/// self.state.select(Some(i));
/// }
///
/// // Select the previous item. This will not be reflected until the widget is drawn in the
/// // `Terminal::draw` callback using `Frame::render_stateful_widget`.
/// pub fn previous(&mut self) {
/// let i = match self.state.selected() {
/// Some(i) => {
/// if i == 0 {
/// self.items.len() - 1
/// } else {
/// i - 1
/// }
/// }
/// None => 0,
/// };
/// self.state.select(Some(i));
/// }
///
/// // Unselect the currently selected item if any. The implementation of `ListState` makes
/// // sure that the stored offset is also reset.
/// pub fn unselect(&mut self) {
/// self.state.select(None);
/// }
/// }
///
/// let stdout = io::stdout();
/// let backend = TermionBackend::new(stdout);
/// let mut terminal = Terminal::new(backend).unwrap();
///
/// let mut events = Events::new(vec![
/// String::from("Item 1"),
/// String::from("Item 2")
/// ]);
///
/// loop {
/// terminal.draw(|f| {
/// // The items managed by the application are transformed to something
/// // that is understood by tui.
/// let items: Vec<ListItem>= events.items.iter().map(|i| ListItem::new(i.as_ref())).collect();
/// // The `List` widget is then built with those items.
/// let list = List::new(items);
/// // Finally the widget is rendered using the associated state. `events.state` is
/// // effectively the only thing that we will "remember" from this draw call.
/// f.render_stateful_widget(list, f.size(), &mut events.state);
/// });
///
/// // In response to some input events or an external http request or whatever:
/// events.next();
/// }
/// ```
pub trait StatefulWidget {
type State;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State);
/// RenderContext is a set of dependencies that may be used when a widget is rendered.
pub struct RenderContext<'a, S> {
/// Area where the widget is rendered.
pub area: Rect,
/// Buffer where the drawing operations will be temporarily registered.
pub buffer: &'a mut Buffer,
/// Internal state associated with the widget.
pub state: &'a mut S,
}

View File

@@ -1,11 +1,10 @@
use crate::{
buffer::Buffer,
layout::{Alignment, Rect},
layout::Alignment,
style::Style,
text::{StyledGrapheme, Text},
widgets::{
reflow::{LineComposer, LineTruncator, WordWrapper},
Block, Widget,
Block, RenderContext, Widget,
},
};
use std::iter;
@@ -133,15 +132,21 @@ impl<'a> Paragraph<'a> {
}
impl<'a> Widget for Paragraph<'a> {
fn render(mut self, area: Rect, buf: &mut Buffer) {
buf.set_style(area, self.style);
type State = ();
fn render(mut self, ctx: &mut RenderContext<Self::State>) {
ctx.buffer.set_style(ctx.area, self.style);
let text_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
let inner_area = b.inner(ctx.area);
b.render(&mut RenderContext {
area: ctx.area,
buffer: ctx.buffer,
state: &mut (),
});
inner_area
}
None => area,
None => ctx.area,
};
if text_area.height < 1 {
@@ -176,7 +181,8 @@ impl<'a> Widget for Paragraph<'a> {
if y >= self.scroll.0 {
let mut x = get_line_offset(current_line_width, text_area.width, self.alignment);
for StyledGrapheme { symbol, style } in current_line {
buf.get_mut(text_area.left() + x, text_area.top() + y - self.scroll.0)
ctx.buffer
.get_mut(text_area.left() + x, text_area.top() + y - self.scroll.0)
.set_symbol(if symbol.is_empty() {
// If the symbol is empty, the last char which rendered last time will
// leave on the line. It's a quick fix.

View File

@@ -57,7 +57,7 @@ impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> {
let mut symbols_exhausted = true;
for StyledGrapheme { symbol, style } in &mut self.symbols {
symbols_exhausted = false;
let symbol_whitespace = symbol.chars().all(&char::is_whitespace);
let symbol_whitespace = symbol.chars().all(&char::is_whitespace) && symbol != NBSP;
// Ignore characters wider that the total max width.
if symbol.width() as u16 > self.max_line_width
@@ -77,7 +77,7 @@ impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> {
}
// Mark the previous symbol as word end.
if symbol_whitespace && !prev_whitespace && symbol != NBSP {
if symbol_whitespace && !prev_whitespace {
symbols_to_last_word_end = self.current_line.len();
width_to_last_word_end = current_line_width;
}

View File

@@ -1,9 +1,7 @@
use crate::{
buffer::Buffer,
layout::Rect,
style::Style,
symbols,
widgets::{Block, Widget},
widgets::{Block, RenderContext, Widget},
};
use std::cmp::min;
@@ -75,14 +73,20 @@ impl<'a> Sparkline<'a> {
}
impl<'a> Widget for Sparkline<'a> {
fn render(mut self, area: Rect, buf: &mut Buffer) {
type State = ();
fn render(mut self, ctx: &mut RenderContext<Self::State>) {
let spark_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
let inner_area = b.inner(ctx.area);
b.render(&mut RenderContext {
area: ctx.area,
buffer: ctx.buffer,
state: &mut (),
});
inner_area
}
None => area,
None => ctx.area,
};
if spark_area.height < 1 {
@@ -119,7 +123,8 @@ impl<'a> Widget for Sparkline<'a> {
7 => self.bar_set.seven_eighths,
_ => self.bar_set.full,
};
buf.get_mut(spark_area.left() + i as u16, spark_area.top() + j)
ctx.buffer
.get_mut(spark_area.left() + i as u16, spark_area.top() + j)
.set_symbol(symbol)
.set_style(self.style);
@@ -135,14 +140,23 @@ impl<'a> Widget for Sparkline<'a> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{
buffer::Buffer,
layout::Rect,
widgets::{RenderContext, Sparkline, Widget},
};
#[test]
fn it_does_not_panic_if_max_is_zero() {
let widget = Sparkline::default().data(&[0, 0, 0]);
let area = Rect::new(0, 0, 3, 1);
let mut buffer = Buffer::empty(area);
widget.render(area, &mut buffer);
let mut ctx = RenderContext {
area,
buffer: &mut buffer,
state: &mut (),
};
widget.render(&mut ctx);
}
#[test]
@@ -150,6 +164,11 @@ mod tests {
let widget = Sparkline::default().data(&[0, 1, 2]).max(0);
let area = Rect::new(0, 0, 3, 1);
let mut buffer = Buffer::empty(area);
widget.render(area, &mut buffer);
let mut ctx = RenderContext {
area,
buffer: &mut buffer,
state: &mut (),
};
widget.render(&mut ctx);
}
}

View File

@@ -2,7 +2,8 @@ use crate::{
buffer::Buffer,
layout::{Constraint, Rect},
style::Style,
widgets::{Block, StatefulWidget, Widget},
text::Text,
widgets::{Block, RenderContext, Widget},
};
use cassowary::{
strength::{MEDIUM, REQUIRED, WEAK},
@@ -11,158 +12,226 @@ use cassowary::{
};
use std::{
collections::HashMap,
fmt::Display,
iter::{self, Iterator},
};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone)]
pub struct TableState {
offset: usize,
selected: Option<usize>,
/// A [`Cell`] contains the [`Text`] to be displayed in a [`Row`] of a [`Table`].
///
/// It can be created from anything that can be converted to a [`Text`].
/// ```rust
/// # use tui::widgets::Cell;
/// # use tui::style::{Style, Modifier};
/// # use tui::text::{Span, Spans, Text};
/// Cell::from("simple string");
///
/// Cell::from(Span::from("span"));
///
/// Cell::from(Spans::from(vec![
/// Span::raw("a vec of "),
/// Span::styled("spans", Style::default().add_modifier(Modifier::BOLD))
/// ]));
///
/// Cell::from(Text::from("a text"));
/// ```
///
/// You can apply a [`Style`] on the entire [`Cell`] using [`Cell::style`] or rely on the styling
/// capabilities of [`Text`].
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Cell<'a> {
content: Text<'a>,
style: Style,
}
impl Default for TableState {
fn default() -> TableState {
TableState {
offset: 0,
selected: None,
}
impl<'a> Cell<'a> {
/// Set the `Style` of this cell.
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
}
impl TableState {
pub fn selected(&self) -> Option<usize> {
self.selected
}
pub fn select(&mut self, index: Option<usize>) {
self.selected = index;
if index.is_none() {
self.offset = 0;
}
}
}
/// Holds data to be displayed in a Table widget
#[derive(Debug, Clone)]
pub enum Row<D>
impl<'a, T> From<T> for Cell<'a>
where
D: Iterator,
D::Item: Display,
T: Into<Text<'a>>,
{
Data(D),
StyledData(D, Style),
fn from(content: T) -> Cell<'a> {
Cell {
content: content.into(),
style: Style::default(),
}
}
}
/// A widget to display data in formatted columns
///
/// # Examples
/// Holds data to be displayed in a [`Table`] widget.
///
/// A [`Row`] is a collection of cells. It can be created from simple strings:
/// ```rust
/// # use tui::widgets::Row;
/// Row::new(vec!["Cell1", "Cell2", "Cell3"]);
/// ```
/// # use tui::widgets::{Block, Borders, Table, Row};
/// # use tui::layout::Constraint;
///
/// But if you need a bit more control over individual cells, you can explicity create [`Cell`]s:
/// ```rust
/// # use tui::widgets::{Row, Cell};
/// # use tui::style::{Style, Color};
/// let row_style = Style::default().fg(Color::White);
/// Table::new(
/// ["Col1", "Col2", "Col3"].into_iter(),
/// vec![
/// Row::StyledData(["Row11", "Row12", "Row13"].into_iter(), row_style),
/// Row::StyledData(["Row21", "Row22", "Row23"].into_iter(), row_style),
/// Row::StyledData(["Row31", "Row32", "Row33"].into_iter(), row_style),
/// Row::Data(["Row41", "Row42", "Row43"].into_iter())
/// ].into_iter()
/// )
/// .block(Block::default().title("Table"))
/// .header_style(Style::default().fg(Color::Yellow))
/// .widths(&[Constraint::Length(5), Constraint::Length(5), Constraint::Length(10)])
/// .style(Style::default().fg(Color::White))
/// .column_spacing(1);
/// Row::new(vec![
/// Cell::from("Cell1"),
/// Cell::from("Cell2").style(Style::default().fg(Color::Yellow)),
/// ]);
/// ```
#[derive(Debug, Clone)]
pub struct Table<'a, H, R> {
///
/// By default, a row has a height of 1 but you can change this using [`Row::height`].
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Row<'a> {
cells: Vec<Cell<'a>>,
height: u16,
style: Style,
bottom_margin: u16,
}
impl<'a> Row<'a> {
/// Creates a new [`Row`] from an iterator where items can be converted to a [`Cell`].
pub fn new<T>(cells: T) -> Self
where
T: IntoIterator,
T::Item: Into<Cell<'a>>,
{
Self {
height: 1,
cells: cells.into_iter().map(|c| c.into()).collect(),
style: Style::default(),
bottom_margin: 0,
}
}
/// Set the fixed height of the [`Row`]. Any [`Cell`] whose content has more lines than this
/// height will see its content truncated.
pub fn height(mut self, height: u16) -> Self {
self.height = height;
self
}
/// Set the [`Style`] of the entire row. This [`Style`] can be overriden by the [`Style`] of a
/// any individual [`Cell`] or event by their [`Text`] content.
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
/// Set the bottom margin. By default, the bottom margin is `0`.
pub fn bottom_margin(mut self, margin: u16) -> Self {
self.bottom_margin = margin;
self
}
/// Returns the total height of the row.
fn total_height(&self) -> u16 {
self.height.saturating_add(self.bottom_margin)
}
}
/// A widget to display data in formatted columns.
///
/// It is a collection of [`Row`]s, themselves composed of [`Cell`]s:
/// ```rust
/// # use tui::widgets::{Block, Borders, Table, Row, Cell};
/// # use tui::layout::Constraint;
/// # use tui::style::{Style, Color, Modifier};
/// # use tui::text::{Text, Spans, Span};
/// Table::new(vec![
/// // Row can be created from simple strings.
/// Row::new(vec!["Row11", "Row12", "Row13"]),
/// // You can style the entire row.
/// Row::new(vec!["Row21", "Row22", "Row23"]).style(Style::default().fg(Color::Blue)),
/// // If you need more control over the styling you may need to create Cells directly
/// Row::new(vec![
/// Cell::from("Row31"),
/// Cell::from("Row32").style(Style::default().fg(Color::Yellow)),
/// Cell::from(Spans::from(vec![
/// Span::raw("Row"),
/// Span::styled("33", Style::default().fg(Color::Green))
/// ])),
/// ]),
/// // If a Row need to display some content over multiple lines, you just have to change
/// // its height.
/// Row::new(vec![
/// Cell::from("Row\n41"),
/// Cell::from("Row\n42"),
/// Cell::from("Row\n43"),
/// ]).height(2),
/// ])
/// // You can set the style of the entire Table.
/// .style(Style::default().fg(Color::White))
/// // It has an optional header, which is simply a Row always visible at the top.
/// .header(
/// Row::new(vec!["Col1", "Col2", "Col3"])
/// .style(Style::default().fg(Color::Yellow))
/// // If you want some space between the header and the rest of the rows, you can always
/// // specify some margin at the bottom.
/// .bottom_margin(1)
/// )
/// // As any other widget, a Table can be wrapped in a Block.
/// .block(Block::default().title("Table"))
/// // Columns widths are constrained in the same way as Layout...
/// .widths(&[Constraint::Length(5), Constraint::Length(5), Constraint::Length(10)])
/// // ...and they can be separated by a fixed spacing.
/// .column_spacing(1)
/// // If you wish to highlight a row in any specific way when it is selected...
/// .highlight_style(Style::default().add_modifier(Modifier::BOLD))
/// // ...and potentially show a symbol in front of the selection.
/// .highlight_symbol(">>");
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct Table<'a> {
/// A block to wrap the widget in
block: Option<Block<'a>>,
/// Base style for the widget
style: Style,
/// Header row for all columns
header: H,
/// Style for the header
header_style: Style,
/// Width constraints for each column
widths: &'a [Constraint],
/// Space between each column
column_spacing: u16,
/// Space between the header and the rows
header_gap: u16,
/// Style used to render the selected row
highlight_style: Style,
/// Symbol in front of the selected rom
highlight_symbol: Option<&'a str>,
/// Optional header
header: Option<Row<'a>>,
/// Data to display in each row
rows: R,
rows: Vec<Row<'a>>,
selected: Option<usize>,
}
impl<'a, H, R> Default for Table<'a, H, R>
where
H: Iterator + Default,
R: Iterator + Default,
{
fn default() -> Table<'a, H, R> {
Table {
impl<'a> Table<'a> {
pub fn new<T>(rows: T) -> Self
where
T: IntoIterator<Item = Row<'a>>,
{
Self {
block: None,
style: Style::default(),
header: H::default(),
header_style: Style::default(),
widths: &[],
column_spacing: 1,
header_gap: 1,
highlight_style: Style::default(),
highlight_symbol: None,
rows: R::default(),
header: None,
rows: rows.into_iter().collect(),
selected: None,
}
}
}
impl<'a, H, D, R> Table<'a, H, R>
where
H: Iterator,
D: Iterator,
D::Item: Display,
R: Iterator<Item = Row<D>>,
{
pub fn new(header: H, rows: R) -> Table<'a, H, R> {
Table {
block: None,
style: Style::default(),
header,
header_style: Style::default(),
widths: &[],
column_spacing: 1,
header_gap: 1,
highlight_style: Style::default(),
highlight_symbol: None,
rows,
}
}
pub fn block(mut self, block: Block<'a>) -> Table<'a, H, R> {
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn header<II>(mut self, header: II) -> Table<'a, H, R>
where
II: IntoIterator<Item = H::Item, IntoIter = H>,
{
self.header = header.into_iter();
pub fn header(mut self, header: Row<'a>) -> Self {
self.header = Some(header);
self
}
pub fn header_style(mut self, style: Style) -> Table<'a, H, R> {
self.header_style = style;
self
}
pub fn widths(mut self, widths: &'a [Constraint]) -> Table<'a, H, R> {
pub fn widths(mut self, widths: &'a [Constraint]) -> Self {
let between_0_and_100 = |&w| match w {
Constraint::Percentage(p) => p <= 100,
_ => true,
@@ -175,63 +244,32 @@ where
self
}
pub fn rows<II>(mut self, rows: II) -> Table<'a, H, R>
where
II: IntoIterator<Item = Row<D>, IntoIter = R>,
{
self.rows = rows.into_iter();
self
}
pub fn style(mut self, style: Style) -> Table<'a, H, R> {
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Table<'a, H, R> {
pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Self {
self.highlight_symbol = Some(highlight_symbol);
self
}
pub fn highlight_style(mut self, highlight_style: Style) -> Table<'a, H, R> {
pub fn highlight_style(mut self, highlight_style: Style) -> Self {
self.highlight_style = highlight_style;
self
}
pub fn column_spacing(mut self, spacing: u16) -> Table<'a, H, R> {
pub fn column_spacing(mut self, spacing: u16) -> Self {
self.column_spacing = spacing;
self
}
pub fn header_gap(mut self, gap: u16) -> Table<'a, H, R> {
self.header_gap = gap;
pub fn select(mut self, index: Option<usize>) -> Self {
self.selected = index;
self
}
}
impl<'a, H, D, R> StatefulWidget for Table<'a, H, R>
where
H: Iterator,
H::Item: Display,
D: Iterator,
D::Item: Display,
R: Iterator<Item = Row<D>>,
{
type State = TableState;
fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
buf.set_style(area, self.style);
// Render block if necessary and get the drawing area
let table_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
inner_area
}
None => area,
};
fn get_columns_widths(&self, max_width: u16, has_selection: bool) -> Vec<u16> {
let mut solver = Solver::new();
let mut var_indices = HashMap::new();
let mut ccs = Vec::new();
@@ -241,15 +279,24 @@ where
variables.push(var);
var_indices.insert(var, i);
}
let spacing_width = (variables.len() as u16).saturating_sub(1) * self.column_spacing;
let mut available_width = max_width.saturating_sub(spacing_width);
if has_selection {
let highlight_symbol_width =
self.highlight_symbol.map(|s| s.width() as u16).unwrap_or(0);
available_width = available_width.saturating_sub(highlight_symbol_width);
}
for (i, constraint) in self.widths.iter().enumerate() {
ccs.push(variables[i] | GE(WEAK) | 0.);
ccs.push(match *constraint {
Constraint::Length(v) => variables[i] | EQ(MEDIUM) | f64::from(v),
Constraint::Percentage(v) => {
variables[i] | EQ(WEAK) | (f64::from(v * area.width) / 100.0)
variables[i] | EQ(WEAK) | (f64::from(v * available_width) / 100.0)
}
Constraint::Ratio(n, d) => {
variables[i] | EQ(WEAK) | (f64::from(area.width) * f64::from(n) / f64::from(d))
variables[i]
| EQ(WEAK)
| (f64::from(available_width) * f64::from(n) / f64::from(d))
}
Constraint::Min(v) => variables[i] | GE(WEAK) | f64::from(v),
Constraint::Max(v) => variables[i] | LE(WEAK) | f64::from(v),
@@ -261,98 +308,211 @@ where
.iter()
.fold(Expression::from_constant(0.), |acc, v| acc + *v)
| LE(REQUIRED)
| f64::from(
area.width - 2 - (self.column_spacing * (variables.len() as u16 - 1)),
),
| f64::from(available_width),
)
.unwrap();
solver.add_constraints(&ccs).unwrap();
let mut solved_widths = vec![0; variables.len()];
let mut widths = vec![0; variables.len()];
for &(var, value) in solver.fetch_changes() {
let index = var_indices[&var];
let value = if value.is_sign_negative() {
0
} else {
value as u16
value.round() as u16
};
solved_widths[index] = value
widths[index] = value;
}
// Cassowary could still return columns widths greater than the max width when there are
// fixed length constraints that cannot be satisfied. Therefore, we clamp the widths from
// left to right.
let mut available_width = max_width;
for w in &mut widths {
*w = available_width.min(*w);
available_width = available_width
.saturating_sub(*w)
.saturating_sub(self.column_spacing);
}
widths
}
fn get_row_bounds(&self, offset: usize, max_height: u16) -> (usize, usize) {
let mut start = offset;
let mut end = offset;
let mut height = 0;
for item in self.rows.iter().skip(offset) {
if height + item.height > max_height {
break;
}
height += item.total_height();
end += 1;
}
let mut y = table_area.top();
let mut x = table_area.left();
// Draw header
if y < table_area.bottom() {
for (w, t) in solved_widths.iter().zip(self.header.by_ref()) {
buf.set_stringn(x, y, format!("{}", t), *w as usize, self.header_style);
x += *w + self.column_spacing;
let selected = self.selected.unwrap_or(0).min(self.rows.len() - 1);
while selected >= end {
height = height.saturating_add(self.rows[end].total_height());
end += 1;
while height > max_height {
height = height.saturating_sub(self.rows[start].total_height());
start += 1;
}
}
y += 1 + self.header_gap;
while selected < start {
start -= 1;
height = height.saturating_add(self.rows[start].total_height());
while height > max_height {
end -= 1;
height = height.saturating_sub(self.rows[end].total_height());
}
}
(start, end)
}
}
// Use highlight_style only if something is selected
let (selected, highlight_style) = match state.selected {
Some(i) => (Some(i), self.highlight_style),
None => (None, self.style),
#[derive(Debug, Clone)]
pub struct TableState {
offset: usize,
}
impl Default for TableState {
fn default() -> TableState {
TableState { offset: 0 }
}
}
impl<'a> Widget for Table<'a> {
type State = TableState;
fn render(mut self, ctx: &mut RenderContext<Self::State>) {
if ctx.area.area() == 0 {
return;
}
ctx.buffer.set_style(ctx.area, self.style);
let table_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(ctx.area);
b.render(&mut RenderContext {
area: ctx.area,
buffer: ctx.buffer,
state: &mut (),
});
inner_area
}
None => ctx.area,
};
let has_selection = self.selected.is_some();
if !has_selection {
ctx.state.offset = 0;
}
let columns_widths = self.get_columns_widths(table_area.width, has_selection);
let highlight_symbol = self.highlight_symbol.unwrap_or("");
let blank_symbol = iter::repeat(" ")
.take(highlight_symbol.width())
.collect::<String>();
let mut current_height = 0;
let mut rows_height = table_area.height;
// Draw header
if let Some(ref header) = self.header {
let max_header_height = table_area.height.min(header.total_height());
ctx.buffer.set_style(
Rect {
x: table_area.left(),
y: table_area.top(),
width: table_area.width,
height: table_area.height.min(header.height),
},
header.style,
);
let mut col = table_area.left();
if has_selection {
col += (highlight_symbol.width() as u16).min(table_area.width);
}
for (width, cell) in columns_widths.iter().zip(header.cells.iter()) {
render_cell(
ctx.buffer,
cell,
Rect {
x: col,
y: table_area.top(),
width: *width,
height: max_header_height,
},
);
col += *width + self.column_spacing;
}
current_height += max_header_height;
rows_height = rows_height.saturating_sub(max_header_height);
}
// Draw rows
let default_style = Style::default();
if y < table_area.bottom() {
let remaining = (table_area.bottom() - y) as usize;
// Make sure the table shows the selected item
state.offset = if let Some(selected) = selected {
if selected >= remaining + state.offset - 1 {
selected + 1 - remaining
} else if selected < state.offset {
selected
} else {
state.offset
}
} else {
0
if self.rows.is_empty() {
return;
}
let (start, end) = self.get_row_bounds(ctx.state.offset, rows_height);
ctx.state.offset = start;
for (i, table_row) in self
.rows
.iter_mut()
.enumerate()
.skip(ctx.state.offset)
.take(end - start)
{
let (row, col) = (table_area.top() + current_height, table_area.left());
current_height += table_row.total_height();
let table_row_area = Rect {
x: col,
y: row,
width: table_area.width,
height: table_row.height,
};
for (i, row) in self.rows.skip(state.offset).take(remaining).enumerate() {
let (data, style, symbol) = match row {
Row::Data(d) | Row::StyledData(d, _)
if Some(i) == state.selected.map(|s| s - state.offset) =>
{
(d, highlight_style, highlight_symbol)
}
Row::Data(d) => (d, default_style, blank_symbol.as_ref()),
Row::StyledData(d, s) => (d, s, blank_symbol.as_ref()),
ctx.buffer.set_style(table_row_area, table_row.style);
let is_selected = self.selected.map(|s| s == i).unwrap_or(false);
let table_row_start_col = if has_selection {
let symbol = if is_selected {
highlight_symbol
} else {
&blank_symbol
};
x = table_area.left();
for (c, (w, elt)) in solved_widths.iter().zip(data).enumerate() {
let s = if c == 0 {
format!("{}{}", symbol, elt)
} else {
format!("{}", elt)
};
buf.set_stringn(x, y + i as u16, s, *w as usize, style);
x += *w + self.column_spacing;
}
let (col, _) = ctx.buffer.set_stringn(
col,
row,
symbol,
table_area.width as usize,
table_row.style,
);
col
} else {
col
};
let mut col = table_row_start_col;
for (width, cell) in columns_widths.iter().zip(table_row.cells.iter()) {
render_cell(
ctx.buffer,
cell,
Rect {
x: col,
y: row,
width: *width,
height: table_row.height,
},
);
col += *width + self.column_spacing;
}
if is_selected {
ctx.buffer.set_style(table_row_area, self.highlight_style);
}
}
}
}
impl<'a, H, D, R> Widget for Table<'a, H, R>
where
H: Iterator,
H::Item: Display,
D: Iterator,
D::Item: Display,
R: Iterator<Item = Row<D>>,
{
fn render(self, area: Rect, buf: &mut Buffer) {
let mut state = TableState::default();
StatefulWidget::render(self, area, buf, &mut state);
fn render_cell(buf: &mut Buffer, cell: &Cell, area: Rect) {
buf.set_style(area, cell.style);
for (i, spans) in cell.content.lines.iter().enumerate() {
if i as u16 >= area.height {
break;
}
buf.set_spans(area.x, area.y + i as u16, spans, area.width);
}
}
@@ -363,7 +523,6 @@ mod tests {
#[test]
#[should_panic]
fn table_invalid_percentages() {
Table::new([""].iter(), vec![Row::Data([""].iter())].into_iter())
.widths(&[Constraint::Percentage(110)]);
Table::new(vec![]).widths(&[Constraint::Percentage(110)]);
}
}

View File

@@ -1,10 +1,9 @@
use crate::{
buffer::Buffer,
layout::Rect,
style::Style,
symbols,
text::{Span, Spans},
widgets::{Block, Widget},
widgets::{Block, RenderContext, Widget},
};
/// A widget to display available tabs in a multiple panels context.
@@ -81,15 +80,21 @@ impl<'a> Tabs<'a> {
}
impl<'a> Widget for Tabs<'a> {
fn render(mut self, area: Rect, buf: &mut Buffer) {
buf.set_style(area, self.style);
type State = ();
fn render(mut self, ctx: &mut RenderContext<Self::State>) {
ctx.buffer.set_style(ctx.area, self.style);
let tabs_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
let inner_area = b.inner(ctx.area);
b.render(&mut RenderContext {
area: ctx.area,
buffer: ctx.buffer,
state: &mut (),
});
inner_area
}
None => area,
None => ctx.area,
};
if tabs_area.height < 1 {
@@ -105,9 +110,11 @@ impl<'a> Widget for Tabs<'a> {
if remaining_width == 0 {
break;
}
let pos = buf.set_spans(x, tabs_area.top(), &title, remaining_width);
let pos = ctx
.buffer
.set_spans(x, tabs_area.top(), &title, remaining_width);
if i == self.selected {
buf.set_style(
ctx.buffer.set_style(
Rect {
x,
y: tabs_area.top(),
@@ -122,7 +129,9 @@ impl<'a> Widget for Tabs<'a> {
if remaining_width == 0 || last_title {
break;
}
let pos = buf.set_span(x, tabs_area.top(), &self.divider, remaining_width);
let pos = ctx
.buffer
.set_span(x, tabs_area.top(), &self.divider, remaining_width);
x = pos.0;
}
}

View File

@@ -34,21 +34,21 @@ fn backend_termion_should_only_write_diffs() -> Result<(), Box<dyn std::error::E
let mut s = String::new();
// First draw
write!(s, "{}", cursor::Goto(1, 1))?;
s.push_str("a");
s.push('a');
write!(s, "{}", color::Fg(color::Reset))?;
write!(s, "{}", color::Bg(color::Reset))?;
write!(s, "{}", style::Reset)?;
write!(s, "{}", cursor::Hide)?;
// Second draw
write!(s, "{}", cursor::Goto(2, 1))?;
s.push_str("b");
s.push('b');
write!(s, "{}", color::Fg(color::Reset))?;
write!(s, "{}", color::Bg(color::Reset))?;
write!(s, "{}", style::Reset)?;
write!(s, "{}", cursor::Hide)?;
// Third draw
write!(s, "{}", cursor::Goto(3, 1))?;
s.push_str("c");
s.push('c');
write!(s, "{}", color::Fg(color::Reset))?;
write!(s, "{}", color::Bg(color::Reset))?;
write!(s, "{}", style::Reset)?;

View File

@@ -1,5 +1,8 @@
use std::error::Error;
use tui::{
backend::{Backend, TestBackend},
layout::Rect,
widgets::Paragraph,
Terminal,
};
@@ -11,3 +14,23 @@ fn terminal_buffer_size_should_be_limited() {
assert_eq!(size.width, 255);
assert_eq!(size.height, 255);
}
#[test]
fn terminal_draw_returns_the_completed_frame() -> Result<(), Box<dyn Error>> {
let backend = TestBackend::new(10, 10);
let mut terminal = Terminal::new(backend)?;
let frame = terminal.draw(|f| {
let paragrah = Paragraph::new("Test");
f.render_widget(paragrah, f.size());
})?;
assert_eq!(frame.buffer.get(0, 0).symbol, "T");
assert_eq!(frame.area, Rect::new(0, 0, 10, 10));
terminal.backend_mut().resize(8, 8);
let frame = terminal.draw(|f| {
let paragrah = Paragraph::new("test");
f.render_widget(paragrah, f.size());
})?;
assert_eq!(frame.buffer.get(0, 0).symbol, "t");
assert_eq!(frame.area, Rect::new(0, 0, 8, 8));
Ok(())
}

40
tests/widgets_barchart.rs Normal file
View File

@@ -0,0 +1,40 @@
use tui::backend::TestBackend;
use tui::buffer::Buffer;
use tui::widgets::{BarChart, Block, Borders};
use tui::Terminal;
#[test]
fn widgets_barchart_not_full_below_max_value() {
let test_case = |expected| {
let backend = TestBackend::new(30, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let size = f.size();
let barchart = BarChart::default()
.block(Block::default().borders(Borders::ALL))
.data(&[("empty", 0), ("half", 50), ("almost", 99), ("full", 100)])
.max(100)
.bar_width(7)
.bar_gap(0);
f.render_widget(barchart, size);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
};
// check that bars fill up correctly up to max value
test_case(Buffer::with_lines(vec![
"┌────────────────────────────┐",
"│ ▇▇▇▇▇▇▇███████│",
"│ ██████████████│",
"│ ██████████████│",
"│ ▄▄▄▄▄▄▄██████████████│",
"│ █████████████████████│",
"│ █████████████████████│",
"│ ██50█████99█████100██│",
"│empty half almost full │",
"└────────────────────────────┘",
]));
}

View File

@@ -45,3 +45,169 @@ fn widgets_block_renders() {
}
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widgets_block_renders_on_small_areas() {
let test_case = |block, area: Rect, expected| {
let backend = TestBackend::new(area.width, area.height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
f.render_widget(block, area);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
};
let one_cell_test_cases = [
(Borders::NONE, "T"),
(Borders::LEFT, ""),
(Borders::TOP, "T"),
(Borders::RIGHT, ""),
(Borders::BOTTOM, "T"),
(Borders::ALL, ""),
];
for (borders, symbol) in one_cell_test_cases.iter().cloned() {
test_case(
Block::default().title("Test").borders(borders),
Rect {
x: 0,
y: 0,
width: 0,
height: 0,
},
Buffer::empty(Rect {
x: 0,
y: 0,
width: 0,
height: 0,
}),
);
test_case(
Block::default().title("Test").borders(borders),
Rect {
x: 0,
y: 0,
width: 1,
height: 0,
},
Buffer::empty(Rect {
x: 0,
y: 0,
width: 1,
height: 0,
}),
);
test_case(
Block::default().title("Test").borders(borders),
Rect {
x: 0,
y: 0,
width: 0,
height: 1,
},
Buffer::empty(Rect {
x: 0,
y: 0,
width: 0,
height: 1,
}),
);
test_case(
Block::default().title("Test").borders(borders),
Rect {
x: 0,
y: 0,
width: 1,
height: 1,
},
Buffer::with_lines(vec![symbol]),
);
}
test_case(
Block::default().title("Test").borders(Borders::LEFT),
Rect {
x: 0,
y: 0,
width: 4,
height: 1,
},
Buffer::with_lines(vec!["│Tes"]),
);
test_case(
Block::default().title("Test").borders(Borders::RIGHT),
Rect {
x: 0,
y: 0,
width: 4,
height: 1,
},
Buffer::with_lines(vec!["Tes│"]),
);
test_case(
Block::default().title("Test").borders(Borders::RIGHT),
Rect {
x: 0,
y: 0,
width: 4,
height: 1,
},
Buffer::with_lines(vec!["Tes│"]),
);
test_case(
Block::default()
.title("Test")
.borders(Borders::LEFT | Borders::RIGHT),
Rect {
x: 0,
y: 0,
width: 4,
height: 1,
},
Buffer::with_lines(vec!["│Te│"]),
);
test_case(
Block::default().title("Test").borders(Borders::TOP),
Rect {
x: 0,
y: 0,
width: 4,
height: 1,
},
Buffer::with_lines(vec!["Test"]),
);
test_case(
Block::default().title("Test").borders(Borders::TOP),
Rect {
x: 0,
y: 0,
width: 5,
height: 1,
},
Buffer::with_lines(vec!["Test─"]),
);
test_case(
Block::default()
.title("Test")
.borders(Borders::LEFT | Borders::TOP),
Rect {
x: 0,
y: 0,
width: 5,
height: 1,
},
Buffer::with_lines(vec!["┌Test"]),
);
test_case(
Block::default()
.title("Test")
.borders(Borders::LEFT | Borders::TOP),
Rect {
x: 0,
y: 0,
width: 6,
height: 1,
},
Buffer::with_lines(vec!["┌Test─"]),
);
}

View File

@@ -1,5 +1,6 @@
use tui::{
backend::TestBackend,
buffer::Buffer,
layout::Rect,
style::{Color, Style},
symbols,
@@ -12,6 +13,40 @@ fn create_labels<'a>(labels: &'a [&'a str]) -> Vec<Span<'a>> {
labels.iter().map(|l| Span::from(*l)).collect()
}
#[test]
fn widgets_chart_can_render_on_small_areas() {
let test_case = |width, height| {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let datasets = vec![Dataset::default()
.marker(symbols::Marker::Braille)
.style(Style::default().fg(Color::Magenta))
.data(&[(0.0, 0.0)])];
let chart = Chart::new(datasets)
.block(Block::default().title("Plot").borders(Borders::ALL))
.x_axis(
Axis::default()
.bounds([0.0, 0.0])
.labels(create_labels(&["0.0", "1.0"])),
)
.y_axis(
Axis::default()
.bounds([0.0, 0.0])
.labels(create_labels(&["0.0", "1.0"])),
);
f.render_widget(chart, f.size());
})
.unwrap();
};
test_case(0, 0);
test_case(0, 1);
test_case(1, 0);
test_case(1, 1);
test_case(2, 2);
}
#[test]
fn widgets_chart_can_have_axis_with_zero_length_bounds() {
let backend = TestBackend::new(100, 100);
@@ -124,3 +159,255 @@ fn widgets_chart_can_have_empty_datasets() {
})
.unwrap();
}
#[test]
fn widgets_chart_can_have_a_legend() {
let backend = TestBackend::new(60, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let datasets = vec![
Dataset::default()
.name("Dataset 1")
.style(Style::default().fg(Color::Blue))
.data(&[
(0.0, 0.0),
(10.0, 1.0),
(20.0, 2.0),
(30.0, 3.0),
(40.0, 4.0),
(50.0, 5.0),
(60.0, 6.0),
(70.0, 7.0),
(80.0, 8.0),
(90.0, 9.0),
(100.0, 10.0),
])
.graph_type(Line),
Dataset::default()
.name("Dataset 2")
.style(Style::default().fg(Color::Green))
.data(&[
(0.0, 10.0),
(10.0, 9.0),
(20.0, 8.0),
(30.0, 7.0),
(40.0, 6.0),
(50.0, 5.0),
(60.0, 4.0),
(70.0, 3.0),
(80.0, 2.0),
(90.0, 1.0),
(100.0, 0.0),
])
.graph_type(Line),
];
let chart = Chart::new(datasets)
.style(Style::default().bg(Color::White))
.block(Block::default().title("Chart Test").borders(Borders::ALL))
.x_axis(
Axis::default()
.bounds([0.0, 100.0])
.title(Span::styled("X Axis", Style::default().fg(Color::Yellow)))
.labels(create_labels(&["0.0", "50.0", "100.0"])),
)
.y_axis(
Axis::default()
.bounds([0.0, 10.0])
.title("Y Axis")
.labels(create_labels(&["0.0", "5.0", "10.0"])),
);
f.render_widget(
chart,
Rect {
x: 0,
y: 0,
width: 60,
height: 30,
},
);
})
.unwrap();
let mut expected = Buffer::with_lines(vec![
"┌Chart Test────────────────────────────────────────────────┐",
"│10.0│Y Axis ┌─────────┐│",
"│ │ •• │Dataset 1││",
"│ │ •• │Dataset 2││",
"│ │ •• └─────────┘│",
"│ │ •• •• │",
"│ │ •• •• │",
"│ │ •• •• │",
"│ │ •• •• │",
"│ │ •• •• │",
"│ │ •• •• │",
"│ │ •• •• │",
"│ │ ••• •• │",
"│ │ ••• │",
"│5.0 │ •• •• │",
"│ │ •• •• │",
"│ │ ••• •• │",
"│ │ •• •• │",
"│ │ •• •• │",
"│ │ •• •• │",
"│ │ •• •• │",
"│ │ •• •• │",
"│ │ •• •• │",
"│ │ •• ••• │",
"│ │ •• •• │",
"│ │ •• •• │",
"│0.0 │• X Axis│",
"│ └─────────────────────────────────────────────────────│",
"│ 0.0 50.0 100.0 │",
"└──────────────────────────────────────────────────────────┘",
]);
// Set expected backgound color
for row in 0..30 {
for col in 0..60 {
expected.get_mut(col, row).set_bg(Color::White);
}
}
// Set expected colors of the first dataset
let line1 = vec![
(48, 5),
(49, 5),
(46, 6),
(47, 6),
(44, 7),
(45, 7),
(42, 8),
(43, 8),
(40, 9),
(41, 9),
(38, 10),
(39, 10),
(36, 11),
(37, 11),
(34, 12),
(35, 12),
(33, 13),
(30, 14),
(31, 14),
(28, 15),
(29, 15),
(25, 16),
(26, 16),
(27, 16),
(23, 17),
(24, 17),
(21, 18),
(22, 18),
(19, 19),
(20, 19),
(17, 20),
(18, 20),
(15, 21),
(16, 21),
(13, 22),
(14, 22),
(11, 23),
(12, 23),
(9, 24),
(10, 24),
(7, 25),
(8, 25),
(6, 26),
];
let legend1 = vec![
(49, 2),
(50, 2),
(51, 2),
(52, 2),
(53, 2),
(54, 2),
(55, 2),
(56, 2),
(57, 2),
];
for (col, row) in line1 {
expected.get_mut(col, row).set_fg(Color::Blue);
}
for (col, row) in legend1 {
expected.get_mut(col, row).set_fg(Color::Blue);
}
// Set expected colors of the second dataset
let line2 = vec![
(8, 2),
(9, 2),
(10, 3),
(11, 3),
(12, 4),
(13, 4),
(14, 5),
(15, 5),
(16, 6),
(17, 6),
(18, 7),
(19, 7),
(20, 8),
(21, 8),
(22, 9),
(23, 9),
(24, 10),
(25, 10),
(26, 11),
(27, 11),
(28, 12),
(29, 12),
(30, 12),
(31, 13),
(32, 13),
(33, 14),
(34, 14),
(35, 15),
(36, 15),
(37, 16),
(38, 16),
(39, 17),
(40, 17),
(41, 18),
(42, 18),
(43, 19),
(44, 19),
(45, 20),
(46, 20),
(47, 21),
(48, 21),
(49, 22),
(50, 22),
(51, 23),
(52, 23),
(53, 23),
(54, 24),
(55, 24),
(56, 25),
(57, 25),
];
let legend2 = vec![
(49, 3),
(50, 3),
(51, 3),
(52, 3),
(53, 3),
(54, 3),
(55, 3),
(56, 3),
(57, 3),
];
for (col, row) in line2 {
expected.get_mut(col, row).set_fg(Color::Green);
}
for (col, row) in legend2 {
expected.get_mut(col, row).set_fg(Color::Green);
}
// Set expected colors of the x axis
let x_axis_title = vec![(53, 26), (54, 26), (55, 26), (56, 26), (57, 26), (58, 26)];
for (col, row) in x_axis_title {
expected.get_mut(col, row).set_fg(Color::Yellow);
}
terminal.backend().assert_buffer(&expected);
}

View File

@@ -1,8 +1,10 @@
use tui::{
backend::TestBackend,
buffer::Buffer,
layout::{Constraint, Direction, Layout},
widgets::{Block, Borders, Gauge},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
symbols,
widgets::{Block, Borders, Gauge, LineGauge},
Terminal,
};
@@ -20,11 +22,82 @@ fn widgets_gauge_renders() {
let gauge = Gauge::default()
.block(Block::default().title("Percentage").borders(Borders::ALL))
.gauge_style(Style::default().bg(Color::Blue).fg(Color::Red))
.use_unicode(true)
.percent(43);
f.render_widget(gauge, chunks[0]);
let gauge = Gauge::default()
.block(Block::default().title("Ratio").borders(Borders::ALL))
.ratio(0.211_313_934_313_1);
.gauge_style(Style::default().bg(Color::Blue).fg(Color::Red))
.use_unicode(true)
.ratio(0.511_313_934_313_1);
f.render_widget(gauge, chunks[1]);
})
.unwrap();
let mut expected = Buffer::with_lines(vec![
" ",
" ",
" ┌Percentage────────────────────────┐ ",
" │ ▋43% │ ",
" └──────────────────────────────────┘ ",
" ┌Ratio─────────────────────────────┐ ",
" │ 51% │ ",
" └──────────────────────────────────┘ ",
" ",
" ",
]);
for i in 3..17 {
expected
.get_mut(i, 3)
.set_bg(Color::Red)
.set_fg(Color::Blue);
}
for i in 17..37 {
expected
.get_mut(i, 3)
.set_bg(Color::Blue)
.set_fg(Color::Red);
}
for i in 3..20 {
expected
.get_mut(i, 6)
.set_bg(Color::Red)
.set_fg(Color::Blue);
}
for i in 20..37 {
expected
.get_mut(i, 6)
.set_bg(Color::Blue)
.set_fg(Color::Red);
}
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widgets_gauge_renders_no_unicode() {
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(f.size());
let gauge = Gauge::default()
.block(Block::default().title("Percentage").borders(Borders::ALL))
.percent(43)
.use_unicode(false);
f.render_widget(gauge, chunks[0]);
let gauge = Gauge::default()
.block(Block::default().title("Ratio").borders(Borders::ALL))
.ratio(0.211_313_934_313_1)
.use_unicode(false);
f.render_widget(gauge, chunks[1]);
})
.unwrap();
@@ -42,3 +115,55 @@ fn widgets_gauge_renders() {
]);
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widgets_line_gauge_renders() {
let backend = TestBackend::new(20, 4);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let gauge = LineGauge::default()
.gauge_style(Style::default().fg(Color::Green).bg(Color::White))
.ratio(0.43);
f.render_widget(
gauge,
Rect {
x: 0,
y: 0,
width: 20,
height: 1,
},
);
let gauge = LineGauge::default()
.block(Block::default().title("Gauge 2").borders(Borders::ALL))
.gauge_style(Style::default().fg(Color::Green))
.line_set(symbols::line::THICK)
.ratio(0.211_313_934_313_1);
f.render_widget(
gauge,
Rect {
x: 0,
y: 1,
width: 20,
height: 3,
},
);
})
.unwrap();
let mut expected = Buffer::with_lines(vec![
"43% ────────────────",
"┌Gauge 2───────────┐",
"│21% ━━━━━━━━━━━━━━│",
"└──────────────────┘",
]);
for col in 4..10 {
expected.get_mut(col, 0).set_fg(Color::Green);
}
for col in 10..20 {
expected.get_mut(col, 0).set_fg(Color::White);
}
for col in 5..7 {
expected.get_mut(col, 2).set_fg(Color::Green);
}
terminal.backend().assert_buffer(&expected);
}

View File

@@ -4,7 +4,7 @@ use tui::{
layout::Rect,
style::{Color, Style},
symbols,
widgets::{Block, Borders, List, ListItem, ListState},
widgets::{Block, Borders, List, ListItem},
Terminal,
};
@@ -12,8 +12,6 @@ use tui::{
fn widgets_list_should_highlight_the_selected_item() {
let backend = TestBackend::new(10, 3);
let mut terminal = Terminal::new(backend).unwrap();
let mut state = ListState::default();
state.select(Some(1));
terminal
.draw(|f| {
let size = f.size();
@@ -23,9 +21,10 @@ fn widgets_list_should_highlight_the_selected_item() {
ListItem::new("Item 3"),
];
let list = List::new(items)
.select(Some(1))
.highlight_style(Style::default().bg(Color::Yellow))
.highlight_symbol(">> ");
f.render_stateful_widget(list, size, &mut state);
f.render_widget(list, size);
})
.unwrap();
let mut expected = Buffer::with_lines(vec![" Item 1 ", ">> Item 2 ", " Item 3 "]);
@@ -73,14 +72,13 @@ fn widgets_list_should_truncate_items() {
},
];
for case in cases {
let mut state = ListState::default();
state.select(case.selected);
terminal
.draw(|f| {
let list = List::new(case.items.clone())
.block(Block::default().borders(Borders::RIGHT))
.select(case.selected)
.highlight_symbol(">> ");
f.render_stateful_widget(list, Rect::new(0, 0, 8, 2), &mut state);
f.render_widget(list, Rect::new(0, 0, 8, 2));
})
.unwrap();
terminal.backend().assert_buffer(&case.expected);

View File

@@ -2,7 +2,7 @@ use tui::{
backend::TestBackend,
buffer::Buffer,
layout::Alignment,
text::{Spans, Text},
text::{Span, Spans, Text},
widgets::{Block, Borders, Paragraph, Wrap},
Terminal,
};
@@ -141,6 +141,27 @@ fn widgets_paragraph_renders_mixed_width_graphemes() {
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widgets_paragraph_can_wrap_with_a_trailing_nbsp() {
let nbsp: &str = "\u{00a0}";
let line = Spans::from(vec![Span::raw("NBSP"), Span::raw(nbsp)]);
let backend = TestBackend::new(20, 3);
let mut terminal = Terminal::new(backend).unwrap();
let expected = Buffer::with_lines(vec![
"┌──────────────────┐",
"│NBSP\u{00a0}",
"└──────────────────┘",
]);
terminal
.draw(|f| {
let size = f.size();
let paragraph = Paragraph::new(line).block(Block::default().borders(Borders::ALL));
f.render_widget(paragraph, size);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widgets_paragraph_can_scroll_horizontally() {
let test_case = |alignment, scroll, expected| {

View File

@@ -1,8 +1,12 @@
use tui::backend::TestBackend;
use tui::buffer::Buffer;
use tui::layout::Constraint;
use tui::widgets::{Block, Borders, Row, Table};
use tui::Terminal;
use tui::{
backend::TestBackend,
buffer::Buffer,
layout::Constraint,
style::{Color, Modifier, Style},
text::{Span, Spans},
widgets::{Block, Borders, Cell, Row, Table},
Terminal,
};
#[test]
fn widgets_table_column_spacing_can_be_changed() {
@@ -13,16 +17,13 @@ fn widgets_table_column_spacing_can_be_changed() {
terminal
.draw(|f| {
let size = f.size();
let table = Table::new(
["Head1", "Head2", "Head3"].iter(),
vec![
Row::Data(["Row11", "Row12", "Row13"].iter()),
Row::Data(["Row21", "Row22", "Row23"].iter()),
Row::Data(["Row31", "Row32", "Row33"].iter()),
Row::Data(["Row41", "Row42", "Row43"].iter()),
]
.into_iter(),
)
let table = Table::new(vec![
Row::new(vec!["Row11", "Row12", "Row13"]),
Row::new(vec!["Row21", "Row22", "Row23"]),
Row::new(vec!["Row31", "Row32", "Row33"]),
Row::new(vec!["Row41", "Row42", "Row43"]),
])
.header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1))
.block(Block::default().borders(Borders::ALL))
.widths(&[
Constraint::Length(5),
@@ -114,16 +115,13 @@ fn widgets_table_columns_widths_can_use_fixed_length_constraints() {
terminal
.draw(|f| {
let size = f.size();
let table = Table::new(
["Head1", "Head2", "Head3"].iter(),
vec![
Row::Data(["Row11", "Row12", "Row13"].iter()),
Row::Data(["Row21", "Row22", "Row23"].iter()),
Row::Data(["Row31", "Row32", "Row33"].iter()),
Row::Data(["Row41", "Row42", "Row43"].iter()),
]
.into_iter(),
)
let table = Table::new(vec![
Row::new(vec!["Row11", "Row12", "Row13"]),
Row::new(vec!["Row21", "Row22", "Row23"]),
Row::new(vec!["Row31", "Row32", "Row33"]),
Row::new(vec!["Row41", "Row42", "Row43"]),
])
.header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1))
.block(Block::default().borders(Borders::ALL))
.widths(widths);
f.render_widget(table, size);
@@ -205,16 +203,13 @@ fn widgets_table_columns_widths_can_use_percentage_constraints() {
terminal
.draw(|f| {
let size = f.size();
let table = Table::new(
["Head1", "Head2", "Head3"].iter(),
vec![
Row::Data(["Row11", "Row12", "Row13"].iter()),
Row::Data(["Row21", "Row22", "Row23"].iter()),
Row::Data(["Row31", "Row32", "Row33"].iter()),
Row::Data(["Row41", "Row42", "Row43"].iter()),
]
.into_iter(),
)
let table = Table::new(vec![
Row::new(vec!["Row11", "Row12", "Row13"]),
Row::new(vec!["Row21", "Row22", "Row23"]),
Row::new(vec!["Row31", "Row32", "Row33"]),
Row::new(vec!["Row41", "Row42", "Row43"]),
])
.header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1))
.block(Block::default().borders(Borders::ALL))
.widths(widths)
.column_spacing(0);
@@ -248,9 +243,9 @@ fn widgets_table_columns_widths_can_use_percentage_constraints() {
// columns of not enough width trims the data
test_case(
&[
Constraint::Percentage(10),
Constraint::Percentage(10),
Constraint::Percentage(10),
Constraint::Percentage(11),
Constraint::Percentage(11),
Constraint::Percentage(11),
],
Buffer::with_lines(vec![
"┌────────────────────────────┐",
@@ -269,9 +264,9 @@ fn widgets_table_columns_widths_can_use_percentage_constraints() {
// columns of large width just before pushing a column off
test_case(
&[
Constraint::Percentage(30),
Constraint::Percentage(30),
Constraint::Percentage(30),
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(33),
],
Buffer::with_lines(vec![
"┌────────────────────────────┐",
@@ -292,12 +287,12 @@ fn widgets_table_columns_widths_can_use_percentage_constraints() {
&[Constraint::Percentage(50), Constraint::Percentage(50)],
Buffer::with_lines(vec![
"┌────────────────────────────┐",
"│Head1 Head2 │",
"│Head1 Head2 ",
"│ │",
"│Row11 Row12 │",
"│Row21 Row22 │",
"│Row31 Row32 │",
"│Row41 Row42 │",
"│Row11 Row12 ",
"│Row21 Row22 ",
"│Row31 Row32 ",
"│Row41 Row42 ",
"│ │",
"│ │",
"└────────────────────────────┘",
@@ -314,16 +309,13 @@ fn widgets_table_columns_widths_can_use_mixed_constraints() {
terminal
.draw(|f| {
let size = f.size();
let table = Table::new(
["Head1", "Head2", "Head3"].iter(),
vec![
Row::Data(["Row11", "Row12", "Row13"].iter()),
Row::Data(["Row21", "Row22", "Row23"].iter()),
Row::Data(["Row31", "Row32", "Row33"].iter()),
Row::Data(["Row41", "Row42", "Row43"].iter()),
]
.into_iter(),
)
let table = Table::new(vec![
Row::new(vec!["Row11", "Row12", "Row13"]),
Row::new(vec!["Row21", "Row22", "Row23"]),
Row::new(vec!["Row31", "Row32", "Row33"]),
Row::new(vec!["Row41", "Row42", "Row43"]),
])
.header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1))
.block(Block::default().borders(Borders::ALL))
.widths(widths);
f.render_widget(table, size);
@@ -356,9 +348,9 @@ fn widgets_table_columns_widths_can_use_mixed_constraints() {
// columns of not enough width trims the data
test_case(
&[
Constraint::Percentage(10),
Constraint::Percentage(11),
Constraint::Length(20),
Constraint::Percentage(10),
Constraint::Percentage(11),
],
Buffer::with_lines(vec![
"┌────────────────────────────┐",
@@ -377,9 +369,9 @@ fn widgets_table_columns_widths_can_use_mixed_constraints() {
// columns of large width just before pushing a column off
test_case(
&[
Constraint::Percentage(30),
Constraint::Percentage(33),
Constraint::Length(10),
Constraint::Percentage(30),
Constraint::Percentage(33),
],
Buffer::with_lines(vec![
"┌────────────────────────────┐",
@@ -416,3 +408,306 @@ fn widgets_table_columns_widths_can_use_mixed_constraints() {
]),
);
}
#[test]
fn widgets_table_columns_widths_can_use_ratio_constraints() {
let test_case = |widths, expected| {
let backend = TestBackend::new(30, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let size = f.size();
let table = Table::new(vec![
Row::new(vec!["Row11", "Row12", "Row13"]),
Row::new(vec!["Row21", "Row22", "Row23"]),
Row::new(vec!["Row31", "Row32", "Row33"]),
Row::new(vec!["Row41", "Row42", "Row43"]),
])
.header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1))
.block(Block::default().borders(Borders::ALL))
.widths(widths)
.column_spacing(0);
f.render_widget(table, size);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
};
// columns of zero width show nothing
test_case(
&[
Constraint::Ratio(0, 1),
Constraint::Ratio(0, 1),
Constraint::Ratio(0, 1),
],
Buffer::with_lines(vec![
"┌────────────────────────────┐",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"└────────────────────────────┘",
]),
);
// columns of not enough width trims the data
test_case(
&[
Constraint::Ratio(1, 9),
Constraint::Ratio(1, 9),
Constraint::Ratio(1, 9),
],
Buffer::with_lines(vec![
"┌────────────────────────────┐",
"│HeaHeaHea │",
"│ │",
"│RowRowRow │",
"│RowRowRow │",
"│RowRowRow │",
"│RowRowRow │",
"│ │",
"│ │",
"└────────────────────────────┘",
]),
);
// columns of large width just before pushing a column off
test_case(
&[
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
],
Buffer::with_lines(vec![
"┌────────────────────────────┐",
"│Head1 Head2 Head3 │",
"│ │",
"│Row11 Row12 Row13 │",
"│Row21 Row22 Row23 │",
"│Row31 Row32 Row33 │",
"│Row41 Row42 Row43 │",
"│ │",
"│ │",
"└────────────────────────────┘",
]),
);
// percentages summing to 100 should give equal widths
test_case(
&[Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)],
Buffer::with_lines(vec![
"┌────────────────────────────┐",
"│Head1 Head2 │",
"│ │",
"│Row11 Row12 │",
"│Row21 Row22 │",
"│Row31 Row32 │",
"│Row41 Row42 │",
"│ │",
"│ │",
"└────────────────────────────┘",
]),
);
}
#[test]
fn widgets_table_can_have_rows_with_multi_lines() {
let test_case = |selected: Option<usize>, expected: Buffer| {
let backend = TestBackend::new(30, 8);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let size = f.size();
let table = Table::new(vec![
Row::new(vec!["Row11", "Row12", "Row13"]),
Row::new(vec!["Row21", "Row22", "Row23"]).height(2),
Row::new(vec!["Row31", "Row32", "Row33"]),
Row::new(vec!["Row41", "Row42", "Row43"]).height(2),
])
.header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1))
.block(Block::default().borders(Borders::ALL))
.highlight_symbol(">> ")
.widths(&[
Constraint::Length(5),
Constraint::Length(5),
Constraint::Length(5),
])
.select(selected)
.column_spacing(1);
f.render_widget(table, size);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
};
// no selection
test_case(
None,
Buffer::with_lines(vec![
"┌────────────────────────────┐",
"│Head1 Head2 Head3 │",
"│ │",
"│Row11 Row12 Row13 │",
"│Row21 Row22 Row23 │",
"│ │",
"│Row31 Row32 Row33 │",
"└────────────────────────────┘",
]),
);
// select first
test_case(
Some(0),
Buffer::with_lines(vec![
"┌────────────────────────────┐",
"│ Head1 Head2 Head3 │",
"│ │",
"│>> Row11 Row12 Row13 │",
"│ Row21 Row22 Row23 │",
"│ │",
"│ Row31 Row32 Row33 │",
"└────────────────────────────┘",
]),
);
// select second (we don't show partially the 4th row)
test_case(
Some(1),
Buffer::with_lines(vec![
"┌────────────────────────────┐",
"│ Head1 Head2 Head3 │",
"│ │",
"│ Row11 Row12 Row13 │",
"│>> Row21 Row22 Row23 │",
"│ │",
"│ Row31 Row32 Row33 │",
"└────────────────────────────┘",
]),
);
// select 4th (we don't show partially the 1st row)
test_case(
Some(3),
Buffer::with_lines(vec![
"┌────────────────────────────┐",
"│ Head1 Head2 Head3 │",
"│ │",
"│ Row31 Row32 Row33 │",
"│>> Row41 Row42 Row43 │",
"│ │",
"│ │",
"└────────────────────────────┘",
]),
);
}
#[test]
fn widgets_table_can_have_elements_styled_individually() {
let backend = TestBackend::new(30, 4);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let size = f.size();
let table = Table::new(vec![
Row::new(vec!["Row11", "Row12", "Row13"]).style(Style::default().fg(Color::Green)),
Row::new(vec![
Cell::from("Row21"),
Cell::from("Row22").style(Style::default().fg(Color::Yellow)),
Cell::from(Spans::from(vec![
Span::raw("Row"),
Span::styled("23", Style::default().fg(Color::Blue)),
]))
.style(Style::default().fg(Color::Red)),
])
.style(Style::default().fg(Color::LightGreen)),
])
.header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1))
.block(Block::default().borders(Borders::LEFT | Borders::RIGHT))
.highlight_symbol(">> ")
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.widths(&[
Constraint::Length(6),
Constraint::Length(6),
Constraint::Length(6),
])
.select(Some(0))
.column_spacing(1);
f.render_widget(table, size);
})
.unwrap();
let mut expected = Buffer::with_lines(vec![
"│ Head1 Head2 Head3 │",
"│ │",
"│>> Row11 Row12 Row13 │",
"│ Row21 Row22 Row23 │",
]);
// First row = row color + highlight style
for col in 1..=28 {
expected.get_mut(col, 2).set_style(
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
);
}
// Second row:
// 1. row color
for col in 1..=28 {
expected
.get_mut(col, 3)
.set_style(Style::default().fg(Color::LightGreen));
}
// 2. cell color
for col in 11..=16 {
expected
.get_mut(col, 3)
.set_style(Style::default().fg(Color::Yellow));
}
for col in 18..=23 {
expected
.get_mut(col, 3)
.set_style(Style::default().fg(Color::Red));
}
// 3. text color
for col in 21..=22 {
expected
.get_mut(col, 3)
.set_style(Style::default().fg(Color::Blue));
}
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widgets_table_should_render_even_if_empty() {
let backend = TestBackend::new(30, 4);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let size = f.size();
let table = Table::new(vec![])
.header(Row::new(vec!["Head1", "Head2", "Head3"]))
.block(Block::default().borders(Borders::LEFT | Borders::RIGHT))
.widths(&[
Constraint::Length(6),
Constraint::Length(6),
Constraint::Length(6),
])
.column_spacing(1);
f.render_widget(table, size);
})
.unwrap();
let expected = Buffer::with_lines(vec![
"│Head1 Head2 Head3 │",
"│ │",
"│ │",
"│ │",
]);
terminal.backend().assert_buffer(&expected);
}