Compare commits

...

149 Commits

Author SHA1 Message Date
Ahmad
10aeccff1a feat: added deref impl for backend types 2023-07-04 13:31:20 -07:00
Josh McKinney
ad288f5168 chore(features): enable building with all-features (#286)
Before this change, it wasn't possible to build all features and all
targets at the same time, which prevents rust-analyzer from working
for the whole project.

Adds a bacon.toml file to the project, which is used by bacon
https://dystroy.org/bacon/

Configures docs.rs to show the feature flags that are necessary to
make modules / types / functions available.
2023-07-04 03:58:25 +00:00
Josh McKinney
c5d387cb53 style: fix formatting (#292)
There are a couple of small formatting changes in the current nightly
2023-07-02 18:47:02 +00:00
Samy Rahmani
2f4413be6e feat: stylization shorthands (#283) 2023-07-01 09:14:16 +00:00
Josh McKinney
83d3ec73e7 fix(clippy): ununsed_mut lint for layout (#285)
This lint is slightly more agressive in +nightly than it is in stable.
2023-06-30 22:01:58 +00:00
Josh McKinney
cf8eda04a1 test(paragraph): simplify paragraph benchmarks (#282)
Reduce benchmarks from 60 calls to 18. Now 3 different line counts
(64, 2048, 65535) * 6 different tests (new, render, scroll half / full,
wrap, wrap and scroll)
2023-06-29 22:12:02 +00:00
SLASHLogin
6bdb97c55c ci(makefile): split CI jobs (#278)
- Split CI into build, clippy and test.
- Run format on nightly only due to the settings being unstable
2023-06-26 07:31:30 +00:00
a-kenji
7a6c3d9db1 feat(misc): make builder fn const (#275) (#275)
This allows the following types to be used in a constant context:
- `Layout`
- `Rect`
- `Style`
- `BorderType`
- `Padding`
- `Block`

Also adds several missing `new()` functions to the above types.

Blocks can now be used in the following way:
```
const DEFAULT_BLOCK: Block = Block::new()
    .title_style(Style::new())
    .title_alignment(Alignment::Left)
    .title_position(Position::Top)
    .borders(Borders::ALL)
    .border_style(Style::new())
    .style(Style::reset())
    .padding(Padding::uniform(1));

```

Layouts can now be used in the following way:
``
const DEFAULT_LAYOUT: Layout = Layout::new()
    .direction(Direction::Horizontal)
    .margin(1)
    .expand_to_fill(false);
```

Rects can now be used in the following way:
```
const RECT: Rect = Rect {
    x: 0,
    y: 0,
    width: 10,
    height: 10,
};
```
2023-06-26 00:09:51 +00:00
a-kenji
bfcc5504bb style(widget): inline format arguments (#279) 2023-06-25 13:19:21 +00:00
SLASHLogin
669a4d5652 build: add git pre-push hooks using cargo-husky (#274)
Fixes https://github.com/tui-rs-revival/ratatui/issues/214
- add cargo-husky to dev-deps
- create hook
- update `CONTRIBUTING.md`
- ensure that the hook is not installed in CI
2023-06-24 06:03:31 +00:00
SLASHLogin
b808305507 docs: fix scrollbar ascii illustrations and calendar doc paths (#272)
* docs(src\widgets\scrollbar.rs): wrap scrollbar's visualisation in text block

'cargo doc' and 'rust-analyzer' removes many whitespaces thus making those parts render improperly

* docs(src/widgets/calendar.rs): fix `no item named ...` for calendar.rs

* style(src/widgets/block.rs): format `block.rs`
2023-06-21 11:57:50 +00:00
Samy Rahmani
a04b190251 feat(block): support for having more than one title (#232) 2023-06-19 08:24:36 +00:00
Leon Sautour
e869869462 ci: add feat-wrapping on push and on pull request ci triggers (#267) 2023-06-19 08:18:27 +00:00
a-kenji
20c0051026 docs(lib): add tui-term a pseudoterminal library (#268) 2023-06-19 08:15:52 +00:00
Orhun Parmaksız
284b0b8de0 chore(github): simplify the CODEOWNERS file (#271) 2023-06-19 00:41:18 +00:00
Dheepak Krishnamurthy
130bdf8337 feat: add scrollbar widget (#228)
Represents a scrollbar widget that renders a track, thumb and arrows
either horizontally or vertically. State is kept in ScrollbarState, and
passed as a parameter to the render function.
2023-06-17 19:25:43 +00:00
Orhun Parmaksız
8b7b7881f5 chore(github): add pull request template (#269) 2023-06-17 17:36:34 +00:00
Ende
28a8435a52 fix(layout): cap Contstraint::apply to 100% length (#264)
This function is only currently used in by the chart widget for
constraining the width and height of the legend.
2023-06-17 10:44:13 +00:00
Josh McKinney
dca9871744 fix: revert removal of WTFPL from deny.toml (#266)
This is actually used for terminfo (transitively from termwiz
2023-06-17 08:54:25 +00:00
Josh McKinney
6c2fbbf275 test: add benchmarks for paragraph (#262)
To run the benchmarks:

    cargo bench

And then open the generated `target/criterion/report/index.html` in a
browser.

- add the BSD 2 clause and ISC licenses to the `cargo deny` allowed
licenses list (as a transitive dependency of the `fakeit` crate).
- remove the WTFPL license from the `cargo deny` allowed licenses list
as it is unused and causes a warning when running the check.
2023-06-16 13:09:41 +00:00
Chris Morris
43bac80e4d fix(examples): Correct progress label in gague example (#263) 2023-06-16 09:17:04 +00:00
Orhun Parmaksız
0bf6af17e7 refactor(ci): simplify cargo-make installation (#240)
* refactor(ci): simplify cargo-make installation

* chore(ci): use the latest version of cargo-make

* refactor(ci): remove unused triple values

* chore(ci): list all steps before ci

* fix(ci): checkout the repository

* refactor(ci): remove unnecessary os variables

* refactor(ci): use dtolnay/rust-toolchain action
2023-06-12 23:25:18 +00:00
Josh McKinney
f7af8a3863 style: reformat imports (#219)
Order imports by std, external, crate and group them by crate
2023-06-12 05:07:15 +00:00
Orhun Parmaksız
492af7a92d chore(ci): bump cargo-make version (#239) 2023-06-11 21:01:21 +00:00
Orhun Parmaksız
de4f2b9990 chore(demo): update demo gif (#234) 2023-06-11 20:33:25 +00:00
Orhun Parmaksız
4a2ff204ec chore(ci): enable merge queue for builds (#235)
* chore(ci): enable merge queue for builds

https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue

* style(ci): format the ci workflow
2023-06-11 20:28:17 +00:00
Orhun Parmaksız
26dbb29b3d style(manifest): apply formatting to Cargo.toml (#237) 2023-06-11 20:27:35 +00:00
Orhun Parmaksız
231aae2920 style(config): apply formatting to config files (#238) 2023-06-11 20:26:45 +00:00
Orhun Parmaksız
4cc7380a88 chore(github): fix the syntax in CODEOWNERS file (#236) 2023-06-11 20:25:14 +00:00
Josh McKinney
593fd29d00 chore(demo): update demo gif with a fixed unicode gauge (#227)
* fix(gauge): render gauge with unicode correctly

Gauge now correctly renders a block rather than a space when in unicode mode.

* docs: update demo.gif

- remove existing gif
- upload using VHS (https://github.com/charmbracelet/vhs)
- add instructions to RELEASE.md
- link new gif in README
2023-06-11 16:23:23 +02:00
Philipp Mildenberger
f84d97b17b feat(terminal): expose 'swap_buffers' method 2023-06-10 22:39:55 -07:00
Josh McKinney
ef4d743af3 fix(typos): configure typos linter (#233)
- Adds a new typos.toml
- Prevents ratatui from being marked as a typo of ratatouille
- Changes paragraph tests so that the truncated word is a valid word
2023-06-10 16:31:36 -07:00
Josh McKinney
9ecc4a15df docs: README tweaks (#225)
- Add contributors graph
- Add markdownlint config file
- Reformat README line width to 100
- Add a link to the CHANGELOG
- Remove APPS.md
- Change apps link to the Wiki instead of APPS.md
2023-06-08 11:00:18 +02:00
snpefk
e165025c94 docs(readme): remove duplicated mention of tui-rs-tree-widgets (#223) 2023-06-05 23:00:21 +02:00
Orhun Parmaksız
d711f2aef3 chore(ci): integrate cargo-deny for linting dependencies (#221) 2023-06-04 17:32:20 +02:00
Nydragon
e724bec987 chore(commitizen): add commitizen config (#222)
* style(commitizen): add commitizen

add customized commitizen config to match repo needs

implement request mentioned in [#214](https://github.com/tui-rs-revival/ratatui/issues/214) by @joshka

* docs(CONTRIBUTING.md): add a section for the commitizen installation

BREAKING_CHANGE:

* style(commitizen): update breaking change default to false
2023-06-04 17:31:49 +02:00
Josh McKinney
40b3543c3f style(comments): set comment length to wrap at 100 chars (#218)
This is an opinionated default that helps avoid horizontal scrolling.
100 is the most common width on github rust projects and works well for
displaying code on a 16in macbook pro.
2023-06-04 12:34:05 +02:00
Josh McKinney
e95b5127ca docs(lib): fixup tui refs in widgets/mod.rs (#216) 2023-06-04 12:33:09 +02:00
Josh McKinney
5243aa0628 docs(lib): add backend docs (#213) 2023-06-02 19:13:56 +02:00
Yuri Astrakhan
509d18501c chore: lint and doc cleanup (#191)
* chore: Lint and doc cleanup

A few more minor cleanups, mostly in documentation

* Remove unused comment
2023-06-02 16:03:34 +02:00
Josh McKinney
77067bdc58 docs: add CODEOWNERS file (#212)
See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
2023-06-02 13:21:20 +02:00
Josh McKinney
358b50ba21 build(deps)!: upgrade bitflags to 2.3 (#205)
BREAKING CHANGE: The serde representation of bitflags has changed. Any
existing serialized types that have Borders or Modifiers will need to be
re-serialized. This is documented in the bitflags changelog.
https://github.com/bitflags/bitflags/blob/main/CHANGELOG.md#200-rc2
2023-06-01 15:37:59 +02:00
Josh McKinney
b40ca44e1a docs: update README.md and add hello_world example (#204)
- Reformat summary info
- Add badges for dependencies, discord, license
- point existing badges to shields.io
- add Table of Contents
- tweaked installation instructions to show instructions for new and
existing crates
- moved fork status lower
- chop lines generally to 100 limit
- add a quickstart based on a simplified hello_world example
- added / updated some internal links to point locally
- removed some details to simplify the readme (e.g. tick-rate)
- reordered widgets and pointed these at the widget docs
- adds a hello_world example that has just the absolute basic code
necessary to run a ratatui app. This includes some comments that help
guide the user towards other approaches and considerations for a real
world app.
2023-06-01 15:22:18 +02:00
Josh McKinney
a68d621f2d ci: add code coverage action (#209)
This runs the coverage and uploads the output to
https://app.codecov.io/gh/tui-rs-revival/ratatui/
2023-06-01 04:21:16 -07:00
Orhun Parmaksız
21ca38d9e9 chore(release): prepare for 0.21.0 (#197)
* chore(release): prepare for 0.21.0

* chore(changelog): update changelog for latest changes
2023-05-29 12:09:00 +02:00
Josh McKinney
769efc20d1 fix(reflow): remove debug macro call (#198)
This was accidentally left in the previous commit and causes all the
demos to fail.
2023-05-28 21:43:50 +02:00
TimerErTim
32e416ea95 feat(paragraph): allow Lines to be individually aligned (#149)
`Paragraph` now supports rendering each line in the paragraph with a
different alignment (Center, Left and Right) rather than the entire
paragraph being aligned the same. Each line either overrides the
paragraph's alignment or inherits it if the line's alignment is
unspecified.

- Adds `alignment` field to `Line` and builder methods on `Line` and
`Span`
- Updates reflow module types to be line oriented rather than symbol
oriented to take account of each lines alignment
- Adds unit tests to `Paragraph` to fully capture the existing and new
behavior

---------

Co-authored-by: Josh McKinney <joshka@users.noreply.github.com>
2023-05-26 11:58:40 -07:00
Josh McKinney
49e0f4e983 docs: scrape example code from examples/* (#195)
see https://doc.rust-lang.org/nightly/rustdoc/scraped-examples.html
2023-05-24 12:58:04 -07:00
Yuri Astrakhan
e08b466166 chore: inline format args (#190) 2023-05-21 20:46:02 -07:00
Yuri Astrakhan
3f9935bbcc chore: Minor lints, making Clippy happier (#189)
- `Default::default` is hard to read
- a few `map` -> `map_or`
- simplified `match` -> `let-if`

Signed-off-by: Yuri Astrakhan <YuriAstrakhan@gmail.com>
2023-05-21 20:45:37 -07:00
Orhun Parmaksız
ef8bc7c5a8 feat(border): add border! macro for easy bitflag manipulation (#11)
Adds a `border!` macro that takes TOP, BOTTOM, LEFT, RIGHT, and ALL and
returns a Borders object. An empty `border!()` call returns
Borders::NONE

This is gated behind a `macros` feature flag to ensure short build
times. To enable, add the following to your `Cargo.toml`:

```toml
ratatui = { version = 0.21.0, features = ["macros"] }
```

Co-authored-by: C-Jameson <booksncode@gmail.com>
2023-05-21 15:26:39 -07:00
a-kenji
fa02cf0f2b docs(readme): fix small typo in readme (#186) 2023-05-20 14:59:58 -07:00
Orhun Parmaksız
bc66a27baf feat(widgets): add offset() and offset_mut() for table and list state (#12) 2023-05-20 13:44:33 -07:00
Thomas Mauran
3e54ac3aca style(apps): update the style of application list (#184)
* style(APPS): adding categories

* chore(APPS): remove dots at the end of titles

* chore(APPS): rename to other

* chore(APPS): add description and table of contents

* chore(APPS): change table to list and remove authors

* style(apps): apply formatting

* docs(apps): add the description for termchat

---------

Co-authored-by: Orhun Parmaksız <orhunparmaksiz@gmail.com>
2023-05-20 20:55:34 +02:00
Amirhossein Akhlaghpour
a425102cf3 feat(color): add FromStr implementation for Color (#180)
* fix: FromStr in Color Struct

* fix: rgb issue

* fix: rgb issue

* fix: add doc

* fix: doctests

* feat: move and add tests

* refactor: rgb & invalid_color test
2023-05-20 18:43:41 +02:00
a-kenji
60cd28005f docs(readme): add termwiz demo to examples (#183) 2023-05-19 19:31:23 +01:00
Josh McKinney
728f82c084 refactor(text): replace Spans with Line (#178)
* refactor: add Line type to replace Spans

`Line` is a significantly better name over `Spans` as the plural causes
confusion and the type really is a representation of a line of text made
up of spans.

This is a backwards compatible version of the approach from
https://github.com/tui-rs-revival/ratatui/pull/175

There is a significant amount of code that uses the Spans type and
methods, so instead of just renaming it, we add a new type and replace
parameters that accepts a `Spans` with a parameter that accepts
`Into<Line>`.

Note that the examples have been intentionally left using `Spans` in
this commit to demonstrate the compiler warnings that will be emitted in
existing code.

Implementation notes:
- moves the Spans code to text::spans and publicly reexports on the text
module. This makes the test in that module only relevant to the Spans
type.
- adds a line module with a copy of the code and tests from Spans with a
single addition: `impl<'a> From<Spans<'a>> for Line<'a>`
- adds tests for `Spans` (created and checked before refactoring)
- adds the same tests for `Line`
- updates all widget methods that accept and store Spans to instead
store `Line` and accept `Into<Line>`

* refactor: move text::Masked to text::masked::Masked

Re-exports the Masked type at text::Masked

* refactor: replace Spans with Line in tests/examples/docs
2023-05-18 20:21:43 +02:00
Orhun Parmaksız
4437835057 feat(backend): add termwiz backend and example (#5)
* build: bump MSRV to 1.65

The latest version of the time crate requires Rust 1.65.0

```
cargo +1.64.0-x86_64-apple-darwin test --no-default-features \
  --features serde,crossterm,all-widgets --lib --tests --examples
error: package `time v0.3.21` cannot be built because it requires rustc
1.65.0 or newer, while the currently active rustc version is 1.64.0
```

* feat(backend): add termwiz backend and demo

* ci(termwiz): add termwiz to makefile.toml

---------

Co-authored-by: Josh McKinney <joshka@users.noreply.github.com>
Co-authored-by: Prabir Shrestha <mail@prabir.me>
2023-05-12 17:58:01 +02:00
Josh McKinney
1cc405d2dc build: bump MSRV to 1.65.0 (#171)
The latest version of the time crate requires Rust 1.65.0

```
cargo +1.64.0-x86_64-apple-darwin test --no-default-features \
  --features serde,crossterm,all-widgets --lib --tests --examples
error: package `time v0.3.21` cannot be built because it requires rustc
1.65.0 or newer, while the currently active rustc version is 1.64.0
```

Also fixes several clippy warnings added in 1.63/1.65. Although these
have been since moved to nursery / pedantic, it doesn't hurt to fix
these issues as part of this change:

- https://rust-lang.github.io/rust-clippy/master/index.html#derive_partial_eq_without_eq (nursery)
- https://rust-lang.github.io/rust-clippy/master/index.html#bool_to_int_with_if (pedantic)
2023-05-12 17:45:00 +02:00
Josh McKinney
2f0d549a50 feat(text)!: add Masked to display secure data (#168)
Adds a new type Masked that can mask data with a mask character, and can
be used anywhere we expect Cow<'a, str> or Text<'a>. E.g. Paragraph,
ListItem, Table Cells etc.

BREAKING CHANGE:
Because Masked implements From for Text<'a>, code that binds
Into<Text<'a>> without type annotations may no longer compile
(e.g. `Paragraph::new("".as_ref())`)

To fix this, annotate or call to_string() / to_owned() / as_str()
2023-05-09 19:59:24 +02:00
FujiApple
c7aca64ba1 feat(widget): add circle widget (#159) 2023-05-09 19:56:35 +02:00
Josh McKinney
548961f610 test(list): add characterization tests for list (#167)
- also adds builder methods on list state to make it easy to construct
  a list state with selected and offset as a one-liner. Uses `with_` as
  the prefix for these methods as the selected method currently acts as
  a getter rather than a builder.
- cargo tarpaulin suggests only two lines are not covered (the two
  match patterns of the self.start_corner match 223 and 227).
  the body of these lines is covered, so this is probably 100% coverage.
2023-05-09 17:39:01 +02:00
TimerErTim
b3072ce354 style: clippy's variable inlining in format macros 2023-05-07 16:19:34 -07:00
Josh McKinney
98b6b1911c test(buffer): add assert_buffer_eq! and Debug implementation (#161)
- The implementation of Debug is customized to make it easy to use the
output (particularly the content) directly when writing tests (by
surrounding it with `Buffer::with_lines(vec![])`). The styles part of
the message shows the position of every style change, rather than the
style of each cell, which reduces the verbosity of the detail, while
still showing everything necessary to debug the buffer.

```rust
Buffer {
    area: Rect { x: 0, y: 0, width: 12, height: 2 },
    content: [
        "Hello World!",
        "G'day World!",
    ],
    styles: [
        x: 0, y: 0, fg: Reset, bg: Reset, modifier: (empty),
        x: 0, y: 1, fg: Green, bg: Yellow, modifier: BOLD,
    ]
}
```

- The assert_buffer_eq! macro shows debug view and diff of the two
buffers, which makes it easy to understand exactly where the difference
is.

- Also adds a unit test for buffer_set_string_multi_width_overwrite
which was missing from the buffer tests
2023-05-07 21:39:51 +02:00
kyoto7250
ef96109ea5 feat(list): add len() to List 2023-05-06 22:29:54 -07:00
Josh McKinney
cf1a759fa5 test(widget): add unit tests for Paragraph (#156) 2023-05-04 21:29:40 +02:00
a-kenji
86c3fc9fac feat(test): expose test buffer (#160)
Allow a way to expose the buffer of the `TestBackend`,
to easier support different testing methodologies.
2023-05-04 21:28:55 +02:00
Josh McKinney
5f12f06297 ci: add ci, build, and revert to allowed commit types
This is the same list as https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional\#rules
2023-05-04 04:12:55 -07:00
Lowell Thoerner
33b3f4e312 feat(widget): add style methods to Span, Spans, Text (#148)
- add patch_style() and reset_style() to `Span` and `Spans`
- add reset_style() to `Text`
- updated doc for patch_style() on `Text` for clarity.
2023-05-02 15:30:05 -07:00
Josh McKinney
603ec3f10a docs(block): add example for block.inner (#158) 2023-05-02 06:48:38 +01:00
Leon Sautour
37bb82e87d docs(readme): add acknowledgement section (#154) 2023-04-27 14:10:26 +02:00
Orhun Parmaksız
047e0b7e8d style(readme): update project introduction in README.md (#153) 2023-04-27 12:03:37 +01:00
Orhun Parmaksız
782820c34a feat(widget): support adding padding to Block (#20)
Co-authored-by: Igor Mandello <igormandello@gmail.com>
Co-authored-by: Léon Sautour <leon@sautour.net>
2023-04-27 12:30:13 +02:00
Leon Sautour
26cf1f7a89 refactor(examples): refactor paragraph example (#152) 2023-04-27 11:06:45 +01:00
Erich Heine
f7ab4f04ac feat(calendar): add calendar widget (#138) 2023-04-26 23:02:35 +02:00
Erich Heine
2751f08bdc fix(142): cleanup doc example (#145) 2023-04-21 16:06:14 +02:00
wcampbell
0904d448e0 docs(apps): ix rsadsb/adsb_deku radar link (#140) 2023-04-18 18:50:34 +02:00
BADR
fab7952af6 docs(apps): add tenere (#141) 2023-04-18 18:48:53 +02:00
Conrad Ludgate
6af75d6d40 feat(terminal)!: add inline viewport (#114)
Co-authored-by: Florian Dehau <work@fdehau.com>
Co-authored-by: Orhun Parmaksız <orhunparmaksiz@gmail.com>
2023-04-17 14:23:50 +02:00
Josh McKinney
5f1a37f0db fix(canvas)!: use full block for Marker::Block (#133) 2023-04-15 17:40:28 +02:00
Josh McKinney
b7bd3051b1 feat(sparkline): finish #1 Sparkline directions PR (#134)
Co-authored-by: Joseph Price <pricejosephd@gmail.com>
2023-04-14 19:15:56 +02:00
Orhun Parmaksız
7adc3fe19b feat(block): support placing the title on bottom (#36)
Co-authored-by: Spencer Gouw <swyverng55@g.ucla.edu>
Co-authored-by: Benjscho <47790940+Benjscho@users.noreply.github.com>
Co-authored-by: Arijit Basu <sayanarijit@gmail.com>
2023-04-13 22:24:31 +02:00
lesleyrs
4842885aa6 fix(examples): update input in examples to only use press events (#129) 2023-04-13 22:21:35 +02:00
Xithrius
00e8c0d1b0 docs(apps): add twitch-tui (#124) 2023-04-11 13:05:47 +02:00
MeowKing
239efa5fbd docs(readme): update project description (#127) 2023-04-07 16:28:42 +02:00
Brook Jeynes [SSW]
a9ba23bae8 docs(apps): add oxycards (#113) 2023-03-27 10:43:40 +02:00
Orhun Parmaksız
62930f2821 refactor(example): remove redundant vec![] in user_input example (#26)
Co-authored-by: rhysd <lin90162@yahoo.co.jp>
2023-03-25 14:45:28 +01:00
FujiApple
4334c71bc7 docs(apps): re-add trippy to APPS.md (#117) 2023-03-25 12:29:52 +01:00
kpcyrd
21a029f17e refactor(style): Mark some Style fns const so they can be defined globally (#115) 2023-03-24 17:34:06 +01:00
Orhun Parmaksız
9d37c3bd45 docs(changelog): update the empty profile link in contributors (#112) 2023-03-23 09:40:39 +05:30
Arijit Basu
343ec220b4 chore(release): prepare for 0.20.1 (#110) 2023-03-22 23:57:54 +05:30
Josh McKinney
2da4c10384 docs: fixup remaining tui references (#106) 2023-03-22 11:03:45 +05:30
acheronfail
829dfee9e5 Update APPS.md (#108) 2023-03-22 10:57:20 +05:30
UncleScientist
26c7bcfdd5 fix(style): bold needs a bit (#104) 2023-03-20 23:09:27 +05:30
todoesverso
cf1b05d16e docs(apps): add "logss" to apps (#105) 2023-03-20 18:20:22 +01:00
Orhun Parmaksız
cfa8b8042a chore(release): prepare for 0.20.0 (#97) 2023-03-19 18:11:15 +01:00
Orhun Parmaksız
f0c0985708 docs(readme): update crate status badge (#102) 2023-03-19 17:55:48 +01:00
Leon Sautour
73c937bc30 docs(readme): small edits before first release (#101) 2023-03-19 17:49:26 +01:00
Orhun Parmaksız
33e67abbe9 docs(canvas): add documentation for x_bounds, y_bounds (#35)
Co-authored-by: girvel <widauka@ya.ru>
Co-authored-by: Leon Sautour <leon1.sautour@epitech.eu>
Co-authored-by: Arijit Basu <sayanarijit@users.noreply.github.com>
2023-03-19 17:30:23 +01:00
Arijit Basu
c20fb723ef docs(readme): add install instruction and update title (#100) 2023-03-19 16:08:04 +01:00
Orhun Parmaksız
ccd142df97 docs(apps): move 'apps using ratatui' to dedicated file (#98) (#99) 2023-03-19 01:30:47 +01:00
Orhun Parmaksız
f6dbd1c0b5 docs(readme): add systeroid to application list (#92) 2023-03-18 08:11:44 +05:30
Orhun Parmaksız
d38d185d1c feat(cd): add continuous deployment workflow (#93) 2023-03-17 21:19:01 +01:00
Qichao Lan
ef79b72471 docs(readme): add glicol-cli to showcase list (#95) 2023-03-17 21:18:04 +01:00
Orhun Parmaksız
ed12ab16e0 chore(cargo): update project metadata (#94) 2023-03-17 17:03:49 +01:00
Orhun Parmaksız
24820cfcff chore(ci): integrate typos for checking typos (#91) 2023-03-16 15:00:52 +01:00
Kian-Meng Ang
e10f62663e docs: fix typos (#90) 2023-03-16 18:56:50 +05:30
Conrad Ludgate
02573b0ad2 better safe shared layout cache (#62) 2023-03-12 18:23:30 +05:30
Orhun Parmaksız
0dc39434c2 Fix: cassowary/layouts: add extra constraints for fixing Min(v)/Max(v) combination. (#31)
Co-authored-by: Simon Allen <simon@simonallen.org>
2023-03-11 17:11:13 +05:30
Linda_pp
33acfce083 fix(ci): Test MSRV compatibility on CI (#85) 2023-03-08 22:07:19 +05:30
Arijit Basu
79d0eadbd6 docs: update to build more backends (#81)
Co-authored-by: Doug Goldstein <cardoe@cardoe.com>
2023-03-05 13:25:36 +01:00
Orhun Parmaksız
73f7f16298 fix(ci): Bump Rust version to 1.63.0 (#80) 2023-03-02 07:41:06 +01:00
Orhun Parmaksız
66eb0e42fe chore(ci): Change the target branch to main (#79)
Co-authored-by: Leon Sautour <leon@sautour.net>
2023-03-01 11:06:14 +01:00
Orhun Parmaksız
052ae53b6e chore: Integrate committed for checking conventional commits (#77)
Closes #50
2023-02-25 14:36:49 +01:00
Orhun Parmaksız
1c0ed3268b fix(ci): use env for the cargo-make version (#76) 2023-02-22 13:40:04 +01:00
Orhun Parmaksız
0456abb327 fix(widget)!: List should not ignore empty string items (#42)
Fixes issue #680. Handles the case where a list item is created with an empty string, which is not split by the lines iterator.

Co-authored-by: Collin O'Connor <collinoconnor2@gmail.com>
Co-authored-by: Arijit Basu <sayanarijit@users.noreply.github.com>
Co-authored-by: Arijit Basu <sayanarijit@gmail.com>
2023-02-21 13:22:37 +01:00
Jack Wills
ec50458491 docs(readme): Add oxker to application list (#74) 2023-02-21 11:33:52 +01:00
牧心
b834ccaa2f docs(readme): Add app kubectl-watch which uses tui (#73) 2023-02-20 15:35:37 +01:00
Leon Sautour
ffb3de6c36 docs(github): remove pull request template (#68) 2023-02-20 14:32:38 +01:00
Leon Sautour
454c8459f2 docs(contributing): specify the use of unsafe for optimization (#67) 2023-02-20 14:28:49 +01:00
CK Aznable
94a0d09591 docs(readme): add poketex to 'apps using tui' in README (#64) 2023-02-19 12:42:09 +01:00
Orhun Parmaksız
9b7a6ed85d feat(widget): add offset() to TableState (#10)
Co-authored-by: Aaron Rennow <arennow@outlook.com>
2023-02-18 03:11:34 +01:00
Orhun Parmaksız
7e31035114 refactor(style): make bitflags smaller (#13)
Co-authored-by: Cédric Barreteau <cbarrete@users.noreply.github.com>
2023-02-18 03:10:40 +01:00
Orhun Parmaksız
e15a6146f8 feat(widget): add width() to ListItem (#17)
Co-authored-by: Nicholas Howard <nicholas.howard@novatechautomation.com>
2023-02-18 03:09:32 +01:00
Linda_pp
e49cb1126b fix(doc): add 3rd party libraries accidentally removed at #21 (#61) 2023-02-17 13:01:16 +01:00
Linda_pp
142bc5720e feat(ci): add MacOS to CI (#60) 2023-02-17 12:59:18 +01:00
Linda_pp
feaeb7870f fix(ci): fix deprecation warnings on CI (#58)
* fix(ci): fix deprecation warnings on CI

* fix(ci): remove unnecessary step in CI workflow
2023-02-17 06:25:51 +05:30
Linda_pp
9df0eefe49 chore(ci): re-enable clippy on CI (#59) 2023-02-16 18:51:54 +05:30
Linda_pp
8e89a9377a chore: update rust-version to 1.59 in Cargo.toml (#57) 2023-02-16 08:38:30 +05:30
Orhun Parmaksız
d3df8fe7ef fix: Fix user_input example double key press registered on windows
Co-authored-by: 朕与将军解战袍 <72246322+a1393323447@users.noreply.github.com>
2023-02-15 22:52:08 +01:00
Arijit Basu
9feda988a5 chore: Update deps (#51)
* Update deps

Also, temporarily disabled clippy check. Can be discussed in #49.

* Fix termion demo

* chore: fix all clippy warnings

* Call into_raw_mode()

* Update min supported rust version

---------

Co-authored-by: rhysd <lin90162@yahoo.co.jp>
2023-02-15 18:29:50 +05:30
Orhun Parmaksız
9534d533e3 fix: Ignore zero-width symbol on rendering Paragraph
This fixes out-of-bounds crash on rendering `Paragraph` when zero-width
character is at end of line.

fix #642

Co-authored-by: rhysd <lin90162@yahoo.co.jp>
2023-02-15 13:56:54 +01:00
Orhun Parmaksız
85eefe1d8b docs: Expand "Apps" and "Third-party" sections (#21)
* README.md/#Apps using tui: Sort lexically

* README.md/#Apps using tui: Remove Hoard.

Closes #569.

* README.md/#Apps using tui: Add a description to each app.

* README.md/#Apps using tui: Add some more apps.

This is a curated addition.

Here are the apps I chose not to add:

```md
* [Chatui](https://github.com/xaerru/chatui) — ChatApp made using the standard library net module and Tui-rs
* [Corona-rs](https://github.com/varjolintu/corona-rs) — Corona virus statistics with Tui-rs
* [HTTP Request Tool](https://github.com/Callum-Irving/http-request-tool) — HTTP request sending tool similar to Insomnia, but uses a text user interface (TUI)
* [KRTirtho/portfolio](https://github.com/KRTirtho/portfolio) — A TUI based personal portfolio created using Rust & Tui-rs
* [Picterm](https://github.com/ksk001100/picterm) — TUI image viewer
```

* README.md/#Third party: add more projects

* Undo the capitalization

---------

Co-authored-by: Nicolas Girard <girard.nicolas@gmail.com>
Co-authored-by: Arijit Basu <sayanarijit@gmail.com>
2023-02-15 13:55:44 +01:00
Orhun Parmaksız
3343270680 docs: add tui-input and update xplr in README.md
Also update xplr description.

Co-authored-by: Arijit Basu <sayanarijit@gmail.com>
2023-02-14 19:13:21 +01:00
Orhun Parmaksız
bf9d502742 Update README.md (#39)
Co-authored-by: 晧暐 <henryliking@gmail.com>
2023-02-14 19:09:25 +01:00
Orhun Parmaksız
85c0779ac0 Update README.md (#40)
Co-authored-by: Tin Chung <56880684+chungquantin@users.noreply.github.com>
Co-authored-by: Arijit Basu <sayanarijit@users.noreply.github.com>
2023-02-14 21:29:11 +05:30
Orhun Parmaksız
070de44069 docs: Add hncli to list of applications made with tui-rs (#41)
Co-authored-by: pierreyoda <pierreyoda@users.noreply.github.com>
2023-02-14 16:54:14 +01:00
Orhun Parmaksız
33087e3a99 Fix typos (#45)
Co-authored-by: Howard Halim <howardhalim@gmail.com>
2023-02-14 10:26:07 +01:00
UncleScientist
fe4eb5e771 fix typos (#47) 2023-02-14 10:23:14 +01:00
Owletti
2fead23556 Clarify README.md fork status update
Clarify README.md
2023-02-14 10:22:07 +01:00
Leon Sautour
bc5a9e4c06 doc: Updated readme and contributing guide with updates about the fork (#46)
* doc: Updated readme and contributing guide with updates about the fork

* doc: added missing discord invite link
2023-02-13 16:10:08 +01:00
davidhelbig
fafad6c961 chore: fix typo in layout.rs (#619) 2022-08-14 15:58:02 +02:00
Florian Dehau
a4de409235 chore: add apps using tui 2022-08-14 15:51:31 +02:00
Florian Dehau
a05fd45959 Release v0.19.0 2022-08-14 15:38:31 +02:00
Florian Dehau
24de2f8a96 chore: bump crossterm to v0.25 2022-08-14 15:19:48 +02:00
Florian Dehau
eee37011a5 chore: fix clippy warnings 2022-08-14 14:59:04 +02:00
Florian Dehau
a67706bea0 chore(ci): bump cargo-make to v0.35.16 2022-08-14 14:43:38 +02:00
Linda_pp
faa69b6cfe chore: explicitly set MSRV to 1.56.1 in Cargo.toml 2022-08-14 14:24:54 +02:00
Florian Dehau
ba5ea2deff chore: update README 2022-08-14 13:58:53 +02:00
95 changed files with 9628 additions and 1576 deletions

16
.cargo-husky/hooks/pre-push Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
if !(command cargo-make >/dev/null 2>&1); then # Check if cargo-make is installed
echo Attempting to run cargo-make as part of the pre-push hook but it\'s not installed.
echo Please install it by running the following command:
echo
echo " cargo install --force cargo-make"
echo
echo If you don\'t want to run cargo-make as part of the pre-push hook, you can run
echo the following command instead of git push:
echo
echo " git push --no-verify"
exit 1
fi
cargo make ci

78
.cz.toml Normal file
View File

@@ -0,0 +1,78 @@
# configuration for https://github.com/commitizen/cz-cli
[tool.commitizen]
name = "cz_customize"
tag_format = "$version"
version_type = "semver"
version_provider = "cargo"
update_changelog_on_bump = true
major_version_zero = true
use_shortcuts = true
[tool.commitizen.customize]
message_template = """{{change_type}}({{scope}}): {{subject}}
{% if body %}\
{{body}}\
{% endif %}
{%if is_breaking_change %}\
BREAKING_CHANGE: \
{% endif %}\
{{footer}}\
"""
example = "feature: this feature enable customize through config file"
schema = "<type>(<scope>): <subject>\n\n<body>\n\n<footer>"
schema_pattern = "(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)\\(\\w+\\):\\s(?P<subject>.*)(\\n\\n(?P<body>.*))?(\\n\\n(?P<footer>.*))?"
# The order needs to be preserved, as it influences the order when executing cz commit/cz c
# Change types
[[tool.commitizen.customize.questions]]
type = "list"
name = "change_type"
choices = [
{ value = "build", name = "build: Changes that affect the build system or external dependencies (example scopes: pip, docker, npm)", key = "b" },
{ value = "chore", name = "chore: A modification that generally does not fall into any other category", key = "c" },
{ value = "ci", name = "ci: Changes to our CI configuration files and scripts (example scopes: GitLabCI)", key = "i" },
{ value = "docs", name = "docs: Documentation only changes", key = "d" },
{ value = "feat", name = "feat: A new feature.", key = "f" },
{ value = "fix", name = "fix: A bug fix.", key = "x" },
{ value = "perf", name = "perf: A code change that improves performance", key = "p" },
{ value = "refactor", name = "refactor: A code change that neither fixes a bug nor adds a feature", key = "r" },
{ value = "revert", name = "revert: Revert previous commits", key = "v" },
{ value = "style", name = "style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)", key = "s" },
{ value = "test", name = "test: Adding missing or correcting existing tests", key = "t" },
]
message = "Select the type of change you are committing"
# The scope of the change, can be a file, class name or other context
[[tool.commitizen.customize.questions]]
type = "input"
name = "scope"
message = "What is the scope of this change? (class or file name): (press [enter] to skip)\n"
# Summary of the changes
[[tool.commitizen.customize.questions]]
"type" = "input"
"name" = "subject"
"message" = "Write a short and imperative summary of the code changes: (lower case and no period)\n"
# The commit body, elaborate the changes if need be.
[[tool.commitizen.customize.questions]]
type = "input"
name = "body"
message = "Provide additional contextual information about the code changes: (press [enter] to skip)\n"
# Specify if the changes are breaking
[[tool.commitizen.customize.questions]]
type = "confirm"
name = "is_breaking_change"
message = "Is this a BREAKING CHANGE?"
default = false
# Reference closing issues and share other
[[tool.commitizen.customize.questions]]
type = "input"
name = "footer"
message = "Footer. Information about Breaking Changes and reference issues that this commit closes: (press [enter] to skip)"

8
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,8 @@
# See https://help.github.com/articles/about-codeowners/
# for more info about CODEOWNERS file
# It uses the same pattern rule for gitignore file
# https://git-scm.com/docs/gitignore#_pattern_format
# Maintainers
* @orhun @mindoodoo @sayanarijit @sophacles @joshka

View File

@@ -7,7 +7,7 @@ assignees: ''
---
<!--
Hi there, sorry `tui` is not working as expected.
Hi there, sorry `ratatui` is not working as expected.
Please fill this bug report conscientiously.
A detailed and complete issue is more likely to be processed quickly.
-->

View File

@@ -1,17 +1 @@
## Description
<!--
A clear and concise description of what this PR changes.
-->
## Testing guidelines
<!--
A clear and concise description of how the changes can be tested.
For example, you can include a command to run the relevant tests or examples.
You can also include screenshots of the expected behavior.
-->
## Checklist
* [ ] I have read the [contributing guidelines](../CONTRIBUTING.md).
* [ ] I have added relevant tests.
* [ ] I have documented all new additions.
<!-- Please read CONTRIBUTING.md before submitting any pull request. -->

19
.github/workflows/cd.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: Continuous Deployment
on:
push:
tags:
- "v*.*.*"
jobs:
publish:
name: Publish on crates.io
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v3
- name: Publish
uses: actions-rs/cargo@v1
with:
command: publish
args: --token ${{ secrets.CARGO_TOKEN }}

View File

@@ -1,71 +1,121 @@
on:
push:
branches:
- master
- feat-wrapping
- main
pull_request:
branches:
- master
- main
- feat-wrapping
merge_group:
name: CI
env:
CI_CARGO_MAKE_VERSION: 0.35.8
jobs:
linux:
name: Linux
build:
strategy:
matrix:
os: [ ubuntu-latest, windows-latest, macos-latest ]
toolchain: [ "1.65.0", "stable" ]
runs-on: ${{ matrix.os }}
needs: lint
steps:
- uses: actions/checkout@v3
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.toolchain }}
- name: Install cargo-make
uses: taiki-e/install-action@cargo-make
- name: "Check"
run: cargo make check
env:
RUST_BACKTRACE: full
CARGO_HUSKY_DONT_INSTALL_HOOKS: true
clippy:
name: Clippy
runs-on: ubuntu-latest
strategy:
matrix:
rust: ["1.56.1", "stable"]
needs: lint
steps:
- uses: hecrj/setup-rust-action@967aec96c6a27a0ce15c1dac3aaba332d60565e2
- uses: actions/checkout@v3
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
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:
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 / Build / Test"
run: cargo make ci
components: clippy
- name: Install cargo-make
uses: taiki-e/install-action@cargo-make
- name: "Clippy"
run: cargo make clippy
env:
RUST_BACKTRACE: full
windows:
name: Windows
runs-on: windows-latest
CARGO_HUSKY_DONT_INSTALL_HOOKS: true
test:
strategy:
matrix:
rust: ["1.56.1", "stable"]
os: [ ubuntu-latest, windows-latest, macos-latest ]
toolchain: [ "1.65.0", "stable" ]
runs-on: ${{ matrix.os }}
needs: lint
steps:
- uses: actions/checkout@v1
- uses: hecrj/setup-rust-action@967aec96c6a27a0ce15c1dac3aaba332d60565e2
- uses: actions/checkout@v3
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
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:
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 / Build / Test"
run: cargo make ci
toolchain: ${{ matrix.toolchain }}
- name: Install cargo-make
uses: taiki-e/install-action@cargo-make
- name: "Test"
run: cargo make test
env:
RUST_BACKTRACE: full
CARGO_HUSKY_DONT_INSTALL_HOOKS: true
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
if: github.event_name != 'pull_request'
uses: actions/checkout@v3
- name: Checkout
if: github.event_name == 'pull_request'
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: "Check conventional commits"
uses: crate-ci/committed@master
with:
args: "-vv"
commits: "HEAD"
- name: "Check typos"
uses: crate-ci/typos@master
- name: "Lint dependencies"
uses: EmbarkStudios/cargo-deny-action@v1
- name: Install Rust
uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt
- name: "Formatting"
run: cargo fmt --all --check
coverage:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v3
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools
- name: cargo install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: cargo llvm-cov
run: cargo llvm-cov --all-features --lcov --output-path lcov.info
env:
CARGO_HUSKY_DONT_INSTALL_HOOKS: true
- name: Upload to codecov.io
uses: codecov/codecov-action@v3
with:
fail_ci_if_error: true

9
.markdownlint.yaml Normal file
View File

@@ -0,0 +1,9 @@
# configuration for https://github.com/DavidAnson/markdownlint
no-inline-html:
allowed_elements:
- img
- details
- summary
line-length:
line_length: 100

View File

@@ -1,6 +1,217 @@
# Changelog
## To be released
## v0.21.0 - 2023-05-28
### Features
- *(backend)* Add termwiz backend and example ([#5](https://github.com/tui-rs-revival/ratatui/issues/5))
- *(block)* Support placing the title on bottom ([#36](https://github.com/tui-rs-revival/ratatui/issues/36))
- *(border)* Add border! macro for easy bitflag manipulation ([#11](https://github.com/tui-rs-revival/ratatui/issues/11))
- *(calendar)* Add calendar widget ([#138](https://github.com/tui-rs-revival/ratatui/issues/138))
- *(color)* Add `FromStr` implementation for `Color` ([#180](https://github.com/tui-rs-revival/ratatui/issues/180))
- *(list)* Add len() to List ([#24](https://github.com/tui-rs-revival/ratatui/pull/24))
- *(paragraph)* Allow Lines to be individually aligned ([#149](https://github.com/tui-rs-revival/ratatui/issues/149))
- *(sparkline)* Finish #1 Sparkline directions PR ([#134](https://github.com/tui-rs-revival/ratatui/issues/134))
- *(terminal)* Add inline viewport ([#114](https://github.com/tui-rs-revival/ratatui/issues/114)) [**breaking**]
- *(test)* Expose test buffer ([#160](https://github.com/tui-rs-revival/ratatui/issues/160))
- *(text)* Add `Masked` to display secure data ([#168](https://github.com/tui-rs-revival/ratatui/issues/168)) [**breaking**]
- *(widget)* Add circle widget ([#159](https://github.com/tui-rs-revival/ratatui/issues/159))
- *(widget)* Add style methods to Span, Spans, Text ([#148](https://github.com/tui-rs-revival/ratatui/issues/148))
- *(widget)* Support adding padding to Block ([#20](https://github.com/tui-rs-revival/ratatui/issues/20))
- *(widget)* Add offset() and offset_mut() for table and list state ([#12](https://github.com/tui-rs-revival/ratatui/issues/12))
### Bug Fixes
- *(canvas)* Use full block for Marker::Block ([#133](https://github.com/tui-rs-revival/ratatui/issues/133)) [**breaking**]
- *(example)* Update input in examples to only use press events ([#129](https://github.com/tui-rs-revival/ratatui/issues/129))
- *(uncategorized)* Cleanup doc example ([#145](https://github.com/tui-rs-revival/ratatui/issues/145))
- *(reflow)* Remove debug macro call ([#198](https://github.com/tui-rs-revival/ratatui/issues/198))
### Refactor
- *(example)* Remove redundant `vec![]` in `user_input` example ([#26](https://github.com/tui-rs-revival/ratatui/issues/26))
- *(example)* Refactor paragraph example ([#152](https://github.com/tui-rs-revival/ratatui/issues/152))
- *(style)* Mark some Style fns const so they can be defined globally ([#115](https://github.com/tui-rs-revival/ratatui/issues/115))
- *(text)* Replace `Spans` with `Line` ([#178](https://github.com/tui-rs-revival/ratatui/issues/178))
### Documentation
- *(apps)* Fix rsadsb/adsb_deku radar link ([#140](https://github.com/tui-rs-revival/ratatui/issues/140))
- *(apps)* Add tenere ([#141](https://github.com/tui-rs-revival/ratatui/issues/141))
- *(apps)* Add twitch-tui ([#124](https://github.com/tui-rs-revival/ratatui/issues/124))
- *(apps)* Add oxycards ([#113](https://github.com/tui-rs-revival/ratatui/issues/113))
- *(apps)* Re-add trippy to APPS.md ([#117](https://github.com/tui-rs-revival/ratatui/issues/117))
- *(block)* Add example for block.inner ([#158](https://github.com/tui-rs-revival/ratatui/issues/158))
- *(changelog)* Update the empty profile link in contributors ([#112](https://github.com/tui-rs-revival/ratatui/issues/112))
- *(readme)* Fix small typo in readme ([#186](https://github.com/tui-rs-revival/ratatui/issues/186))
- *(readme)* Add termwiz demo to examples ([#183](https://github.com/tui-rs-revival/ratatui/issues/183))
- *(readme)* Add acknowledgement section ([#154](https://github.com/tui-rs-revival/ratatui/issues/154))
- *(readme)* Update project description ([#127](https://github.com/tui-rs-revival/ratatui/issues/127))
- *(uncategorized)* Scrape example code from examples/* ([#195](https://github.com/tui-rs-revival/ratatui/issues/195))
### Styling
- *(apps)* Update the style of application list ([#184](https://github.com/tui-rs-revival/ratatui/issues/184))
- *(readme)* Update project introduction in README.md ([#153](https://github.com/tui-rs-revival/ratatui/issues/153))
- *(uncategorized)* Clippy's variable inlining in format macros
### Testing
- *(buffer)* Add `assert_buffer_eq!` and Debug implementation ([#161](https://github.com/tui-rs-revival/ratatui/issues/161))
- *(list)* Add characterization tests for list ([#167](https://github.com/tui-rs-revival/ratatui/issues/167))
- *(widget)* Add unit tests for Paragraph ([#156](https://github.com/tui-rs-revival/ratatui/issues/156))
### Miscellaneous Tasks
- *(uncategorized)* Inline format args ([#190](https://github.com/tui-rs-revival/ratatui/issues/190))
- *(uncategorized)* Minor lints, making Clippy happier ([#189](https://github.com/tui-rs-revival/ratatui/issues/189))
### Build
- *(uncategorized)* Bump MSRV to 1.65.0 ([#171](https://github.com/tui-rs-revival/ratatui/issues/171))
### Continuous Integration
- *(uncategorized)* Add ci, build, and revert to allowed commit types
### Contributors
Thank you so much to everyone that contributed to this release!
Here is the list of contributors who has contributed to `ratatui` for the first time!
- [@kpcyrd](https://github.com/kpcyrd)
- [@fujiapple852](https://github.com/fujiapple852)
- [@BrookJeynes](https://github.com/BrookJeynes)
- [@Ziqi-Yang](https://github.com/Ziqi-Yang)
- [@Xithrius](https://github.com/Xithrius)
- [@lesleyrs](https://github.com/lesleyrs)
- [@pythops](https://github.com/pythops)
- [@wcampbell0x2a](https://github.com/wcampbell0x2a)
- [@sophacles](https://github.com/sophacles)
- [@Eyesonjune18](https://github.com/Eyesonjune18)
- [@a-kenji](https://github.com/a-kenji)
- [@TimerErTim](https://github.com/TimerErTim)
- [@Mehrbod2002](https://github.com/Mehrbod2002)
- [@thomas-mauran](https://github.com/thomas-mauran)
- [@nyurik](https://github.com/nyurik)
## v0.20.1 - 2023-03-19
### Bug Fixes
- *(style)* Bold needs a bit ([#104](https://github.com/tui-rs-revival/ratatui/issues/104))
### Documentation
- *(apps)* Add "logss" to apps ([#105](https://github.com/tui-rs-revival/ratatui/issues/105))
- *(uncategorized)* Fixup remaining tui references ([#106](https://github.com/tui-rs-revival/ratatui/issues/106))
### Contributors
Thank you so much to everyone that contributed to this release!
- [@joshka](https://github.com/joshka)
- [@todoesverso](https://github.com/todoesverso)
- [@UncleScientist](https://github.com/UncleScientist)
## v0.20.0 - 2023-03-19
This marks the first release of `ratatui`, a community-maintained fork of [tui](https://github.com/fdehau/tui-rs).
The purpose of this release is to include **bug fixes** and **small changes** into the repository thus **no new features** are added. We have transferred all the pull requests from the original repository and worked on the low hanging ones to incorporate them in this "maintenance" release.
Here is a list of changes:
### Features
- *(cd)* Add continuous deployment workflow ([#93](https://github.com/tui-rs-revival/ratatui/issues/93))
- *(ci)* Add MacOS to CI ([#60](https://github.com/tui-rs-revival/ratatui/issues/60))
- *(widget)* Add `offset()` to `TableState` ([#10](https://github.com/tui-rs-revival/ratatui/issues/10))
- *(widget)* Add `width()` to ListItem ([#17](https://github.com/tui-rs-revival/ratatui/issues/17))
### Bug Fixes
- *(ci)* Test MSRV compatibility on CI ([#85](https://github.com/tui-rs-revival/ratatui/issues/85))
- *(ci)* Bump Rust version to 1.63.0 ([#80](https://github.com/tui-rs-revival/ratatui/issues/80))
- *(ci)* Use env for the cargo-make version ([#76](https://github.com/tui-rs-revival/ratatui/issues/76))
- *(ci)* Fix deprecation warnings on CI ([#58](https://github.com/tui-rs-revival/ratatui/issues/58))
- *(doc)* Add 3rd party libraries accidentally removed at #21 ([#61](https://github.com/tui-rs-revival/ratatui/issues/61))
- *(widget)* List should not ignore empty string items ([#42](https://github.com/tui-rs-revival/ratatui/issues/42)) [**breaking**]
- *(uncategorized)* Cassowary/layouts: add extra constraints for fixing Min(v)/Max(v) combination. ([#31](https://github.com/tui-rs-revival/ratatui/issues/31))
- *(uncategorized)* Fix user_input example double key press registered on windows
- *(uncategorized)* Ignore zero-width symbol on rendering `Paragraph`
- *(uncategorized)* Fix typos ([#45](https://github.com/tui-rs-revival/ratatui/issues/45))
- *(uncategorized)* Fix typos ([#47](https://github.com/tui-rs-revival/ratatui/issues/47))
### Refactor
- *(style)* Make bitflags smaller ([#13](https://github.com/tui-rs-revival/ratatui/issues/13))
### Documentation
- *(apps)* Move 'apps using ratatui' to dedicated file ([#98](https://github.com/tui-rs-revival/ratatui/issues/98)) ([#99](https://github.com/tui-rs-revival/ratatui/issues/99))
- *(canvas)* Add documentation for x_bounds, y_bounds ([#35](https://github.com/tui-rs-revival/ratatui/issues/35))
- *(contributing)* Specify the use of unsafe for optimization ([#67](https://github.com/tui-rs-revival/ratatui/issues/67))
- *(github)* Remove pull request template ([#68](https://github.com/tui-rs-revival/ratatui/issues/68))
- *(readme)* Update crate status badge ([#102](https://github.com/tui-rs-revival/ratatui/issues/102))
- *(readme)* Small edits before first release ([#101](https://github.com/tui-rs-revival/ratatui/issues/101))
- *(readme)* Add install instruction and update title ([#100](https://github.com/tui-rs-revival/ratatui/issues/100))
- *(readme)* Add systeroid to application list ([#92](https://github.com/tui-rs-revival/ratatui/issues/92))
- *(readme)* Add glicol-cli to showcase list ([#95](https://github.com/tui-rs-revival/ratatui/issues/95))
- *(readme)* Add oxker to application list ([#74](https://github.com/tui-rs-revival/ratatui/issues/74))
- *(readme)* Add app kubectl-watch which uses tui ([#73](https://github.com/tui-rs-revival/ratatui/issues/73))
- *(readme)* Add poketex to 'apps using tui' in README ([#64](https://github.com/tui-rs-revival/ratatui/issues/64))
- *(readme)* Update README.md ([#39](https://github.com/tui-rs-revival/ratatui/issues/39))
- *(readme)* Update README.md ([#40](https://github.com/tui-rs-revival/ratatui/issues/40))
- *(readme)* Clarify README.md fork status update
- *(uncategorized)* Fix: fix typos ([#90](https://github.com/tui-rs-revival/ratatui/issues/90))
- *(uncategorized)* Update to build more backends ([#81](https://github.com/tui-rs-revival/ratatui/issues/81))
- *(uncategorized)* Expand "Apps" and "Third-party" sections ([#21](https://github.com/tui-rs-revival/ratatui/issues/21))
- *(uncategorized)* Add tui-input and update xplr in README.md
- *(uncategorized)* Add hncli to list of applications made with tui-rs ([#41](https://github.com/tui-rs-revival/ratatui/issues/41))
- *(uncategorized)* Updated readme and contributing guide with updates about the fork ([#46](https://github.com/tui-rs-revival/ratatui/issues/46))
### Performance
- *(layout)* Better safe shared layout cache ([#62](https://github.com/tui-rs-revival/ratatui/issues/62))
### Miscellaneous Tasks
- *(cargo)* Update project metadata ([#94](https://github.com/tui-rs-revival/ratatui/issues/94))
- *(ci)* Integrate `typos` for checking typos ([#91](https://github.com/tui-rs-revival/ratatui/issues/91))
- *(ci)* Change the target branch to main ([#79](https://github.com/tui-rs-revival/ratatui/issues/79))
- *(ci)* Re-enable clippy on CI ([#59](https://github.com/tui-rs-revival/ratatui/issues/59))
- *(uncategorized)* Integrate `committed` for checking conventional commits ([#77](https://github.com/tui-rs-revival/ratatui/issues/77))
- *(uncategorized)* Update `rust-version` to 1.59 in Cargo.toml ([#57](https://github.com/tui-rs-revival/ratatui/issues/57))
- *(uncategorized)* Update deps ([#51](https://github.com/tui-rs-revival/ratatui/issues/51))
- *(uncategorized)* Fix typo in layout.rs ([#619](https://github.com/tui-rs-revival/ratatui/issues/619))
- *(uncategorized)* Add apps using `tui`
### Contributors
Thank you so much to everyone that contributed to this release!
- [@orhun](https://github.com/orhun)
- [@mindoodoo](https://github.com/mindoodoo)
- [@sayanarijit](https://github.com/sayanarijit)
- [@Owletti](https://github.com/Owletti)
- [@UncleScientist](https://github.com/UncleScientist)
- [@rhysd](https://github.com/rhysd)
- [@ckaznable](https://github.com/ckaznable)
- [@imuxin](https://github.com/imuxin)
- [@mrjackwills](https://github.com/mrjackwills)
- [@conradludgate](https://github.com/conradludgate)
- [@kianmeng](https://github.com/kianmeng)
- [@chaosprint](https://github.com/chaosprint)
And most importantly, special thanks to [Florian Dehau](https://github.com/fdehau) for creating this awesome library 💖 We look forward to building on the strong foundations that the original crate laid out.
## v0.19.0 - 2022-08-14
### Features
* Bump `crossterm` to `0.25`
## v0.18.0 - 2022-04-24
@@ -12,7 +223,7 @@
### Features
* Add option to `widgets::List` to repeat the hightlight symbol for each line of multi-line items (#533).
* Add option to `widgets::List` to repeat the highlight symbol for each line of multi-line items (#533).
* Add option to control the alignment of `Axis` labels in the `Chart` widget (#568).
### Breaking changes
@@ -270,7 +481,7 @@ In this new release, you may now write this as:
```rust
Block::default()
.style(Style::default().bg(Color::Green))
// The style is not overidden anymore, we simply add new style rule for the title.
// The style is not overridden anymore, we simply add new style rule for the title.
.title(Span::styled("My title", Style::default().add_modifier(Modifier::BOLD)))
```
@@ -278,9 +489,9 @@ In addition, the crate now provides a method `patch` to combine two styles into
rules:
```rust
let style = Style::default().modifer(Modifier::BOLD);
let style = Style::default().modifier(Modifier::BOLD);
let style = style.patch(Style::default().add_modifier(Modifier::ITALIC));
// style.modifer == Modifier::BOLD | Modifier::ITALIC, the modifier has been enriched not overidden
// style.modifier == Modifier::BOLD | Modifier::ITALIC, the modifier has been enriched not overridden
```
- `Style::modifier` has been removed in favor of `Style::add_modifier` and `Style::remove_modifier`.
@@ -598,7 +809,7 @@ let style = Style::default().add_modifier(Modifier::ITALIC | Modifier::BOLD);
### Bug Fixes
* Ensure correct behavoir of the alternate screens with the `Crossterm` backend.
* Ensure correct behavior of the alternate screens with the `Crossterm` backend.
* Fix out of bounds panic when two `Buffer` are merged.
## v0.4.0 - 2019-02-03
@@ -667,7 +878,7 @@ additional `termion` features.
* Replace `Item` by a generic and flexible `Text` that can be used in both
`Paragraph` and `List` widgets.
* Remove unecessary borrows on `Style`.
* Remove unnecessary borrows on `Style`.
## v0.3.0-beta.0 - 2018-09-04
@@ -690,7 +901,7 @@ widgets on the given `Frame`
* All widgets use the consumable builder pattern
* `SelectableList` can have no selected item and the highlight symbol is hidden
in this case
* Remove markup langage inside `Paragraph`. `Paragraph` now expects an iterator
* Remove markup language inside `Paragraph`. `Paragraph` now expects an iterator
of `Text` items
## v0.2.3 - 2018-06-09
@@ -698,7 +909,7 @@ of `Text` items
### Features
* Add `start_corner` option for `List`
* Add more text aligment options for `Paragraph`
* Add more text alignment options for `Paragraph`
## v0.2.2 - 2018-05-06

View File

@@ -1,10 +1,38 @@
# Fork Status
## Pull Requests
**All** pull requests opened on the original repository have been imported. We'll be going through any open PRs in a timely manner, starting with the **smallest bug fixes and README updates**. If you have an open PR make sure to let us know about it on our [discord](https://discord.gg/pMCEU9hNEj) as it helps to know you are still active.
## Issues
We have been unsuccessful in importing all issues opened on the previous repository.
For that reason, anyone wanting to **work on or discuss** an issue will have to follow the following workflow :
- Recreate the issue
- Start by referencing the **original issue**: ```Referencing issue #[<issue number>](<original issue link>)```
- Then, paste the original issues **opening** text
You can then resume the conversation by replying to this new issue you have created.
### Closing Issues
If you close an issue that you have "imported" to this fork, please make sure that you add the issue to the **CLOSED_ISSUES.md**. This will enable us to keep track of which issues have been closed from the original repo, in case we are able to have the original repository transferred.
# Contributing
## Implementation Guidelines
### Use of unsafe for optimization purposes
**Do not** use unsafe to achieve better performances. This is subject to change, [see.](https://github.com/tui-rs-revival/tui-rs-revival/discussions/66)
The only exception to this rule is if it's to fix **reproducible slowness.**
## Building
[cargo-make]: https://github.com/sagiegurari/cargo-make "cargo-make"
`tui` is an ordinary Rust project where common tasks are managed with [cargo-make].
`ratatui` 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.
Building the project should be as easy as running `cargo make build`.
@@ -15,6 +43,14 @@ Please include as many details as possible in your PR description to help the re
Make sure to highlight changes which may need additional attention or you are uncertain about.
Any idea with a large scale impact on the crate or its users should ideally be discussed in a "Feature Request" issue beforehand.
## Committing
To avoid any issues that may arrise with the CI/CD by not following the [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) syntax, we recommend to install [Commitizen](https://commitizen-tools.github.io/commitizen/).\
By using this tool you automatically follow the configuration defined in [.cz.toml](.cz.toml).
Additionally, we're using [cargo-husky](https://github.com/rhysd/cargo-husky) to automatically load pre-push hook, which will run `cargo make ci` before each push. It will load the hook automatically when you run `cargo test`. If `cargo-make` is not installed, it will install it for you.\
This will ensure that your code is formatted, compiles and passes all tests before you push. If you want to skip this check, you can use `git push --no-verify`.
## Continuous Integration
We use Github Actions for the CI where we perform the following checks:
@@ -28,6 +64,6 @@ You can also check most of those things yourself locally using `cargo make ci` w
## 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 usual doc and unit tests, one of the most valuable test you can write for `tui` is a test again the `TestBackend`.
Beside the usual doc and unit tests, one of the most valuable test you can write for `ratatui` is a test against the `TestBackend`.
It allows you to assert the content of the output buffer that would have been flushed to the terminal after a given draw call.
See `widgets_block_renders` in [tests/widgets_block.rs](./tests/widget_block.rs) for an example.

View File

@@ -1,93 +1,160 @@
[package]
name = "tui"
version = "0.18.0"
name = "ratatui"
version = "0.21.0"
authors = ["Florian Dehau <work@fdehau.com>"]
description = """
A library to build rich terminal user interfaces or dashboards
"""
documentation = "https://docs.rs/tui/0.18.0/tui/"
description = "A library to build rich terminal user interfaces or dashboards"
documentation = "https://docs.rs/ratatui/latest/ratatui/"
keywords = ["tui", "terminal", "dashboard"]
repository = "https://github.com/fdehau/tui-rs"
repository = "https://github.com/tui-rs-revival/ratatui"
readme = "README.md"
license = "MIT"
exclude = ["assets/*", ".github", "Makefile.toml", "CONTRIBUTING.md", "*.log", "tags"]
exclude = [
"assets/*",
".github",
"Makefile.toml",
"CONTRIBUTING.md",
"*.log",
"tags",
]
autoexamples = true
edition = "2021"
rust-version = "1.65.0"
[badges]
[features]
default = ["crossterm"]
all-widgets = ["widget-calendar"]
widget-calendar = ["time"]
macros = []
serde = ["dep:serde", "bitflags/serde"]
[package.metadata.docs.rs]
all-features = true
# see https://doc.rust-lang.org/nightly/rustdoc/scraped-examples.html
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
bitflags = "1.3"
bitflags = "2.3"
cassowary = "0.3"
unicode-segmentation = "1.2"
crossterm = { version = "0.26", optional = true }
indoc = "2.0"
serde = { version = "1", optional = true, features = ["derive"] }
termion = { version = "2.0", optional = true }
termwiz = { version = "0.20.0", optional = true }
time = { version = "0.3.11", optional = true, features = ["local-offset"] }
unicode-segmentation = "1.10"
unicode-width = "0.1"
termion = { version = "1.5", optional = true }
crossterm = { version = "0.23", optional = true }
serde = { version = "1", optional = true, features = ["derive"]}
[dev-dependencies]
rand = "0.8"
anyhow = "1.0.71"
argh = "0.1"
cargo-husky = { version = "1.5.0", default-features = false, features = ["user-hooks"] }
criterion = { version = "0.5", features = ["html_reports"] }
fakeit = "1.1"
rand = "0.8"
[[bench]]
name = "paragraph"
harness = false
[[example]]
name = "barchart"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "block"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "canvas"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "calendar"
required-features = ["crossterm", "widget-calendar"]
doc-scrape-examples = true
[[example]]
name = "chart"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "custom_widget"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "demo"
# this runs for all of the terminal backends, so it can't be built using --all-features or scraped
doc-scrape-examples = false
[[example]]
name = "gauge"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "hello_world"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "layout"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "list"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "panic"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "paragraph"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "popup"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "scrollbar"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "sparkline"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "table"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "tabs"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "user_input"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "inline"
required-features = ["crossterm"]
doc-scrape-examples = true

View File

@@ -1,53 +1,88 @@
# configuration for https://github.com/sagiegurari/cargo-make
[config]
skip_core_tasks = true
[tasks.ci]
run_task = [
{ name = "ci-unix", condition = { platforms = ["linux", "mac"] } },
{ name = "ci-windows", condition = { platforms = ["windows"] } },
{ name = "ci-unix", condition = { platforms = [
"linux",
"mac",
] } },
{ name = "ci-windows", condition = { platforms = [
"windows",
] } },
]
[tasks.ci-unix]
private = true
dependencies = [
"fmt",
"check-crossterm",
"check-termion",
"test-crossterm",
"test-termion",
"clippy-crossterm",
"clippy-termion",
"test-doc",
"style-check",
"check-unix",
"test-unix",
"clippy-unix",
]
[tasks.ci-windows]
private = true
dependencies = [
"fmt",
"check-crossterm",
"test-crossterm",
"clippy-crossterm",
"test-doc",
"style-check",
"check-windows",
"test-windows",
"clippy-windows",
]
[tasks.style-check]
dependencies = ["fmt", "typos"]
[tasks.fmt]
toolchain = "nightly"
command = "cargo"
args = [
"fmt",
"--all",
"--",
"--check",
args = ["fmt", "--all", "--check"]
[tasks.typos]
install_crate = { crate_name = "typos-cli", binary = "typos", test_arg = "--version" }
command = "typos"
[tasks.check]
run_task = [
{ name = "check-unix", condition = { platforms = [
"linux",
"mac",
] } },
{ name = "check-windows", condition = { platforms = [
"windows",
] } },
]
[tasks.check-unix]
private = true
dependencies = [
"check-crossterm",
"check-termion",
"check-termwiz",
]
[tasks.check-windows]
private = true
dependencies = [
"check-crossterm",
"check-termwiz",
]
[tasks.check-crossterm]
env = { TUI_FEATURES = "serde,crossterm" }
run_task = "check"
run_task = "check-backend"
[tasks.check-termion]
env = { TUI_FEATURES = "serde,termion" }
run_task = "check"
run_task = "check-backend"
[tasks.check]
[tasks.check-termwiz]
env = { TUI_FEATURES = "serde,termwiz" }
run_task = "check-backend"
[tasks.check-backend]
command = "cargo"
condition = { env_set = ["TUI_FEATURES"] }
args = [
@@ -58,15 +93,45 @@ args = [
"--all-targets",
]
[tasks.build]
run_task = [
{ name = "build-unix", condition = { platforms = [
"linux",
"mac",
] } },
{ name = "build-windows", condition = { platforms = [
"windows",
] } },
]
[tasks.build-unix]
private = true
dependencies = [
"build-crossterm",
"build-termion",
"build-termwiz",
]
[tasks.build-windows]
private = true
dependencies = [
"build-crossterm",
"build-termwiz",
]
[tasks.build-crossterm]
env = { TUI_FEATURES = "serde,crossterm" }
run_task = "build"
run_task = "build-backend"
[tasks.build-termion]
env = { TUI_FEATURES = "serde,termion" }
run_task = "build"
run_task = "build-backend"
[tasks.build]
[tasks.build-termwiz]
env = { TUI_FEATURES = "serde,termwiz" }
run_task = "build-backend"
[tasks.build-backend]
command = "cargo"
condition = { env_set = ["TUI_FEATURES"] }
args = [
@@ -77,21 +142,53 @@ args = [
"--all-targets",
]
[tasks.clippy]
run_task = [
{ name = "clippy-unix", condition = { platforms = [
"linux",
"mac",
] } },
{ name = "clippy-windows", condition = { platforms = [
"windows",
] } },
]
[tasks.clippy-unix]
private = true
dependencies = [
"clippy-crossterm",
"clippy-termion",
"clippy-termwiz",
]
[tasks.clippy-windows]
private = true
dependencies = [
"clippy-crossterm",
"clippy-termwiz",
]
[tasks.clippy-crossterm]
env = { TUI_FEATURES = "serde,crossterm" }
run_task = "clippy"
run_task = "clippy-backend"
[tasks.clippy-termion]
env = { TUI_FEATURES = "serde,termion" }
run_task = "clippy"
run_task = "clippy-backend"
[tasks.clippy]
[tasks.clippy-termwiz]
env = { TUI_FEATURES = "serde,termwiz" }
run_task = "clippy-backend"
[tasks.clippy-backend]
command = "cargo"
condition = { env_set = ["TUI_FEATURES"] }
args = [
"clippy",
"--all-targets",
"--no-default-features",
"--tests",
"--benches",
"--features",
"${TUI_FEATURES}",
"--",
@@ -99,15 +196,47 @@ args = [
"warnings",
]
[tasks.test]
run_task = [
{ name = "test-unix", condition = { platforms = [
"linux",
"mac",
] } },
{ name = "test-windows", condition = { platforms = [
"windows",
] } },
]
[tasks.test-unix]
private = true
dependencies = [
"test-crossterm",
"test-termion",
"test-termwiz",
"test-doc",
]
[tasks.test-windows]
private = true
dependencies = [
"test-crossterm",
"test-termwiz",
"test-doc",
]
[tasks.test-crossterm]
env = { TUI_FEATURES = "serde,crossterm" }
run_task = "test"
env = { TUI_FEATURES = "serde,crossterm,all-widgets,macros" }
run_task = "test-backend"
[tasks.test-termion]
env = { TUI_FEATURES = "serde,termion" }
run_task = "test"
env = { TUI_FEATURES = "serde,termion,all-widgets,macros" }
run_task = "test-backend"
[tasks.test]
[tasks.test-termwiz]
env = { TUI_FEATURES = "serde,termwiz,all-widgets,macros" }
run_task = "test-backend"
[tasks.test-backend]
command = "cargo"
condition = { env_set = ["TUI_FEATURES"] }
args = [
@@ -115,36 +244,22 @@ args = [
"--no-default-features",
"--features",
"${TUI_FEATURES}",
"--lib",
"--tests",
"--examples",
"--all-targets",
]
[tasks.test-doc]
command = "cargo"
args = [
"test",
"--doc",
]
args = ["test", "--doc"]
[tasks.run-example]
private = true
condition = { env_set = ["TUI_EXAMPLE_NAME"] }
command = "cargo"
args = [
"run",
"--release",
"--example",
"${TUI_EXAMPLE_NAME}"
]
args = ["run", "--release", "--example", "${TUI_EXAMPLE_NAME}"]
[tasks.build-examples]
command = "cargo"
args = [
"build",
"--examples",
"--release"
]
args = ["build", "--examples", "--release"]
[tasks.run-examples]
dependencies = ["build-examples"]

325
README.md
View File

@@ -1,126 +1,263 @@
# tui-rs
# Ratatui
[![Build Status](https://github.com/fdehau/tui-rs/workflows/CI/badge.svg)](https://github.com/fdehau/tui-rs/actions?query=workflow%3ACI+)
[![Crate Status](https://img.shields.io/crates/v/tui.svg)](https://crates.io/crates/tui)
[![Docs Status](https://docs.rs/tui/badge.svg)](https://docs.rs/crate/tui/)
<img align="left" src="https://avatars.githubusercontent.com/u/125200832?s=128&v=4">
<img src="./assets/demo.gif" alt="Demo cast under Linux Termite with Inconsolata font 12pt">
`ratatui` is a [Rust](https://www.rust-lang.org) library to build rich terminal user interfaces and
dashboards. It is a community fork of the original [tui-rs](https://github.com/fdehau/tui-rs)
project.
`tui-rs` is a [Rust](https://www.rust-lang.org) library to build rich terminal
user interfaces and dashboards. It is heavily inspired by the `Javascript`
library [blessed-contrib](https://github.com/yaronn/blessed-contrib) and the
`Go` library [termui](https://github.com/gizak/termui).
[![Crates.io](https://img.shields.io/crates/v/ratatui?logo=rust&style=for-the-badge)](https://crates.io/crates/ratatui)
[![License](https://img.shields.io/crates/l/ratatui?style=for-the-badge)](./LICENSE) [![GitHub CI
Status](https://img.shields.io/github/actions/workflow/status/tui-rs-revival/ratatui/ci.yml?style=for-the-badge&logo=github)](https://github.com/tui-rs-revival/ratatui/actions?query=workflow%3ACI+)
[![Docs.rs](https://img.shields.io/docsrs/ratatui?logo=rust&style=for-the-badge)](https://docs.rs/crate/ratatui/)
[![Dependency
Status](https://deps.rs/repo/github/tui-rs-revival/ratatui/status.svg?style=for-the-badge)](https://deps.rs/repo/github/tui-rs-revival/ratatui)
[![Codecov](https://img.shields.io/codecov/c/github/tui-rs-revival/ratatui?logo=codecov&style=for-the-badge&token=BAQ8SOKEST)](https://app.codecov.io/gh/tui-rs-revival/ratatui)
[![Discord](https://img.shields.io/discord/1070692720437383208?label=discord&logo=discord&style=for-the-badge)](https://discord.gg/pMCEU9hNEj)
The library supports multiple backends:
- [crossterm](https://github.com/crossterm-rs/crossterm) [default]
- [termion](https://github.com/ticki/termion)
<!-- See RELEASE.md for instructions on creating the demo gif --->
![Demo of Ratatui](https://github.com/tui-rs-revival/ratatui/assets/24392180/93ab0e38-93e0-4ae0-a31b-91ae6c393185)
The library is based on the principle of immediate rendering with intermediate
buffers. This means that at each new frame you should build all widgets that are
supposed to be part of the UI. While providing a great flexibility for rich and
interactive UI, this may introduce overhead for highly dynamic content. So, the
implementation try to minimize the number of ansi escapes sequences generated to
draw the updated UI. In practice, given the speed of `Rust` the overhead rather
comes from the terminal emulator than the library itself.
<details>
<summary>Table of Contents</summary>
Moreover, the library does not provide any input handling nor any event system and
you may rely on the previously cited libraries to achieve such features.
* [Ratatui](#ratatui)
* [Installation](#installation)
* [Introduction](#introduction)
* [Quickstart](#quickstart)
* [Status of this fork](#status-of-this-fork)
* [Rust version requirements](#rust-version-requirements)
* [Documentation](#documentation)
* [Examples](#examples)
* [Widgets](#widgets)
* [Built in](#built-in)
* [Third\-party libraries, bootstrapping templates and
widgets](#third-party-libraries-bootstrapping-templates-and-widgets)
* [Apps](#apps)
* [Alternatives](#alternatives)
* [Contributors](#contributors)
* [Acknowledgments](#acknowledgments)
* [License](#license)
### Rust version requirements
</details>
Since version 0.17.0, `tui` requires **rustc version 1.56.1 or greater**.
### [Documentation](https://docs.rs/tui)
### Demo
The demo shown in the gif can be run with all available backends.
## Installation
```shell
cargo add ratatui --features all-widgets
```
Or modify your `Cargo.toml`
```toml
[dependencies]
ratatui = { version = "0.21.0", features = ["all-widgets"]}
```
Ratatui is mostly backwards compatible with `tui-rs`. To migrate an existing project, it may be
easier to rename the ratatui dependency to `tui` rather than updating every usage of the crate.
E.g.:
```toml
[dependencies]
tui = { package = "ratatui", version = "0.21.0", features = ["all-widgets"]}
```
## Introduction
`ratatui` is a terminal UI library that supports multiple backends:
* [crossterm](https://github.com/crossterm-rs/crossterm) [default]
* [termion](https://github.com/ticki/termion)
* [termwiz](https://github.com/wez/wezterm/tree/master/termwiz)
The library is based on the principle of immediate rendering with intermediate buffers. This means
that at each new frame you should build all widgets that are supposed to be part of the UI. While
providing a great flexibility for rich and interactive UI, this may introduce overhead for highly
dynamic content. So, the implementation try to minimize the number of ansi escapes sequences
generated to draw the updated UI. In practice, given the speed of `Rust` the overhead rather comes
from the terminal emulator than the library itself.
Moreover, the library does not provide any input handling nor any event system and you may rely on
the previously cited libraries to achieve such features.
We keep a [CHANGELOG](./CHANGELOG.md) generated by [git-cliff](https://github.com/orhun/git-cliff)
utilizing [Conventional Commits](https://www.conventionalcommits.org/).
## Quickstart
The following example demonstrates the minimal amount of code necessary to setup a terminal and
render "Hello World!". The full code for this example which contains a little more detail is in
[hello_world.rs](./examples/hello_world.rs). For more guidance on how to create Ratatui apps, see
the [Docs](https://docs.rs/ratatui) and [Examples](#examples). There is also a starter template
available at [rust-tui-template](https://github.com/tui-rs-revival/rust-tui-template).
```rust
fn main() -> Result<(), Box<dyn Error>> {
let mut terminal = setup_terminal()?;
run(&mut terminal)?;
restore_terminal(&mut terminal)?;
Ok(())
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, Box<dyn Error>> {
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
Ok(Terminal::new(CrosstermBackend::new(stdout))?)
}
fn restore_terminal(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<(), Box<dyn Error>> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen,)?;
Ok(terminal.show_cursor()?)
}
fn run(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), Box<dyn Error>> {
Ok(loop {
terminal.draw(|frame| {
let greeting = Paragraph::new("Hello World!");
frame.render_widget(greeting, frame.size());
})?;
if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
if KeyCode::Char('q') == key.code {
break;
}
}
}
})
}
```
## Status of this fork
In response to the original maintainer [**Florian Dehau**](https://github.com/fdehau)'s issue
regarding the [future of `tui-rs`](https://github.com/fdehau/tui-rs/issues/654), several members of
the community forked the project and created this crate. We look forward to continuing the work
started by Florian 🚀
In order to organize ourselves, we currently use a [Discord server](https://discord.gg/pMCEU9hNEj),
feel free to join and come chat! There are also plans to implement a [Matrix](https://matrix.org/)
bridge in the near future. **Discord is not a MUST to contribute**. We follow a pretty standard
github centered open source workflow keeping the most important conversations on GitHub, open an
issue or PR and it will be addressed. 😄
Please make sure you read the updated [contributing](./CONTRIBUTING.md) guidelines, especially if
you are interested in working on a PR or issue opened in the previous repository.
## Rust version requirements
Since version 0.21.0, The Minimum Supported Rust Version (MSRV) of `ratatui` is 1.65.0.
## Documentation
The documentation can be found on [docs.rs.](https://docs.rs/ratatui)
## Examples
The demo shown in the gif above is available on all available backends.
```shell
# crossterm
cargo run --example demo --release -- --tick-rate 200
cargo run --example demo
# termion
cargo run --example demo --no-default-features --features=termion --release -- --tick-rate 200
cargo run --example demo --no-default-features --features=termion
# termwiz
cargo run --example demo --no-default-features --features=termwiz
```
where `tick-rate` is the UI refresh rate in ms.
The UI code for the is in [examples/demo/ui.rs](./examples/demo/ui.rs) while the application state
is in [examples/demo/app.rs](./examples/demo/app.rs).
The UI code is in [examples/demo/ui.rs](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/demo/ui.rs) while the
application state is in [examples/demo/app.rs](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/demo/app.rs).
If the user interface contains glyphs that are not displayed correctly by your terminal, you may
want to run the demo without those symbols:
If the user interface contains glyphs that are not displayed correctly by your terminal, you may want to run
the demo without those symbols:
```
```shell
cargo run --example demo --release -- --tick-rate 200 --enhanced-graphics false
```
### Widgets
More examples are available in the [examples](./examples/) folder.
The library comes with the following list of widgets:
## Widgets
* [Block](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/block.rs)
* [Gauge](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/gauge.rs)
* [Sparkline](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/sparkline.rs)
* [Chart](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/chart.rs)
* [BarChart](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/barchart.rs)
* [List](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/list.rs)
* [Table](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/table.rs)
* [Paragraph](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/paragraph.rs)
* [Canvas (with line, point cloud, map)](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/canvas.rs)
* [Tabs](https://github.com/fdehau/tui-rs/blob/v0.18.0/examples/tabs.rs)
### Built in
Click on each item to see the source of the example. Run the examples with with
cargo (e.g. to run the gauge example `cargo run --example gauge`), and quit by pressing `q`.
The library comes with the following
[widgets](https://docs.rs/ratatui/latest/ratatui/widgets/index.html):
You can run all examples by running `cargo make run-examples` (require
`cargo-make` that can be installed with `cargo install cargo-make`).
* [Canvas](https://docs.rs/ratatui/latest/ratatui/widgets/canvas/struct.Canvas.html) which allows
rendering [points, lines, shapes and a world
map](https://docs.rs/ratatui/latest/ratatui/widgets/canvas/index.html)
* [BarChart](https://docs.rs/ratatui/latest/ratatui/widgets/struct.BarChart.html)
* [Block](https://docs.rs/ratatui/latest/ratatui/widgets/struct.Block.html)
* [Calendar](https://docs.rs/ratatui/latest/ratatui/widgets/calendar/index.html)
* [Chart](https://docs.rs/ratatui/latest/ratatui/widgets/struct.Chart.html)
* [Gauge](https://docs.rs/ratatui/latest/ratatui/widgets/struct.Gauge.html)
* [List](https://docs.rs/ratatui/latest/ratatui/widgets/struct.List.html)
* [Paragraph](https://docs.rs/ratatui/latest/ratatui/widgets/struct.Paragraph.html)
* [Sparkline](https://docs.rs/ratatui/latest/ratatui/widgets/struct.Sparkline.html)
* [Table](https://docs.rs/ratatui/latest/ratatui/widgets/struct.Table.html)
* [Tabs](https://docs.rs/ratatui/latest/ratatui/widgets/struct.Tabs.html)
### Third-party widgets
Each wiget has an associated example which can be found in the [examples](./examples/) folder. Run
each examples with cargo (e.g. to run the gauge example `cargo run --example gauge`), and quit by
pressing `q`.
* [tui-logger](https://github.com/gin66/tui-logger)
You can also run all examples by running `cargo make run-examples` (requires `cargo-make` that can
be installed with `cargo install cargo-make`).
### Apps using tui
### Third-party libraries, bootstrapping templates and widgets
* [spotify-tui](https://github.com/Rigellute/spotify-tui)
* [bandwhich](https://github.com/imsnif/bandwhich)
* [kmon](https://github.com/orhun/kmon)
* [gpg-tui](https://github.com/orhun/gpg-tui)
* [ytop](https://github.com/cjbassi/ytop)
* [zenith](https://github.com/bvaisvil/zenith)
* [bottom](https://github.com/ClementTsang/bottom)
* [oha](https://github.com/hatoo/oha)
* [gitui](https://github.com/extrawurst/gitui)
* [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/)
* [Vector](https://vector.dev)
* [KDash](https://github.com/kdash-rs/kdash)
* [xplr](https://github.com/sayanarijit/xplr)
* [minesweep](https://github.com/cpcloud/minesweep-rs)
* [Battleship.rs](https://github.com/deepu105/battleship-rs)
* [termscp](https://github.com/veeso/termscp)
* [joshuto](https://github.com/kamiyaa/joshuto)
* [adsb_deku/radar](https://github.com/wcampbell0x2a/adsb_deku#radar-tui)
* [hoard](https://github.com/Hyde46/hoard)
* [tokio-console](https://github.com/tokio-rs/console): a diagnostics and debugging tool for asynchronous Rust programs.
* [hwatch](https://github.com/blacknon/hwatch): a alternative watch command that records the result of command execution and can display its history and diffs.
* [ytui-music](https://github.com/sudipghimire533/ytui-music): listen to music from youtube inside your terminal.
* [mqttui](https://github.com/EdJoPaTo/mqttui): subscribe or publish to a MQTT Topic quickly from the terminal.
* [meteo-tui](https://github.com/16arpi/meteo-tui): french weather via the command line.
* [picterm](https://github.com/ksk001100/picterm): preview images in your terminal.
* [gobang](https://github.com/TaKO8Ki/gobang): a cross-platform TUI database management tool.
* [ansi-to-tui](https://github.com/uttarayan21/ansi-to-tui) — Convert ansi colored text to
`tui::text::Text`
* [color-to-tui](https://github.com/uttarayan21/color-to-tui) — Parse hex colors to
`tui::style::Color`
* [rust-tui-template](https://github.com/orhun/rust-tui-template) — A template for bootstrapping a
Rust TUI application with Tui-rs & crossterm
* [simple-tui-rs](https://github.com/pmsanford/simple-tui-rs) — A simple example tui-rs app
* [tui-builder](https://github.com/jkelleyrtp/tui-builder) — Batteries-included MVC framework for
Tui-rs + Crossterm apps
* [tui-clap](https://github.com/kegesch/tui-clap-rs) — Use clap-rs together with Tui-rs
* [tui-log](https://github.com/kegesch/tui-log-rs) — Example of how to use logging with Tui-rs
* [tui-logger](https://github.com/gin66/tui-logger) — Logger and Widget for Tui-rs
* [tui-realm](https://github.com/veeso/tui-realm) — Tui-rs framework to build stateful applications
with a React/Elm inspired approach
* [tui-realm-treeview](https://github.com/veeso/tui-realm-treeview) — Treeview component for
Tui-realm
* [tui-rs-tree-widgets](https://github.com/EdJoPaTo/tui-rs-tree-widget): Widget for tree data
structures.
* [tui-windows](https://github.com/markatk/tui-windows-rs) — Tui-rs abstraction to handle multiple
windows and their rendering
* [tui-textarea](https://github.com/rhysd/tui-textarea): Simple yet powerful multi-line text editor
widget supporting several key shortcuts, undo/redo, text search, etc.
* [tui-input](https://github.com/sayanarijit/tui-input): TUI input library supporting multiple
backends and tui-rs.
* [tui-term](https://github.com/a-kenji/tui-term): A pseudoterminal widget library
that enables the rendering of terminal applications as ratatui widgets.
### Alternatives
## Apps
You might want to checkout [Cursive](https://github.com/gyscos/Cursive) for an
alternative solution to build text user interfaces in Rust.
Check out the list of more than 50 [Apps using
`Ratatui`](https://github.com/tui-rs-revival/ratatui/wiki/Apps-using-Ratatui)!
## Alternatives
You might want to checkout [Cursive](https://github.com/gyscos/Cursive) for an alternative solution
to build text user interfaces in Rust.
## Contributors
[![GitHub
Contributors](https://contrib.rocks/image?repo=tui-rs-revival/ratatui)](https://github.com/tui-rs-revival/ratatui/graphs/contributors)
## Acknowledgments
Special thanks to [**Pavel Fomchenkov**](https://github.com/nawok) for his work in designing **an
awesome logo** for the ratatui project and tui-rs-revival organization.
## License
[MIT](LICENSE)
[MIT](./LICENSE)

30
RELEASE.md Normal file
View File

@@ -0,0 +1,30 @@
# Creating a Release
[crates.io](https://crates.io/crates/ratatui) releases are automated via [GitHub
actions](.github/workflows/cd.yml) and triggered by pushing a tag.
1. Record a new demo gif. The preferred tool for this is [ttyrec](http://0xcc.net/ttyrec/) and
[ttygif](https://github.com/icholy/ttygif). [Asciinema](https://asciinema.org/) handles block
character height poorly, [termanilizer](https://www.terminalizer.com/) takes forever to render,
[vhs](https://github.com/charmbracelet/vhs) handles braille
characters poorly (though if <https://github.com/charmbracelet/vhs/issues/322> is fixed, then
it's probably the best option).
```shell
cargo build --example demo
ttyrec -e 'cargo --quiet run --release --example demo -- --tick-rate 100' demo.rec
ttygif demo.rec
```
Then upload it somewhere (e.g. use `vhs publish tty.gif` to publish it or upload it to a GitHub
wiki page as an attachment). Avoid adding the gif to the git repo as binary files tend to bloat
repositories.
1. Bump the version in [Cargo.toml](Cargo.toml).
1. Ensure [CHANGELOG.md](CHANGELOG.md) is updated. [git-cliff](https://github.com/orhun/git-cliff)
can be used for generating the entries.
1. Commit and push the changes.
1. Create a new tag: `git tag -a v[X.Y.Z]`
1. Push the tag: `git push --tags`
1. Wait for [Continuous Deployment](https://github.com/tui-rs-revival/ratatui/actions) workflow to
finish.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

91
bacon.toml Normal file
View File

@@ -0,0 +1,91 @@
# This is a configuration file for the bacon tool
#
# Bacon repository: https://github.com/Canop/bacon
# Complete help on configuration: https://dystroy.org/bacon/config/
# You can also check bacon's own bacon.toml file
# as an example: https://github.com/Canop/bacon/blob/main/bacon.toml
default_job = "check"
[jobs.check]
command = ["cargo", "check", "--all-features", "--color", "always"]
need_stdout = false
[jobs.check-all]
command = ["cargo", "check", "--all-targets", "--all-features", "--color", "always"]
need_stdout = false
[jobs.clippy]
command = [
"cargo", "clippy",
"--all-targets",
"--color", "always",
]
need_stdout = false
[jobs.test]
command = [
"cargo", "test",
"--all-features",
"--color", "always",
"--", "--color", "always", # see https://github.com/Canop/bacon/issues/124
]
need_stdout = true
[jobs.doc]
command = [
"cargo", "+nightly", "doc",
"-Zunstable-options", "-Zrustdoc-scrape-examples",
"--all-features",
"--color", "always",
"--no-deps",
]
env.RUSTDOCFLAGS = "--cfg docsrs"
need_stdout = false
# If the doc compiles, then it opens in your browser and bacon switches
# to the previous job
[jobs.doc-open]
command = [
"cargo", "+nightly", "doc",
"-Zunstable-options", "-Zrustdoc-scrape-examples",
"--all-features",
"--color", "always",
"--no-deps",
"--open",
]
env.RUSTDOCFLAGS = "--cfg docsrs"
need_stdout = false
on_success = "job:doc" # so that we don't open the browser at each change
# You can run your application and have the result displayed in bacon,
# *if* it makes sense for this crate. You can run an example the same
# way. Don't forget the `--color always` part or the errors won't be
# properly parsed.
[jobs.run]
command = [
"cargo", "run",
"--color", "always",
# put launch parameters for your program behind a `--` separator
]
need_stdout = true
allow_warnings = true
[jobs.check-crossterm]
command = ["cargo", "check", "--color", "always", "--all-targets", "--no-default-features", "--features", "crossterm"]
[jobs.check-termion]
command = ["cargo", "check", "--color", "always", "--all-targets", "--no-default-features", "--features", "termion"]
[jobs.check-termwiz]
command = ["cargo", "check", "--color", "always", "--all-targets", "--no-default-features", "--features", "termwiz"]
# You may define here keybindings that would be specific to
# a project, for example a shortcut to launch a specific job.
# Shortcuts to internal functions (scrolling, toggling, etc.)
# should go in your personal global prefs.toml file instead.
[keybindings]
# alt-m = "job:my-job"
ctrl-c = "job:check-crossterm"
ctrl-t = "job:check-termion"
ctrl-w = "job:check-termwiz"

89
benches/paragraph.rs Normal file
View File

@@ -0,0 +1,89 @@
use criterion::{black_box, criterion_group, criterion_main, Bencher, BenchmarkId, Criterion};
use ratatui::{
buffer::Buffer,
layout::Rect,
widgets::{Paragraph, Widget, Wrap},
};
/// because the scroll offset is a u16, the maximum number of lines that can be scrolled is 65535.
/// This is a limitation of the current implementation and may be fixed by changing the type of the
/// scroll offset to a u32.
const MAX_SCROLL_OFFSET: u16 = u16::MAX;
const NO_WRAP_WIDTH: u16 = 200;
const WRAP_WIDTH: u16 = 100;
/// Benchmark for rendering a paragraph with a given number of lines. The design of this benchmark
/// allows comparison of the performance of rendering a paragraph with different numbers of lines.
/// as well as comparing with the various settings on the scroll and wrap features.
pub fn paragraph(c: &mut Criterion) {
let mut group = c.benchmark_group("paragraph");
for &line_count in [64, 2048, MAX_SCROLL_OFFSET].iter() {
let lines = random_lines(line_count);
let lines = lines.as_str();
// benchmark that measures the overhead of creating a paragraph separately from rendering
group.bench_with_input(BenchmarkId::new("new", line_count), lines, |b, lines| {
b.iter(|| Paragraph::new(black_box(lines)))
});
// render the paragraph with no scroll
group.bench_with_input(
BenchmarkId::new("render", line_count),
&Paragraph::new(lines),
|bencher, paragraph| render(bencher, paragraph, NO_WRAP_WIDTH),
);
// scroll the paragraph by half the number of lines and render
group.bench_with_input(
BenchmarkId::new("render_scroll_half", line_count),
&Paragraph::new(lines).scroll((0u16, line_count / 2)),
|bencher, paragraph| render(bencher, paragraph, NO_WRAP_WIDTH),
);
// scroll the paragraph by the full number of lines and render
group.bench_with_input(
BenchmarkId::new("render_scroll_full", line_count),
&Paragraph::new(lines).scroll((0u16, line_count)),
|bencher, paragraph| render(bencher, paragraph, NO_WRAP_WIDTH),
);
// render the paragraph wrapped to 100 characters
group.bench_with_input(
BenchmarkId::new("render_wrap", line_count),
&Paragraph::new(lines).wrap(Wrap { trim: false }),
|bencher, paragraph| render(bencher, paragraph, WRAP_WIDTH),
);
// scroll the paragraph by the full number of lines and render wrapped to 100 characters
group.bench_with_input(
BenchmarkId::new("render_wrap_scroll_full", line_count),
&Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((0u16, line_count)),
|bencher, paragraph| render(bencher, paragraph, WRAP_WIDTH),
);
}
group.finish();
}
/// render the paragraph into a buffer with the given width
fn render(bencher: &mut Bencher, paragraph: &Paragraph, width: u16) {
let mut buffer = Buffer::empty(Rect::new(0, 0, width, 50));
bencher.iter(|| {
paragraph.clone().render(buffer.area, &mut buffer);
})
}
/// Create a string with the given number of lines filled with nonsense words
///
/// English language has about 5.1 average characters per word so including the space between words
/// this should emit around 200 characters per paragraph on average.
fn random_lines(count: u16) -> String {
let count = count as i64;
let sentence_count = 3;
let word_count = 11;
fakeit::words::paragraph(count, sentence_count, word_count, "\n".into())
}
criterion_group!(benches, paragraph);
criterion_main!(benches);

86
cliff.toml Normal file
View File

@@ -0,0 +1,86 @@
# configuration for https://github.com/orhun/git-cliff
[changelog]
# changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://tera.netlify.app/docs/#introduction
body = """
{% if version %}\
## {{ version }} - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits
| filter(attribute="scope")
| sort(attribute="scope") %}
- *({{commit.scope}})* {{ commit.message | upper_first }}{% if commit.breaking %} [**breaking**]{% endif %}
{%- endfor -%}
{% raw %}\n{% endraw %}\
{%- for commit in commits %}
{%- if commit.scope -%}
{% else -%}
- *(uncategorized)* {{ commit.message | upper_first }}{% if commit.breaking %} [**breaking**]{% endif %}
{% endif -%}
{% endfor -%}
{% endfor %}\n
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
<!-- generated by git-cliff -->
"""
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/tui-rs-revival/ratatui/issues/${2}))" },
{ pattern = '(better safe shared layout cache)', replace = "perf(layout): ${1}" },
{ pattern = '(Clarify README.md)', replace = "docs(readme): ${1}" },
{ pattern = '(Update README.md)', replace = "docs(readme): ${1}" },
{ pattern = '(fix typos|Fix typos)', replace = "fix: ${1}" },
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "<!-- 00 -->Features" },
{ message = "^[fF]ix", group = "<!-- 01 -->Bug Fixes" },
{ message = "^refactor", group = "<!-- 02 -->Refactor" },
{ message = "^doc", group = "<!-- 03 -->Documentation" },
{ message = "^perf", group = "<!-- 04 -->Performance" },
{ message = "^style", group = "<!-- 05 -->Styling" },
{ message = "^test", group = "<!-- 06 -->Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore", group = "<!-- 07 -->Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 08 -->Security" },
{ message = "^build", group = "<!-- 09 -->Build" },
{ message = "^ci", group = "<!-- 10 -->Continuous Integration" },
{ message = "^revert", group = "<!-- 11 -->Reverted Commits" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# glob pattern for matching git tags
tag_pattern = "v[0-9]*"
# regex for skipping tags
skip_tags = "v0.1.0-rc.1"
# regex for ignoring tags
ignore_tags = ""
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "newest"

30
committed.toml Normal file
View File

@@ -0,0 +1,30 @@
# configuration for https://github.com/crate-ci/committed
# https://www.conventionalcommits.org
style = "conventional"
# disallow merge commits
merge_commit = false
# subject is not required to be capitalized
subject_capitalized = false
# subject should start with an imperative verb
imperative_subject = true
# subject should not end with a punctuation
subject_not_punctuated = true
# disable line length
line_length = 0
# disable subject length
subject_length = 0
# default allowed_types [ "chore", "docs", "feat", "fix", "perf", "refactor", "style", "test" ]
allowed_types = [
"build",
"chore",
"ci",
"docs",
"feat",
"fix",
"perf",
"refactor",
"revert",
"style",
"test",
]

28
deny.toml Normal file
View File

@@ -0,0 +1,28 @@
# configuration for https://github.com/EmbarkStudios/cargo-deny
[licenses]
default = "deny"
unlicensed = "deny"
copyleft = "deny"
confidence-threshold = 0.8
allow = [
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"MIT",
"Unicode-DFS-2016",
"WTFPL",
]
[advisories]
unmaintained = "deny"
yanked = "deny"
[bans]
multiple-versions = "allow"
[sources]
unknown-registry = "deny"
unknown-git = "warn"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]

View File

@@ -1,14 +1,15 @@
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use tui::{
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
@@ -81,7 +82,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())

View File

@@ -1,15 +1,15 @@
use std::{error::Error, io};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{error::Error, io};
use tui::{
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, BorderType, Borders},
style::{Color, Style, Stylize},
widgets::{block::title::Title, Block, BorderType, Borders, Padding, Paragraph},
Frame, Terminal,
};
@@ -34,7 +34,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())
@@ -61,8 +61,7 @@ fn ui<B: Backend>(f: &mut Frame<B>) {
// Surrounding block
let block = Block::default()
.borders(Borders::ALL)
.title("Main block with round corners")
.title_alignment(Alignment::Center)
.title(Title::from("Main block with round corners").alignment(Alignment::Center))
.border_type(BorderType::Rounded);
f.render_widget(block, size);
@@ -80,23 +79,13 @@ fn ui<B: Backend>(f: &mut Frame<B>) {
// Top left inner block with green background
let block = Block::default()
.title(vec![
Span::styled("With", Style::default().fg(Color::Yellow)),
Span::from(" background"),
])
.style(Style::default().bg(Color::Green));
.title(vec!["With".yellow(), " background".into()])
.on_green();
f.render_widget(block, top_chunks[0]);
// Top right inner block with styled title aligned to the right
let block = Block::default()
.title(Span::styled(
"Styled title",
Style::default()
.fg(Color::White)
.bg(Color::Red)
.add_modifier(Modifier::BOLD),
))
.title_alignment(Alignment::Right);
.title(Title::from("Styled title".white().on_red().bold()).alignment(Alignment::Right));
f.render_widget(block, top_chunks[1]);
// Bottom two inner blocks
@@ -106,14 +95,32 @@ fn ui<B: Backend>(f: &mut Frame<B>) {
.split(chunks[1]);
// Bottom left block with all default borders
let block = Block::default().title("With borders").borders(Borders::ALL);
f.render_widget(block, bottom_chunks[0]);
let block = Block::default()
.title("With borders")
.borders(Borders::ALL)
.padding(Padding {
left: 4,
right: 4,
top: 2,
bottom: 2,
});
let text = Paragraph::new("text inside padded block").block(block);
f.render_widget(text, bottom_chunks[0]);
// Bottom right block with styled left and right border
let block = Block::default()
.title("With styled borders and doubled borders")
.border_style(Style::default().fg(Color::Cyan))
.borders(Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Double);
.border_type(BorderType::Double)
.padding(Padding::uniform(1));
let inner_block = Block::default()
.title("Block inside padded block")
.borders(Borders::ALL);
let inner_area = block.inner(bottom_chunks[1]);
f.render_widget(block, bottom_chunks[1]);
f.render_widget(inner_block, inner_area);
}

283
examples/calendar.rs Normal file
View File

@@ -0,0 +1,283 @@
use std::{error::Error, io, rc::Rc};
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::calendar::{CalendarEventStore, DateStyler, Monthly},
Frame, Terminal,
};
use time::{Date, Month, OffsetDateTime};
fn main() -> Result<(), Box<dyn Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
loop {
let _ = terminal.draw(|f| draw(f));
if let Event::Key(key) = event::read()? {
#[allow(clippy::single_match)]
match key.code {
KeyCode::Char(_) => {
break;
}
_ => {}
};
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn draw<B: Backend>(f: &mut Frame<B>) {
let app_area = f.size();
let calarea = Rect {
x: app_area.x + 1,
y: app_area.y + 1,
height: app_area.height - 1,
width: app_area.width - 1,
};
let mut start = OffsetDateTime::now_local()
.unwrap()
.date()
.replace_month(Month::January)
.unwrap()
.replace_day(1)
.unwrap();
let list = make_dates(start.year());
for chunk in split_rows(&calarea)
.iter()
.flat_map(|row| split_cols(row).to_vec())
{
let cal = cals::get_cal(start.month(), start.year(), &list);
f.render_widget(cal, chunk);
start = start.replace_month(start.month().next()).unwrap();
}
}
fn split_rows(area: &Rect) -> Rc<[Rect]> {
let list_layout = Layout::default()
.direction(Direction::Vertical)
.margin(0)
.constraints(
[
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(33),
]
.as_ref(),
);
list_layout.split(*area)
}
fn split_cols(area: &Rect) -> Rc<[Rect]> {
let list_layout = Layout::default()
.direction(Direction::Horizontal)
.margin(0)
.constraints(
[
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
]
.as_ref(),
);
list_layout.split(*area)
}
fn make_dates(current_year: i32) -> CalendarEventStore {
let mut list = CalendarEventStore::today(
Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Blue),
);
// Holidays
let holiday_style = Style::default()
.fg(Color::Red)
.add_modifier(Modifier::UNDERLINED);
// new year's
list.add(
Date::from_calendar_date(current_year, Month::January, 1).unwrap(),
holiday_style,
);
// next new_year's for December "show surrounding"
list.add(
Date::from_calendar_date(current_year + 1, Month::January, 1).unwrap(),
holiday_style,
);
// groundhog day
list.add(
Date::from_calendar_date(current_year, Month::February, 2).unwrap(),
holiday_style,
);
// april fool's
list.add(
Date::from_calendar_date(current_year, Month::April, 1).unwrap(),
holiday_style,
);
// earth day
list.add(
Date::from_calendar_date(current_year, Month::April, 22).unwrap(),
holiday_style,
);
// star wars day
list.add(
Date::from_calendar_date(current_year, Month::May, 4).unwrap(),
holiday_style,
);
// festivus
list.add(
Date::from_calendar_date(current_year, Month::December, 23).unwrap(),
holiday_style,
);
// new year's eve
list.add(
Date::from_calendar_date(current_year, Month::December, 31).unwrap(),
holiday_style,
);
// seasons
let season_style = Style::default()
.fg(Color::White)
.bg(Color::Yellow)
.add_modifier(Modifier::UNDERLINED);
// spring equinox
list.add(
Date::from_calendar_date(current_year, Month::March, 22).unwrap(),
season_style,
);
// summer solstice
list.add(
Date::from_calendar_date(current_year, Month::June, 21).unwrap(),
season_style,
);
// fall equinox
list.add(
Date::from_calendar_date(current_year, Month::September, 22).unwrap(),
season_style,
);
list.add(
Date::from_calendar_date(current_year, Month::December, 21).unwrap(),
season_style,
);
list
}
mod cals {
use super::*;
pub(super) fn get_cal<'a, S: DateStyler>(m: Month, y: i32, es: S) -> Monthly<'a, S> {
use Month::*;
match m {
May => example1(m, y, es),
June => example2(m, y, es),
July => example3(m, y, es),
December => example3(m, y, es),
February => example4(m, y, es),
November => example5(m, y, es),
_ => default(m, y, es),
}
}
fn default<'a, S: DateStyler>(m: Month, y: i32, es: S) -> Monthly<'a, S> {
let default_style = Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Rgb(50, 50, 50));
Monthly::new(Date::from_calendar_date(y, m, 1).unwrap(), es)
.show_month_header(Style::default())
.default_style(default_style)
}
fn example1<'a, S: DateStyler>(m: Month, y: i32, es: S) -> Monthly<'a, S> {
let default_style = Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Rgb(50, 50, 50));
Monthly::new(Date::from_calendar_date(y, m, 1).unwrap(), es)
.show_surrounding(default_style)
.default_style(default_style)
.show_month_header(Style::default())
}
fn example2<'a, S: DateStyler>(m: Month, y: i32, es: S) -> Monthly<'a, S> {
let header_style = Style::default()
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::DIM)
.fg(Color::LightYellow);
let default_style = Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Rgb(50, 50, 50));
Monthly::new(Date::from_calendar_date(y, m, 1).unwrap(), es)
.show_weekdays_header(header_style)
.default_style(default_style)
.show_month_header(Style::default())
}
fn example3<'a, S: DateStyler>(m: Month, y: i32, es: S) -> Monthly<'a, S> {
let header_style = Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Green);
let default_style = Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Rgb(50, 50, 50));
Monthly::new(Date::from_calendar_date(y, m, 1).unwrap(), es)
.show_surrounding(Style::default().add_modifier(Modifier::DIM))
.show_weekdays_header(header_style)
.default_style(default_style)
.show_month_header(Style::default())
}
fn example4<'a, S: DateStyler>(m: Month, y: i32, es: S) -> Monthly<'a, S> {
let header_style = Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Green);
let default_style = Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Rgb(50, 50, 50));
Monthly::new(Date::from_calendar_date(y, m, 1).unwrap(), es)
.show_weekdays_header(header_style)
.default_style(default_style)
}
fn example5<'a, S: DateStyler>(m: Month, y: i32, es: S) -> Monthly<'a, S> {
let header_style = Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Green);
let default_style = Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Rgb(50, 50, 50));
Monthly::new(Date::from_calendar_date(y, m, 1).unwrap(), es)
.show_month_header(header_style)
.default_style(default_style)
}
}

View File

@@ -1,18 +1,19 @@
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use tui::{
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::Span,
style::{Color, Stylize},
symbols::Marker,
widgets::{
canvas::{Canvas, Map, MapResolution, Rectangle},
Block, Borders,
@@ -29,6 +30,8 @@ struct App {
vy: f64,
dir_x: bool,
dir_y: bool,
tick_count: u64,
marker: Marker,
}
impl App {
@@ -48,10 +51,22 @@ impl App {
vy: 1.0,
dir_x: true,
dir_y: true,
tick_count: 0,
marker: Marker::Dot,
}
}
fn on_tick(&mut self) {
self.tick_count += 1;
// only change marker every 4 ticks (1s) to avoid stroboscopic effect
if (self.tick_count % 4) == 0 {
self.marker = match self.marker {
Marker::Dot => Marker::Block,
Marker::Block => Marker::Bar,
Marker::Bar => Marker::Braille,
Marker::Braille => Marker::Dot,
};
}
if self.ball.x < self.playground.left() as f64
|| self.ball.x + self.ball.width > self.playground.right() as f64
{
@@ -100,7 +115,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())
@@ -155,22 +170,20 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
.split(f.size());
let canvas = Canvas::default()
.block(Block::default().borders(Borders::ALL).title("World"))
.marker(app.marker)
.paint(|ctx| {
ctx.draw(&Map {
color: Color::White,
resolution: MapResolution::High,
});
ctx.print(
app.x,
-app.y,
Span::styled("You are here", Style::default().fg(Color::Yellow)),
);
ctx.print(app.x, -app.y, "You are here".yellow());
})
.x_bounds([-180.0, 180.0])
.y_bounds([-90.0, 90.0]);
f.render_widget(canvas, chunks[0]);
let canvas = Canvas::default()
.block(Block::default().borders(Borders::ALL).title("Pong"))
.marker(app.marker)
.paint(|ctx| {
ctx.draw(&app.ball);
})

View File

@@ -1,17 +1,18 @@
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use tui::{
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
style::{Color, Modifier, Style, Stylize},
symbols,
text::Span,
widgets::{Axis, Block, Borders, Chart, Dataset, GraphType},
@@ -117,7 +118,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())
@@ -189,12 +190,7 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
let chart = Chart::new(datasets)
.block(
Block::default()
.title(Span::styled(
"Chart 1",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.title("Chart 1".cyan().bold())
.borders(Borders::ALL),
)
.x_axis(
@@ -208,11 +204,7 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
Axis::default()
.title("Y Axis")
.style(Style::default().fg(Color::Gray))
.labels(vec![
Span::styled("-20", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("0"),
Span::styled("20", Style::default().add_modifier(Modifier::BOLD)),
])
.labels(vec!["-20".bold(), "0".into(), "20".bold()])
.bounds([-20.0, 20.0]),
);
f.render_widget(chart, chunks[0]);
@@ -226,12 +218,7 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
let chart = Chart::new(datasets)
.block(
Block::default()
.title(Span::styled(
"Chart 2",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.title("Chart 2".cyan().bold())
.borders(Borders::ALL),
)
.x_axis(
@@ -239,22 +226,14 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
.title("X Axis")
.style(Style::default().fg(Color::Gray))
.bounds([0.0, 5.0])
.labels(vec![
Span::styled("0", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("2.5"),
Span::styled("5.0", Style::default().add_modifier(Modifier::BOLD)),
]),
.labels(vec!["0".bold(), "2.5".into(), "5.0".bold()]),
)
.y_axis(
Axis::default()
.title("Y Axis")
.style(Style::default().fg(Color::Gray))
.bounds([0.0, 5.0])
.labels(vec![
Span::styled("0", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("2.5"),
Span::styled("5.0", Style::default().add_modifier(Modifier::BOLD)),
]),
.labels(vec!["0".bold(), "2.5".into(), "5.0".bold()]),
);
f.render_widget(chart, chunks[1]);
@@ -267,12 +246,7 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
let chart = Chart::new(datasets)
.block(
Block::default()
.title(Span::styled(
"Chart 3",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.title("Chart 3".cyan().bold())
.borders(Borders::ALL),
)
.x_axis(
@@ -280,22 +254,14 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
.title("X Axis")
.style(Style::default().fg(Color::Gray))
.bounds([0.0, 50.0])
.labels(vec![
Span::styled("0", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("25"),
Span::styled("50", Style::default().add_modifier(Modifier::BOLD)),
]),
.labels(vec!["0".bold(), "25".into(), "50".bold()]),
)
.y_axis(
Axis::default()
.title("Y Axis")
.style(Style::default().fg(Color::Gray))
.bounds([0.0, 5.0])
.labels(vec![
Span::styled("0", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("2.5"),
Span::styled("5", Style::default().add_modifier(Modifier::BOLD)),
]),
.labels(vec!["0".bold(), "2.5".into(), "5".bold()]),
);
f.render_widget(chart, chunks[2]);
}

View File

@@ -1,10 +1,11 @@
use std::{error::Error, io};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{error::Error, io};
use tui::{
use ratatui::{
backend::{Backend, CrosstermBackend},
buffer::Buffer,
layout::Rect,
@@ -52,7 +53,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())

View File

@@ -2,7 +2,7 @@ use rand::{
distributions::{Distribution, Uniform},
rngs::ThreadRng,
};
use tui::widgets::ListState;
use ratatui::widgets::ListState;
const TASKS: [&str; 24] = [
"Item1", "Item2", "Item3", "Item4", "Item5", "Item6", "Item7", "Item8", "Item9", "Item10",

View File

@@ -1,19 +1,21 @@
use crate::{app::App, ui};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use tui::{
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
Terminal,
};
use crate::{app::App, ui};
pub fn run(tick_rate: Duration, enhanced_graphics: bool) -> Result<(), Box<dyn Error>> {
// setup terminal
enable_raw_mode()?;
@@ -36,7 +38,7 @@ pub fn run(tick_rate: Duration, enhanced_graphics: bool) -> Result<(), Box<dyn E
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())
@@ -56,13 +58,15 @@ fn run_app<B: Backend>(
.unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char(c) => app.on_key(c),
KeyCode::Left => app.on_left(),
KeyCode::Up => app.on_up(),
KeyCode::Right => app.on_right(),
KeyCode::Down => app.on_down(),
_ => {}
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char(c) => app.on_key(c),
KeyCode::Left => app.on_left(),
KeyCode::Up => app.on_up(),
KeyCode::Right => app.on_right(),
KeyCode::Down => app.on_down(),
_ => {}
}
}
}
}

View File

@@ -1,16 +1,16 @@
use std::{error::Error, time::Duration};
use argh::FromArgs;
mod app;
#[cfg(feature = "crossterm")]
mod crossterm;
#[cfg(feature = "termion")]
mod termion;
mod ui;
#[cfg(feature = "termwiz")]
mod termwiz;
#[cfg(feature = "crossterm")]
use crate::crossterm::run;
#[cfg(feature = "termion")]
use crate::termion::run;
use argh::FromArgs;
use std::{error::Error, time::Duration};
mod ui;
/// Demo
#[derive(Debug, FromArgs)]
@@ -26,6 +26,11 @@ struct Cli {
fn main() -> Result<(), Box<dyn Error>> {
let cli: Cli = argh::from_env();
let tick_rate = Duration::from_millis(cli.tick_rate);
run(tick_rate, cli.enhanced_graphics)?;
#[cfg(feature = "crossterm")]
crate::crossterm::run(tick_rate, cli.enhanced_graphics)?;
#[cfg(feature = "termion")]
crate::termion::run(tick_rate, cli.enhanced_graphics)?;
#[cfg(feature = "termwiz")]
crate::termwiz::run(tick_rate, cli.enhanced_graphics)?;
Ok(())
}

View File

@@ -1,21 +1,26 @@
use crate::{app::App, ui};
use std::{error::Error, io, sync::mpsc, thread, time::Duration};
use ratatui::{
backend::{Backend, TermionBackend},
Terminal,
};
use termion::{
event::Key,
input::{MouseTerminal, TermRead},
raw::IntoRawMode,
screen::AlternateScreen,
};
use tui::{
backend::{Backend, TermionBackend},
Terminal,
screen::IntoAlternateScreen,
};
use crate::{app::App, ui};
pub fn run(tick_rate: Duration, enhanced_graphics: bool) -> Result<(), Box<dyn Error>> {
// setup terminal
let stdout = io::stdout().into_raw_mode()?;
let stdout = io::stdout()
.into_raw_mode()
.unwrap()
.into_alternate_screen()
.unwrap();
let stdout = MouseTerminal::from(stdout);
let stdout = AlternateScreen::from(stdout);
let backend = TermionBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
@@ -64,14 +69,14 @@ fn events(tick_rate: Duration) -> mpsc::Receiver<Event> {
let stdin = io::stdin();
for key in stdin.keys().flatten() {
if let Err(err) = keys_tx.send(Event::Input(key)) {
eprintln!("{}", err);
eprintln!("{err}");
return;
}
}
});
thread::spawn(move || loop {
if let Err(err) = tx.send(Event::Tick) {
eprintln!("{}", err);
eprintln!("{err}");
break;
}
thread::sleep(tick_rate);

76
examples/demo/termwiz.rs Normal file
View File

@@ -0,0 +1,76 @@
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use ratatui::{backend::TermwizBackend, Terminal};
use termwiz::{input::*, terminal::Terminal as TermwizTerminal};
use crate::{app::App, ui};
pub fn run(tick_rate: Duration, enhanced_graphics: bool) -> Result<(), Box<dyn Error>> {
let backend = TermwizBackend::new()?;
let mut terminal = Terminal::new(backend)?;
terminal.hide_cursor()?;
// create app and run it
let app = App::new("Termwiz Demo", enhanced_graphics);
let res = run_app(&mut terminal, app, tick_rate);
terminal.show_cursor()?;
terminal.flush()?;
if let Err(err) = res {
println!("{err:?}");
}
Ok(())
}
fn run_app(
terminal: &mut Terminal<TermwizBackend>,
mut app: App,
tick_rate: Duration,
) -> io::Result<()> {
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| ui::draw(f, &mut app))?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if let Ok(Some(input)) = terminal
.backend_mut()
.buffered_terminal_mut()
.terminal()
.poll_input(Some(timeout))
{
match input {
InputEvent::Key(key_code) => match key_code.key {
KeyCode::UpArrow => app.on_up(),
KeyCode::DownArrow => app.on_down(),
KeyCode::LeftArrow => app.on_left(),
KeyCode::RightArrow => app.on_right(),
KeyCode::Char(c) => app.on_key(c),
_ => {}
},
InputEvent::Resized { cols, rows } => {
terminal
.backend_mut()
.buffered_terminal_mut()
.resize(cols, rows);
}
_ => {}
}
}
if last_tick.elapsed() >= tick_rate {
app.on_tick();
last_tick = Instant::now();
}
if app.should_quit {
return Ok(());
}
}
}

View File

@@ -1,18 +1,19 @@
use crate::app::App;
use tui::{
use ratatui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Span, Spans},
widgets::canvas::{Canvas, Line, Map, MapResolution, Rectangle},
text::{Line, Span},
widgets::{
canvas::{Canvas, Circle, Line as CanvasLine, Map, MapResolution, Rectangle},
Axis, BarChart, Block, Borders, Cell, Chart, Dataset, Gauge, LineGauge, List, ListItem,
Paragraph, Row, Sparkline, Table, Tabs, Wrap,
},
Frame,
};
use crate::app::App;
pub fn draw<B: Backend>(f: &mut Frame<B>, app: &mut App) {
let chunks = Layout::default()
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
@@ -21,7 +22,7 @@ pub fn draw<B: Backend>(f: &mut Frame<B>, app: &mut App) {
.tabs
.titles
.iter()
.map(|t| Spans::from(Span::styled(*t, Style::default().fg(Color::Green))))
.map(|t| Line::from(Span::styled(*t, Style::default().fg(Color::Green))))
.collect();
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title(app.title))
@@ -82,6 +83,7 @@ where
.bg(Color::Black)
.add_modifier(Modifier::ITALIC | Modifier::BOLD),
)
.use_unicode(app.enhanced_graphics)
.label(label)
.ratio(app.progress);
f.render_widget(gauge, chunks[0]);
@@ -137,7 +139,7 @@ where
.tasks
.items
.iter()
.map(|i| ListItem::new(vec![Spans::from(Span::raw(*i))]))
.map(|i| ListItem::new(vec![Line::from(Span::raw(*i))]))
.collect();
let tasks = List::new(tasks)
.block(Block::default().borders(Borders::ALL).title("List"))
@@ -161,8 +163,8 @@ where
"WARNING" => warning_style,
_ => info_style,
};
let content = vec![Spans::from(vec![
Span::styled(format!("{:<9}", level), s),
let content = vec![Line::from(vec![
Span::styled(format!("{level:<9}"), s),
Span::raw(evt),
])];
ListItem::new(content)
@@ -261,9 +263,9 @@ where
B: Backend,
{
let text = vec![
Spans::from("This is a paragraph with several lines. You can change style your text the way you want"),
Spans::from(""),
Spans::from(vec![
Line::from("This is a paragraph with several lines. You can change style your text the way you want"),
Line::from(""),
Line::from(vec![
Span::from("For example: "),
Span::styled("under", Style::default().fg(Color::Red)),
Span::raw(" "),
@@ -272,7 +274,7 @@ where
Span::styled("rainbow", Style::default().fg(Color::Blue)),
Span::raw("."),
]),
Spans::from(vec![
Line::from(vec![
Span::raw("Oh and if you didn't "),
Span::styled("notice", Style::default().add_modifier(Modifier::ITALIC)),
Span::raw(" you can "),
@@ -283,7 +285,7 @@ where
Span::styled("text", Style::default().add_modifier(Modifier::UNDERLINED)),
Span::raw(".")
]),
Spans::from(
Line::from(
"One more thing is that it should display unicode characters: 10€"
),
];
@@ -346,9 +348,15 @@ where
height: 10.0,
color: Color::Yellow,
});
ctx.draw(&Circle {
x: app.servers[2].coords.1,
y: app.servers[2].coords.0,
radius: 10.0,
color: Color::Green,
});
for (i, s1) in app.servers.iter().enumerate() {
for s2 in &app.servers[i + 1..] {
ctx.draw(&Line {
ctx.draw(&CanvasLine {
x1: s1.coords.1,
y1: s1.coords.0,
y2: s2.coords.0,
@@ -411,7 +419,7 @@ where
.iter()
.map(|c| {
let cells = vec![
Cell::from(Span::raw(format!("{:?}: ", c))),
Cell::from(Span::raw(format!("{c:?}: "))),
Cell::from(Span::styled("Foreground", Style::default().fg(*c))),
Cell::from(Span::styled("Background", Style::default().bg(*c))),
];

View File

@@ -1,14 +1,15 @@
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use tui::{
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
@@ -77,7 +78,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())
@@ -152,7 +153,7 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
.use_unicode(true);
f.render_widget(gauge, chunks[2]);
let label = format!("{}/100", app.progress2);
let label = format!("{}/100", app.progress4);
let gauge = Gauge::default()
.block(Block::default().title("Gauge4"))
.gauge_style(

80
examples/hello_world.rs Normal file
View File

@@ -0,0 +1,80 @@
use std::{
io::{self, Stdout},
time::Duration,
};
use anyhow::{Context, Result};
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, widgets::Paragraph, Terminal};
/// This is a bare minimum example. There are many approaches to running an application loop, so
/// this is not meant to be prescriptive. It is only meant to demonstrate the basic setup and
/// teardown of a terminal application.
///
/// A more robust application would probably want to handle errors and ensure that the terminal is
/// restored to a sane state before exiting. This example does not do that. It also does not handle
/// events or update the application state. It just draws a greeting and exits when the user
/// presses 'q'.
fn main() -> Result<()> {
let mut terminal = setup_terminal().context("setup failed")?;
run(&mut terminal).context("app loop failed")?;
restore_terminal(&mut terminal).context("restore terminal failed")?;
Ok(())
}
/// Setup the terminal. This is where you would enable raw mode, enter the alternate screen, and
/// hide the cursor. This example does not handle errors. A more robust application would probably
/// want to handle errors and ensure that the terminal is restored to a sane state before exiting.
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
let mut stdout = io::stdout();
enable_raw_mode().context("failed to enable raw mode")?;
execute!(stdout, EnterAlternateScreen).context("unable to enter alternate screen")?;
Terminal::new(CrosstermBackend::new(stdout)).context("creating terminal failed")
}
/// Restore the terminal. This is where you disable raw mode, leave the alternate screen, and show
/// the cursor.
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
disable_raw_mode().context("failed to disable raw mode")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("unable to switch to main screen")?;
terminal.show_cursor().context("unable to show cursor")
}
/// Run the application loop. This is where you would handle events and update the application
/// state. This example exits when the user presses 'q'. Other styles of application loops are
/// possible, for example, you could have multiple application states and switch between them based
/// on events, or you could have a single application state and update it based on events.
fn run(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
loop {
terminal.draw(crate::render_app)?;
if should_quit()? {
break;
}
}
Ok(())
}
/// Render the application. This is where you would draw the application UI. This example just
/// draws a greeting.
fn render_app(frame: &mut ratatui::Frame<CrosstermBackend<Stdout>>) {
let greeting = Paragraph::new("Hello World! (press 'q' to quit)");
frame.render_widget(greeting, frame.size());
}
/// Check if the user has pressed 'q'. This is where you would handle events. This example just
/// checks if the user has pressed 'q' and returns true if they have. It does not handle any other
/// events. There is a 250ms timeout on the event poll so that the application can exit in a timely
/// manner, and to ensure that the terminal is rendered at least once every 250ms.
fn should_quit() -> Result<bool> {
if event::poll(Duration::from_millis(250)).context("event poll failed")? {
if let Event::Key(key) = event::read().context("event read failed")? {
return Ok(KeyCode::Char('q') == key.code);
}
}
Ok(false)
}

291
examples/inline.rs Normal file
View File

@@ -0,0 +1,291 @@
use std::{
collections::{BTreeMap, VecDeque},
error::Error,
io,
sync::mpsc,
thread,
time::{Duration, Instant},
};
use rand::distributions::{Distribution, Uniform};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Line, Span},
widgets::{block::title::Title, Block, Gauge, LineGauge, List, ListItem, Paragraph, Widget},
Frame, Terminal, TerminalOptions, Viewport,
};
const NUM_DOWNLOADS: usize = 10;
type DownloadId = usize;
type WorkerId = usize;
enum Event {
Input(crossterm::event::KeyEvent),
Tick,
Resize,
DownloadUpdate(WorkerId, DownloadId, f64),
DownloadDone(WorkerId, DownloadId),
}
struct Downloads {
pending: VecDeque<Download>,
in_progress: BTreeMap<WorkerId, DownloadInProgress>,
}
impl Downloads {
fn next(&mut self, worker_id: WorkerId) -> Option<Download> {
match self.pending.pop_front() {
Some(d) => {
self.in_progress.insert(
worker_id,
DownloadInProgress {
id: d.id,
started_at: Instant::now(),
progress: 0.0,
},
);
Some(d)
}
None => None,
}
}
}
struct DownloadInProgress {
id: DownloadId,
started_at: Instant,
progress: f64,
}
struct Download {
id: DownloadId,
size: usize,
}
struct Worker {
id: WorkerId,
tx: mpsc::Sender<Download>,
}
fn main() -> Result<(), Box<dyn Error>> {
crossterm::terminal::enable_raw_mode()?;
let stdout = io::stdout();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(8),
},
)?;
let (tx, rx) = mpsc::channel();
input_handling(tx.clone());
let workers = workers(tx);
let mut downloads = downloads();
for w in &workers {
let d = downloads.next(w.id).unwrap();
w.tx.send(d).unwrap();
}
run_app(&mut terminal, workers, downloads, rx)?;
crossterm::terminal::disable_raw_mode()?;
terminal.clear()?;
Ok(())
}
fn input_handling(tx: mpsc::Sender<Event>) {
let tick_rate = Duration::from_millis(200);
thread::spawn(move || {
let mut last_tick = Instant::now();
loop {
// poll for tick rate duration, if no events, sent tick event.
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout).unwrap() {
match crossterm::event::read().unwrap() {
crossterm::event::Event::Key(key) => tx.send(Event::Input(key)).unwrap(),
crossterm::event::Event::Resize(_, _) => tx.send(Event::Resize).unwrap(),
_ => {}
};
}
if last_tick.elapsed() >= tick_rate {
tx.send(Event::Tick).unwrap();
last_tick = Instant::now();
}
}
});
}
fn workers(tx: mpsc::Sender<Event>) -> Vec<Worker> {
(0..4)
.map(|id| {
let (worker_tx, worker_rx) = mpsc::channel::<Download>();
let tx = tx.clone();
thread::spawn(move || {
while let Ok(download) = worker_rx.recv() {
let mut remaining = download.size;
while remaining > 0 {
let wait = (remaining as u64).min(10);
thread::sleep(Duration::from_millis(wait * 10));
remaining = remaining.saturating_sub(10);
let progress = (download.size - remaining) * 100 / download.size;
tx.send(Event::DownloadUpdate(id, download.id, progress as f64))
.unwrap();
}
tx.send(Event::DownloadDone(id, download.id)).unwrap();
}
});
Worker { id, tx: worker_tx }
})
.collect()
}
fn downloads() -> Downloads {
let distribution = Uniform::new(0, 1000);
let mut rng = rand::thread_rng();
let pending = (0..NUM_DOWNLOADS)
.map(|id| {
let size = distribution.sample(&mut rng);
Download { id, size }
})
.collect();
Downloads {
pending,
in_progress: BTreeMap::new(),
}
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
workers: Vec<Worker>,
mut downloads: Downloads,
rx: mpsc::Receiver<Event>,
) -> Result<(), Box<dyn Error>> {
let mut redraw = true;
loop {
if redraw {
terminal.draw(|f| ui(f, &downloads))?;
}
redraw = true;
match rx.recv()? {
Event::Input(event) => {
if event.code == crossterm::event::KeyCode::Char('q') {
break;
}
}
Event::Resize => {
terminal.autoresize()?;
}
Event::Tick => {}
Event::DownloadUpdate(worker_id, _download_id, progress) => {
let download = downloads.in_progress.get_mut(&worker_id).unwrap();
download.progress = progress;
redraw = false
}
Event::DownloadDone(worker_id, download_id) => {
let download = downloads.in_progress.remove(&worker_id).unwrap();
terminal.insert_before(1, |buf| {
Paragraph::new(Line::from(vec![
Span::from("Finished "),
Span::styled(
format!("download {download_id}"),
Style::default().add_modifier(Modifier::BOLD),
),
Span::from(format!(
" in {}ms",
download.started_at.elapsed().as_millis()
)),
]))
.render(buf.area, buf);
})?;
match downloads.next(worker_id) {
Some(d) => workers[worker_id].tx.send(d).unwrap(),
None => {
if downloads.in_progress.is_empty() {
terminal.insert_before(1, |buf| {
Paragraph::new("Done !").render(buf.area, buf);
})?;
break;
}
}
};
}
};
}
Ok(())
}
fn ui<B: Backend>(f: &mut Frame<B>, downloads: &Downloads) {
let size = f.size();
let block = Block::default().title(Title::from("Progress").alignment(Alignment::Center));
f.render_widget(block, size);
let chunks = Layout::default()
.constraints(vec![Constraint::Length(2), Constraint::Length(4)])
.margin(1)
.split(size);
// total progress
let done = NUM_DOWNLOADS - downloads.pending.len() - downloads.in_progress.len();
let progress = LineGauge::default()
.gauge_style(Style::default().fg(Color::Blue))
.label(format!("{done}/{NUM_DOWNLOADS}"))
.ratio(done as f64 / NUM_DOWNLOADS as f64);
f.render_widget(progress, chunks[0]);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Percentage(20), Constraint::Percentage(80)])
.split(chunks[1]);
// in progress downloads
let items: Vec<ListItem> = downloads
.in_progress
.values()
.map(|download| {
ListItem::new(Line::from(vec![
Span::raw(symbols::DOT),
Span::styled(
format!(" download {:>2}", download.id),
Style::default()
.fg(Color::LightGreen)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(
" ({}ms)",
download.started_at.elapsed().as_millis()
)),
]))
})
.collect();
let list = List::new(items);
f.render_widget(list, chunks[0]);
for (i, (_, download)) in downloads.in_progress.iter().enumerate() {
let gauge = Gauge::default()
.gauge_style(Style::default().fg(Color::Yellow))
.ratio(download.progress / 100.0);
if chunks[1].top().saturating_add(i as u16) > size.bottom() {
continue;
}
f.render_widget(
gauge,
Rect {
x: chunks[1].left(),
y: chunks[1].top().saturating_add(i as u16),
width: chunks[1].width,
height: 1,
},
);
}
}

View File

@@ -1,10 +1,11 @@
use std::{error::Error, io};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{error::Error, io};
use tui::{
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
widgets::{Block, Borders},
@@ -32,7 +33,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())

View File

@@ -1,18 +1,19 @@
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use tui::{
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Corner, Direction, Layout},
style::{Color, Modifier, Style},
text::{Span, Spans},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState},
Frame, Terminal,
};
@@ -63,9 +64,9 @@ impl<T> StatefulList<T> {
}
}
/// 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.
/// 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.
@@ -166,7 +167,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())
@@ -186,12 +187,14 @@ fn run_app<B: Backend>(
.unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Left => app.items.unselect(),
KeyCode::Down => app.items.next(),
KeyCode::Up => app.items.previous(),
_ => {}
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Left => app.items.unselect(),
KeyCode::Down => app.items.next(),
KeyCode::Up => app.items.previous(),
_ => {}
}
}
}
}
@@ -215,12 +218,13 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
.items
.iter()
.map(|i| {
let mut lines = vec![Spans::from(i.0)];
let mut lines = vec![Line::from(i.0)];
for _ in 0..i.1 {
lines.push(Spans::from(Span::styled(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
Style::default().add_modifier(Modifier::ITALIC),
)));
lines.push(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
.italic()
.into(),
);
}
ListItem::new(lines).style(Style::default().fg(Color::Black).bg(Color::White))
})
@@ -255,16 +259,13 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
_ => 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(" "),
Span::styled(
"2020-01-01 10:00:00",
Style::default().add_modifier(Modifier::ITALIC),
),
let header = Line::from(vec![
Span::styled(format!("{level:<9}"), s),
" ".into(),
"2020-01-01 10:00:00".italic(),
]);
// The event gets its own line
let log = Spans::from(vec![Span::raw(event)]);
let log = Line::from(vec![event.into()]);
// Here several things happen:
// 1. Add a `---` spacing line above the final list entry
@@ -272,9 +273,9 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
// 3. Add a spacer line
// 4. Add the actual event
ListItem::new(vec![
Spans::from("-".repeat(chunks[1].width as usize)),
Line::from("-".repeat(chunks[1].width as usize)),
header,
Spans::from(""),
Line::from(""),
log,
])
})

View File

@@ -17,18 +17,19 @@
#![deny(clippy::all)]
#![warn(clippy::pedantic, clippy::nursery)]
use std::error::Error;
use std::io;
use std::{error::Error, io};
use crossterm::event::{self, Event, KeyCode};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use tui::backend::{Backend, CrosstermBackend};
use tui::layout::Alignment;
use tui::text::Spans;
use tui::widgets::{Block, Borders, Paragraph};
use tui::{Frame, Terminal};
use crossterm::{
event::{self, Event, KeyCode},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::Alignment,
text::Line,
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
type Result<T> = std::result::Result<T, Box<dyn Error>>;
@@ -59,7 +60,7 @@ fn main() -> Result<()> {
reset_terminal()?;
if let Err(err) = res {
println!("{:?}", err);
println!("{err:?}");
}
Ok(())
@@ -113,23 +114,23 @@ fn run_tui<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<
fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
let text = vec![
if app.hook_enabled {
Spans::from("HOOK IS CURRENTLY **ENABLED**")
Line::from("HOOK IS CURRENTLY **ENABLED**")
} else {
Spans::from("HOOK IS CURRENTLY **DISABLED**")
Line::from("HOOK IS CURRENTLY **DISABLED**")
},
Spans::from(""),
Spans::from("press `p` to panic"),
Spans::from("press `e` to enable the terminal-resetting panic hook"),
Spans::from("press any other key to quit without panic"),
Spans::from(""),
Spans::from("when you panic without the chained hook,"),
Spans::from("you will likely have to reset your terminal afterwards"),
Spans::from("with the `reset` command"),
Spans::from(""),
Spans::from("with the chained panic hook enabled,"),
Spans::from("you should see the panic report as you would without tui"),
Spans::from(""),
Spans::from("try first without the panic handler to see the difference"),
Line::from(""),
Line::from("press `p` to panic"),
Line::from("press `e` to enable the terminal-resetting panic hook"),
Line::from("press any other key to quit without panic"),
Line::from(""),
Line::from("when you panic without the chained hook,"),
Line::from("you will likely have to reset your terminal afterwards"),
Line::from("with the `reset` command"),
Line::from(""),
Line::from("with the chained panic hook enabled,"),
Line::from("you should see the panic report as you would without ratatui"),
Line::from(""),
Line::from("try first without the panic handler to see the difference"),
];
let b = Block::default()

View File

@@ -1,18 +1,19 @@
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use tui::{
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Span, Spans},
style::{Color, Modifier, Style, Stylize},
text::{Line, Masked, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
@@ -55,7 +56,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())
@@ -95,12 +96,12 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
let mut long_line = s.repeat(usize::from(size.width) / s.len() + 4);
long_line.push('\n');
let block = Block::default().style(Style::default().bg(Color::White).fg(Color::Black));
let block = Block::default().black();
f.render_widget(block, size);
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(5)
.margin(2)
.constraints(
[
Constraint::Percentage(25),
@@ -113,59 +114,54 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
.split(size);
let text = vec![
Spans::from("This is a line "),
Spans::from(Span::styled(
"This is a line ",
Style::default().fg(Color::Red),
)),
Spans::from(Span::styled(
"This is a line",
Style::default().bg(Color::Blue),
)),
Spans::from(Span::styled(
"This is a longer line",
Style::default().add_modifier(Modifier::CROSSED_OUT),
)),
Spans::from(Span::styled(&long_line, Style::default().bg(Color::Green))),
Spans::from(Span::styled(
"This is a line",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::ITALIC),
)),
Line::from("This is a line "),
Line::from("This is a line ".red()),
Line::from("This is a line".on_blue()),
Line::from("This is a longer line".crossed_out()),
Line::from(long_line.on_green()),
Line::from("This is a line".green().italic()),
Line::from(vec![
"Masked text: ".into(),
Span::styled(
Masked::new("password", '*'),
Style::default().fg(Color::Red),
),
]),
];
let create_block = |title| {
Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(Color::White).fg(Color::Black))
.style(Style::default().fg(Color::Gray))
.title(Span::styled(
title,
Style::default().add_modifier(Modifier::BOLD),
))
};
let paragraph = Paragraph::new(text.clone())
.style(Style::default().bg(Color::White).fg(Color::Black))
.block(create_block("Left, no wrap"))
.alignment(Alignment::Left);
.style(Style::default().fg(Color::Gray))
.block(create_block("Default alignment (Left), no wrap"));
f.render_widget(paragraph, chunks[0]);
let paragraph = Paragraph::new(text.clone())
.style(Style::default().bg(Color::White).fg(Color::Black))
.block(create_block("Left, wrap"))
.alignment(Alignment::Left)
.style(Style::default().fg(Color::Gray))
.block(create_block("Default alignment (Left), with wrap"))
.wrap(Wrap { trim: true });
f.render_widget(paragraph, chunks[1]);
let paragraph = Paragraph::new(text.clone())
.style(Style::default().bg(Color::White).fg(Color::Black))
.block(create_block("Center, wrap"))
.style(Style::default().fg(Color::Gray))
.block(create_block("Right alignment, with wrap"))
.alignment(Alignment::Right)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, chunks[2]);
let paragraph = Paragraph::new(text)
.style(Style::default().fg(Color::Gray))
.block(create_block("Center alignment, with wrap, with scroll"))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true })
.scroll((app.scroll, 0));
f.render_widget(paragraph, chunks[2]);
let paragraph = Paragraph::new(text)
.style(Style::default().bg(Color::White).fg(Color::Black))
.block(create_block("Right, wrap"))
.alignment(Alignment::Right)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, chunks[3]);
}

View File

@@ -1,18 +1,17 @@
use std::{error::Error, io};
use tui::{
backend::{Backend, CrosstermBackend},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Clear, Paragraph, Wrap},
Frame, Terminal,
};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::Stylize,
widgets::{Block, Borders, Clear, Paragraph, Wrap},
Frame, Terminal,
};
struct App {
show_popup: bool,
@@ -46,7 +45,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())
@@ -57,10 +56,12 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<(
terminal.draw(|f| ui(f, &app))?;
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('p') => app.show_popup = !app.show_popup,
_ => {}
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('p') => app.show_popup = !app.show_popup,
_ => {}
}
}
}
}
@@ -78,18 +79,15 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
} else {
"Press p to show the popup"
};
let paragraph = Paragraph::new(Span::styled(
text,
Style::default().add_modifier(Modifier::SLOW_BLINK),
))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
let paragraph = Paragraph::new(text.slow_blink())
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, chunks[0]);
let block = Block::default()
.title("Content")
.borders(Borders::ALL)
.style(Style::default().bg(Color::Blue));
.on_blue();
f.render_widget(block, chunks[1]);
if app.show_popup {

255
examples/scrollbar.rs Normal file
View File

@@ -0,0 +1,255 @@
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Alignment, Constraint, Direction, Layout, Margin},
style::{Color, Modifier, Style, Stylize},
text::{Line, Masked, Span},
widgets::{
scrollbar, Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
},
Frame, Terminal,
};
#[derive(Default)]
struct App {
pub vertical_scroll_state: ScrollbarState,
pub horizontal_scroll_state: ScrollbarState,
pub vertical_scroll: usize,
pub horizontal_scroll: usize,
}
fn main() -> Result<(), Box<dyn Error>> {
// setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// create app and run it
let tick_rate = Duration::from_millis(250);
let app = App::default();
let res = run_app(&mut terminal, app, tick_rate);
// restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
Ok(())
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut app: App,
tick_rate: Duration,
) -> io::Result<()> {
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| ui(f, &mut app))?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('j') => {
app.vertical_scroll = app.vertical_scroll.saturating_add(1);
app.vertical_scroll_state = app
.vertical_scroll_state
.position(app.vertical_scroll as u16);
}
KeyCode::Char('k') => {
app.vertical_scroll = app.vertical_scroll.saturating_sub(1);
app.vertical_scroll_state = app
.vertical_scroll_state
.position(app.vertical_scroll as u16);
}
KeyCode::Char('h') => {
app.horizontal_scroll = app.horizontal_scroll.saturating_sub(1);
app.horizontal_scroll_state = app
.horizontal_scroll_state
.position(app.horizontal_scroll as u16);
}
KeyCode::Char('l') => {
app.horizontal_scroll = app.horizontal_scroll.saturating_add(1);
app.horizontal_scroll_state = app
.horizontal_scroll_state
.position(app.horizontal_scroll as u16);
}
_ => {}
}
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
}
}
fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
let size = f.size();
// Words made "loooong" to demonstrate line breaking.
let s = "Veeeeeeeeeeeeeeeery loooooooooooooooooong striiiiiiiiiiiiiiiiiiiiiiiiiing. ";
let mut long_line = s.repeat(usize::from(size.width) / s.len() + 4);
long_line.push('\n');
let block = Block::default().black();
f.render_widget(block, size);
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints(
[
Constraint::Min(1),
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
]
.as_ref(),
)
.split(size);
let text = vec![
Line::from("This is a line "),
Line::from("This is a line ".red()),
Line::from("This is a line".on_dark_gray()),
Line::from("This is a longer line".crossed_out()),
Line::from(long_line.reset()),
Line::from("This is a line".reset()),
Line::from(vec![
Span::raw("Masked text: "),
Span::styled(
Masked::new("password", '*'),
Style::default().fg(Color::Red),
),
]),
Line::from("This is a line "),
Line::from("This is a line ".red()),
Line::from("This is a line".on_dark_gray()),
Line::from("This is a longer line".crossed_out()),
Line::from(long_line.reset()),
Line::from("This is a line".reset()),
Line::from(vec![
Span::raw("Masked text: "),
Span::styled(
Masked::new("password", '*'),
Style::default().fg(Color::Red),
),
]),
];
app.vertical_scroll_state = app.vertical_scroll_state.content_length(text.len() as u16);
app.horizontal_scroll_state = app
.horizontal_scroll_state
.content_length(long_line.len() as u16);
let create_block = |title| {
Block::default()
.borders(Borders::ALL)
.gray()
.title(Span::styled(
title,
Style::default().add_modifier(Modifier::BOLD),
))
};
let title = Block::default()
.title("Use h j k l to scroll ◄ ▲ ▼ ►")
.title_alignment(Alignment::Center);
f.render_widget(title, chunks[0]);
let paragraph = Paragraph::new(text.clone())
.gray()
.block(create_block("Vertical scrollbar with arrows"))
.scroll((app.vertical_scroll as u16, 0));
f.render_widget(paragraph, chunks[1]);
f.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some(""))
.end_symbol(Some("")),
chunks[1],
&mut app.vertical_scroll_state,
);
let paragraph = Paragraph::new(text.clone())
.gray()
.block(create_block(
"Vertical scrollbar without arrows and mirrored",
))
.scroll((app.vertical_scroll as u16, 0));
f.render_widget(paragraph, chunks[2]);
f.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalLeft)
.symbols(scrollbar::VERTICAL)
.begin_symbol(None)
.end_symbol(None),
chunks[2].inner(&Margin {
vertical: 1,
horizontal: 0,
}),
&mut app.vertical_scroll_state,
);
let paragraph = Paragraph::new(text.clone())
.gray()
.block(create_block(
"Horizontal scrollbar with only begin arrow & custom thumb symbol",
))
.scroll((0, app.horizontal_scroll as u16));
f.render_widget(paragraph, chunks[3]);
f.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::HorizontalBottom)
.thumb_symbol("🬋")
.end_symbol(None),
chunks[3].inner(&Margin {
vertical: 0,
horizontal: 1,
}),
&mut app.horizontal_scroll_state,
);
let paragraph = Paragraph::new(text.clone())
.gray()
.block(create_block(
"Horizontal scrollbar without arrows & custom thumb and track symbol",
))
.scroll((0, app.horizontal_scroll as u16));
f.render_widget(paragraph, chunks[4]);
f.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::HorizontalBottom)
.thumb_symbol("")
.track_symbol(""),
chunks[4].inner(&Margin {
vertical: 0,
horizontal: 1,
}),
&mut app.horizontal_scroll_state,
);
}

View File

@@ -1,3 +1,9 @@
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
@@ -7,12 +13,7 @@ use rand::{
distributions::{Distribution, Uniform},
rngs::ThreadRng,
};
use std::{
error::Error,
io,
time::{Duration, Instant},
};
use tui::{
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Style},
@@ -99,7 +100,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())

View File

@@ -1,10 +1,11 @@
use std::{error::Error, io};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{error::Error, io};
use tui::{
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Layout},
style::{Color, Modifier, Style},
@@ -95,7 +96,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())
@@ -106,11 +107,13 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<(
terminal.draw(|f| ui(f, &mut app))?;
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Down => app.next(),
KeyCode::Up => app.previous(),
_ => {}
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Down => app.next(),
KeyCode::Up => app.previous(),
_ => {}
}
}
}
}

View File

@@ -1,14 +1,15 @@
use std::{error::Error, io};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{error::Error, io};
use tui::{
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Span, Spans},
style::{Color, Modifier, Style, Stylize},
text::Line,
widgets::{Block, Borders, Tabs},
Frame, Terminal,
};
@@ -61,7 +62,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())
@@ -72,11 +73,13 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<(
terminal.draw(|f| ui(f, &app))?;
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Right => app.next(),
KeyCode::Left => app.previous(),
_ => {}
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Right => app.next(),
KeyCode::Left => app.previous(),
_ => {}
}
}
}
}
@@ -90,17 +93,14 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
.split(size);
let block = Block::default().style(Style::default().bg(Color::White).fg(Color::Black));
let block = Block::default().on_white().black();
f.render_widget(block, size);
let titles = app
.titles
.iter()
.map(|t| {
let (first, rest) = t.split_at(1);
Spans::from(vec![
Span::styled(first, Style::default().fg(Color::Yellow)),
Span::styled(rest, Style::default().fg(Color::Green)),
])
Line::from(vec![first.yellow(), rest.green()])
})
.collect();
let tabs = Tabs::new(titles)

View File

@@ -1,3 +1,5 @@
use std::{error::Error, io};
/// A simple example demonstrating how to handle user input. This is
/// a bit out of the scope of the library as it does not provide any
/// input handling out of the box. However, it may helps some to get
@@ -10,16 +12,15 @@
/// * Pressing Enter pushes the current input in the history of previous
/// messages
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{error::Error, io};
use tui::{
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Span, Spans, Text},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame, Terminal,
};
@@ -72,7 +73,7 @@ fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
println!("{err:?}");
}
Ok(())
@@ -93,7 +94,7 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<(
}
_ => {}
},
InputMode::Editing => match key.code {
InputMode::Editing if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Enter => {
app.messages.push(app.input.drain(..).collect());
}
@@ -108,6 +109,7 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<(
}
_ => {}
},
_ => {}
}
}
}
@@ -130,31 +132,31 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
let (msg, style) = match app.input_mode {
InputMode::Normal => (
vec![
Span::raw("Press "),
Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to exit, "),
Span::styled("e", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to start editing."),
"Press ".into(),
"q".bold(),
" to exist, ".into(),
"e".bold(),
" to start editing.".bold(),
],
Style::default().add_modifier(Modifier::RAPID_BLINK),
),
InputMode::Editing => (
vec![
Span::raw("Press "),
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to stop editing, "),
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to record the message"),
"Press ".into(),
"Esc".bold(),
" to stop editing, ".into(),
"Enter".bold(),
" to record the message".into(),
],
Style::default(),
),
};
let mut text = Text::from(Spans::from(msg));
let mut text = Text::from(Line::from(msg));
text.patch_style(style);
let help_message = Paragraph::new(text);
f.render_widget(help_message, chunks[0]);
let input = Paragraph::new(app.input.as_ref())
let input = Paragraph::new(app.input.as_str())
.style(match app.input_mode {
InputMode::Normal => Style::default(),
InputMode::Editing => Style::default().fg(Color::Yellow),
@@ -167,7 +169,8 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
{}
InputMode::Editing => {
// Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering
// Make the cursor visible and ask ratatui to put it at the specified coordinates after
// rendering
f.set_cursor(
// Put cursor past the end of the input text
chunks[1].x + app.input.width() as u16 + 1,
@@ -182,7 +185,7 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
.iter()
.enumerate()
.map(|(i, m)| {
let content = vec![Spans::from(Span::raw(format!("{}: {}", i, m)))];
let content = Line::from(Span::raw(format!("{i}: {m}")));
ListItem::new(content)
})
.collect();

5
rustfmt.toml Normal file
View File

@@ -0,0 +1,5 @@
# configuration for https://rust-lang.github.io/rustfmt/
group_imports = "StdExternalCrate"
imports_granularity = "Crate"
wrap_comments = true
comment_width = 100

View File

@@ -1,9 +1,15 @@
use crate::{
backend::Backend,
buffer::Cell,
layout::Rect,
style::{Color, Modifier},
//! This module provides the `CrosstermBackend` implementation for the `Backend` trait.
//! It uses the `crossterm` crate to interact with the terminal.
//!
//!
//! [`Backend`]: trait.Backend.html
//! [`CrosstermBackend`]: struct.CrosstermBackend.html
use std::{
io::{self, Write},
ops::{Deref, DerefMut},
};
use crossterm::{
cursor::{Hide, MoveTo, Show},
execute, queue,
@@ -11,10 +17,34 @@ use crossterm::{
Attribute as CAttribute, Color as CColor, Print, SetAttribute, SetBackgroundColor,
SetForegroundColor,
},
terminal::{self, Clear, ClearType},
terminal::{self, Clear},
};
use std::io::{self, Write};
use crate::{
backend::{Backend, ClearType},
buffer::Cell,
layout::Rect,
style::{Color, Modifier},
};
/// A backend implementation using the `crossterm` crate.
///
/// The `CrosstermBackend` struct is a wrapper around a type implementing `Write`, which
/// is used to send commands to the terminal. It provides methods for drawing content,
/// manipulating the cursor, and clearing the terminal screen.
///
/// # Example
///
/// ```rust
/// use ratatui::backend::{Backend, CrosstermBackend};
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let buffer = std::io::stdout();
/// let mut backend = CrosstermBackend::new(buffer);
/// backend.clear()?;
/// # Ok(())
/// # }
/// ```
pub struct CrosstermBackend<W: Write> {
buffer: W,
}
@@ -23,6 +53,7 @@ impl<W> CrosstermBackend<W>
where
W: Write,
{
/// Creates a new `CrosstermBackend` with the given buffer.
pub fn new(buffer: W) -> CrosstermBackend<W> {
CrosstermBackend { buffer }
}
@@ -32,15 +63,31 @@ impl<W> Write for CrosstermBackend<W>
where
W: Write,
{
/// Writes a buffer of bytes to the underlying buffer.
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.buffer.write(buf)
}
/// Flushes the underlying buffer.
fn flush(&mut self) -> io::Result<()> {
self.buffer.flush()
}
}
impl<W: Write> Deref for CrosstermBackend<W> {
type Target = W;
fn deref(&self) -> &Self::Target {
&self.buffer
}
}
impl<W: Write> DerefMut for CrosstermBackend<W> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.buffer
}
}
impl<W> Backend for CrosstermBackend<W>
where
W: Write,
@@ -107,7 +154,27 @@ where
}
fn clear(&mut self) -> io::Result<()> {
map_error(execute!(self.buffer, Clear(ClearType::All)))
self.clear_region(ClearType::All)
}
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
map_error(execute!(
self.buffer,
Clear(match clear_type {
ClearType::All => crossterm::terminal::ClearType::All,
ClearType::AfterCursor => crossterm::terminal::ClearType::FromCursorDown,
ClearType::BeforeCursor => crossterm::terminal::ClearType::FromCursorUp,
ClearType::CurrentLine => crossterm::terminal::ClearType::CurrentLine,
ClearType::UntilNewLine => crossterm::terminal::ClearType::UntilNewLine,
})
))
}
fn append_lines(&mut self, n: u16) -> io::Result<()> {
for _ in 0..n {
map_error(queue!(self.buffer, Print("\n")))?;
}
self.buffer.flush()
}
fn size(&self) -> io::Result<Rect> {
@@ -152,6 +219,9 @@ impl From<Color> for CColor {
}
}
/// The `ModifierDiff` struct is used to calculate the difference between two `Modifier`
/// values. This is useful when updating the terminal display, as it allows for more
/// efficient updates by only sending the necessary changes.
#[derive(Debug)]
struct ModifierDiff {
pub from: Modifier,

View File

@@ -1,7 +1,33 @@
//! This module provides the backend implementations for different terminal libraries.
//! It defines the [`Backend`] trait which is used to abstract over the specific
//! terminal library being used.
//!
//! The following terminal libraries are supported:
//! - Crossterm (with the `crossterm` feature)
//! - Termion (with the `termion` feature)
//! - Termwiz (with the `termwiz` feature)
//!
//! Additionally, a [`TestBackend`] is provided for testing purposes.
//!
//! # Example
//!
//! ```rust
//! use ratatui::backend::{Backend, CrosstermBackend};
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let buffer = std::io::stdout();
//! let mut backend = CrosstermBackend::new(buffer);
//! backend.clear()?;
//! # Ok(())
//! # }
//! ```
//!
//! [`Backend`]: trait.Backend.html
//! [`TestBackend`]: struct.TestBackend.html
use std::io;
use crate::buffer::Cell;
use crate::layout::Rect;
use crate::{buffer::Cell, layout::Rect};
#[cfg(feature = "termion")]
mod termion;
@@ -13,18 +39,79 @@ mod crossterm;
#[cfg(feature = "crossterm")]
pub use self::crossterm::CrosstermBackend;
#[cfg(feature = "termwiz")]
mod termwiz;
#[cfg(feature = "termwiz")]
pub use self::termwiz::TermwizBackend;
mod test;
pub use self::test::TestBackend;
/// Enum representing the different types of clearing operations that can be performed
/// on the terminal screen.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClearType {
All,
AfterCursor,
BeforeCursor,
CurrentLine,
UntilNewLine,
}
/// The `Backend` trait provides an abstraction over different terminal libraries.
/// It defines the methods required to draw content, manipulate the cursor, and
/// clear the terminal screen.
pub trait Backend {
/// Draw the given content to the terminal screen.
///
/// The content is provided as an iterator over `(u16, u16, &Cell)` tuples,
/// where the first two elements represent the x and y coordinates, and the
/// third element is a reference to the [`Cell`] to be drawn.
fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error>
where
I: Iterator<Item = (u16, u16, &'a Cell)>;
/// Insert `n` line breaks to the terminal screen.
///
/// This method is optional and may not be implemented by all backends.
fn append_lines(&mut self, _n: u16) -> io::Result<()> {
Ok(())
}
/// Hide the cursor on the terminal screen.
fn hide_cursor(&mut self) -> Result<(), io::Error>;
/// Show the cursor on the terminal screen.
fn show_cursor(&mut self) -> Result<(), io::Error>;
/// Get the current cursor position on the terminal screen.
fn get_cursor(&mut self) -> Result<(u16, u16), io::Error>;
/// Set the cursor position on the terminal screen to the given x and y coordinates.
fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error>;
/// Clears the whole terminal screen
fn clear(&mut self) -> Result<(), io::Error>;
/// Clears a specific region of the terminal specified by the [`ClearType`] parameter
///
/// This method is optional and may not be implemented by all backends.
fn clear_region(&mut self, clear_type: ClearType) -> Result<(), io::Error> {
match clear_type {
ClearType::All => self.clear(),
ClearType::AfterCursor
| ClearType::BeforeCursor
| ClearType::CurrentLine
| ClearType::UntilNewLine => Err(io::Error::new(
io::ErrorKind::Other,
format!("clear_type [{clear_type:?}] not supported with this backend"),
)),
}
}
/// Get the size of the terminal screen as a [`Rect`].
fn size(&self) -> Result<Rect, io::Error>;
/// Flush any buffered content to the terminal screen.
fn flush(&mut self) -> Result<(), io::Error>;
}

View File

@@ -1,14 +1,37 @@
use super::Backend;
//! This module provides the `TermionBackend` implementation for the [`Backend`] trait.
//! It uses the Termion crate to interact with the terminal.
//!
//! [`Backend`]: crate::backend::Backend
//! [`TermionBackend`]: crate::backend::TermionBackend
use std::{
fmt,
io::{self, Write},
ops::{Deref, DerefMut},
};
use crate::{
backend::{Backend, ClearType},
buffer::Cell,
layout::Rect,
style::{Color, Modifier},
};
use std::{
fmt,
io::{self, Write},
};
/// A backend that uses the Termion library to draw content, manipulate the cursor,
/// and clear the terminal screen.
///
/// # Example
///
/// ```rust
/// use ratatui::backend::{Backend, TermionBackend};
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let stdout = std::io::stdout();
/// let mut backend = TermionBackend::new(stdout);
/// backend.clear()?;
/// # Ok(())
/// # }
/// ```
pub struct TermionBackend<W>
where
W: Write,
@@ -20,6 +43,7 @@ impl<W> TermionBackend<W>
where
W: Write,
{
/// Creates a new Termion backend with the given output.
pub fn new(stdout: W) -> TermionBackend<W> {
TermionBackend { stdout }
}
@@ -38,35 +62,60 @@ where
}
}
impl<W: Write> Deref for TermionBackend<W> {
type Target = W;
fn deref(&self) -> &Self::Target {
&self.stdout
}
}
impl<W: Write> DerefMut for TermionBackend<W> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.stdout
}
}
impl<W> Backend for TermionBackend<W>
where
W: Write,
{
/// Clears the entire screen and move the cursor to the top left of the screen
fn clear(&mut self) -> io::Result<()> {
write!(self.stdout, "{}", termion::clear::All)?;
write!(self.stdout, "{}", termion::cursor::Goto(1, 1))?;
self.clear_region(ClearType::All)
}
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
match clear_type {
ClearType::All => write!(self.stdout, "{}", termion::clear::All)?,
ClearType::AfterCursor => write!(self.stdout, "{}", termion::clear::AfterCursor)?,
ClearType::BeforeCursor => write!(self.stdout, "{}", termion::clear::BeforeCursor)?,
ClearType::CurrentLine => write!(self.stdout, "{}", termion::clear::CurrentLine)?,
ClearType::UntilNewLine => write!(self.stdout, "{}", termion::clear::UntilNewline)?,
};
self.stdout.flush()
}
fn append_lines(&mut self, n: u16) -> io::Result<()> {
for _ in 0..n {
writeln!(self.stdout)?;
}
self.stdout.flush()
}
/// Hides cursor
fn hide_cursor(&mut self) -> io::Result<()> {
write!(self.stdout, "{}", termion::cursor::Hide)?;
self.stdout.flush()
}
/// Shows cursor
fn show_cursor(&mut self) -> io::Result<()> {
write!(self.stdout, "{}", termion::cursor::Show)?;
self.stdout.flush()
}
/// Gets cursor position (0-based index)
fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
termion::cursor::DetectCursorPos::cursor_pos(&mut self.stdout).map(|(x, y)| (x - 1, y - 1))
}
/// Sets cursor position (0-based index)
fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
write!(self.stdout, "{}", termion::cursor::Goto(x + 1, y + 1))?;
self.stdout.flush()
@@ -113,15 +162,13 @@ where
}
write!(
self.stdout,
"{}{}{}{}",
string,
"{string}{}{}{}",
Fg(Color::Reset),
Bg(Color::Reset),
termion::style::Reset,
)
}
/// Return the size of the terminal
fn size(&self) -> io::Result<Rect> {
let terminal = termion::terminal_size()?;
Ok(Rect::new(0, 0, terminal.0, terminal.1))
@@ -136,6 +183,9 @@ struct Fg(Color);
struct Bg(Color);
/// The `ModifierDiff` struct is used to calculate the difference between two `Modifier`
/// values. This is useful when updating the terminal display, as it allows for more
/// efficient updates by only sending the necessary changes.
struct ModifierDiff {
from: Modifier,
to: Modifier,

223
src/backend/termwiz.rs Normal file
View File

@@ -0,0 +1,223 @@
//! This module provides the `TermwizBackend` implementation for the [`Backend`] trait.
//! It uses the `termwiz` crate to interact with the terminal.
//!
//! [`Backend`]: trait.Backend.html
//! [`TermwizBackend`]: crate::backend::TermionBackend
use std::{error::Error, io};
use termwiz::{
caps::Capabilities,
cell::{AttributeChange, Blink, Intensity, Underline},
color::{AnsiColor, ColorAttribute, SrgbaTuple},
surface::{Change, CursorVisibility, Position},
terminal::{buffered::BufferedTerminal, SystemTerminal, Terminal},
};
use crate::{
backend::Backend,
buffer::Cell,
layout::Rect,
style::{Color, Modifier},
};
/// Termwiz backend implementation for the [`Backend`] trait.
/// # Example
///
/// ```rust
/// use ratatui::backend::{Backend, TermwizBackend};
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let mut backend = TermwizBackend::new()?;
/// backend.clear()?;
/// # Ok(())
/// # }
/// ```
pub struct TermwizBackend {
buffered_terminal: BufferedTerminal<SystemTerminal>,
}
impl TermwizBackend {
/// Creates a new Termwiz backend instance.
pub fn new() -> Result<TermwizBackend, Box<dyn Error>> {
let mut buffered_terminal =
BufferedTerminal::new(SystemTerminal::new(Capabilities::new_from_env()?)?)?;
buffered_terminal.terminal().set_raw_mode()?;
buffered_terminal.terminal().enter_alternate_screen()?;
Ok(TermwizBackend { buffered_terminal })
}
/// Creates a new Termwiz backend instance with the given buffered terminal.
pub fn with_buffered_terminal(instance: BufferedTerminal<SystemTerminal>) -> TermwizBackend {
TermwizBackend {
buffered_terminal: instance,
}
}
/// Returns a reference to the buffered terminal used by the backend.
pub fn buffered_terminal(&self) -> &BufferedTerminal<SystemTerminal> {
&self.buffered_terminal
}
/// Returns a mutable reference to the buffered terminal used by the backend.
pub fn buffered_terminal_mut(&mut self) -> &mut BufferedTerminal<SystemTerminal> {
&mut self.buffered_terminal
}
}
impl Backend for TermwizBackend {
fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
for (x, y, cell) in content {
self.buffered_terminal.add_changes(vec![
Change::CursorPosition {
x: Position::Absolute(x as usize),
y: Position::Absolute(y as usize),
},
Change::Attribute(AttributeChange::Foreground(cell.fg.into())),
Change::Attribute(AttributeChange::Background(cell.bg.into())),
]);
self.buffered_terminal
.add_change(Change::Attribute(AttributeChange::Intensity(
if cell.modifier.contains(Modifier::BOLD) {
Intensity::Bold
} else if cell.modifier.contains(Modifier::DIM) {
Intensity::Half
} else {
Intensity::Normal
},
)));
self.buffered_terminal
.add_change(Change::Attribute(AttributeChange::Italic(
cell.modifier.contains(Modifier::ITALIC),
)));
self.buffered_terminal
.add_change(Change::Attribute(AttributeChange::Underline(
if cell.modifier.contains(Modifier::UNDERLINED) {
Underline::Single
} else {
Underline::None
},
)));
self.buffered_terminal
.add_change(Change::Attribute(AttributeChange::Reverse(
cell.modifier.contains(Modifier::REVERSED),
)));
self.buffered_terminal
.add_change(Change::Attribute(AttributeChange::Invisible(
cell.modifier.contains(Modifier::HIDDEN),
)));
self.buffered_terminal
.add_change(Change::Attribute(AttributeChange::StrikeThrough(
cell.modifier.contains(Modifier::CROSSED_OUT),
)));
self.buffered_terminal
.add_change(Change::Attribute(AttributeChange::Blink(
if cell.modifier.contains(Modifier::SLOW_BLINK) {
Blink::Slow
} else if cell.modifier.contains(Modifier::RAPID_BLINK) {
Blink::Rapid
} else {
Blink::None
},
)));
self.buffered_terminal.add_change(&cell.symbol);
}
Ok(())
}
fn hide_cursor(&mut self) -> Result<(), io::Error> {
self.buffered_terminal
.add_change(Change::CursorVisibility(CursorVisibility::Hidden));
Ok(())
}
fn show_cursor(&mut self) -> Result<(), io::Error> {
self.buffered_terminal
.add_change(Change::CursorVisibility(CursorVisibility::Visible));
Ok(())
}
fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
let (x, y) = self.buffered_terminal.cursor_position();
Ok((x as u16, y as u16))
}
fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
self.buffered_terminal.add_change(Change::CursorPosition {
x: Position::Absolute(x as usize),
y: Position::Absolute(y as usize),
});
Ok(())
}
fn clear(&mut self) -> Result<(), io::Error> {
self.buffered_terminal
.add_change(Change::ClearScreen(termwiz::color::ColorAttribute::Default));
Ok(())
}
fn size(&self) -> Result<Rect, io::Error> {
let (term_width, term_height) = self.buffered_terminal.dimensions();
let max = u16::max_value();
Ok(Rect::new(
0,
0,
if term_width > usize::from(max) {
max
} else {
term_width as u16
},
if term_height > usize::from(max) {
max
} else {
term_height as u16
},
))
}
fn flush(&mut self) -> Result<(), io::Error> {
self.buffered_terminal
.flush()
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
Ok(())
}
}
impl From<Color> for ColorAttribute {
fn from(color: Color) -> ColorAttribute {
match color {
Color::Reset => ColorAttribute::Default,
Color::Black => AnsiColor::Black.into(),
Color::Gray | Color::DarkGray => AnsiColor::Grey.into(),
Color::Red => AnsiColor::Maroon.into(),
Color::LightRed => AnsiColor::Red.into(),
Color::Green => AnsiColor::Green.into(),
Color::LightGreen => AnsiColor::Lime.into(),
Color::Yellow => AnsiColor::Olive.into(),
Color::LightYellow => AnsiColor::Yellow.into(),
Color::Magenta => AnsiColor::Purple.into(),
Color::LightMagenta => AnsiColor::Fuchsia.into(),
Color::Cyan => AnsiColor::Teal.into(),
Color::LightCyan => AnsiColor::Aqua.into(),
Color::White => AnsiColor::White.into(),
Color::Blue => AnsiColor::Navy.into(),
Color::LightBlue => AnsiColor::Blue.into(),
Color::Indexed(i) => ColorAttribute::PaletteIndex(i),
Color::Rgb(r, g, b) => {
ColorAttribute::TrueColorWithDefaultFallback(SrgbaTuple::from((r, g, b)))
}
}
}
}

View File

@@ -1,12 +1,33 @@
//! This module provides the `TestBackend` implementation for the [`Backend`] trait.
//! It is used in the integration tests to verify the correctness of the library.
use std::{
fmt::{Display, Write},
io,
};
use unicode_width::UnicodeWidthStr;
use crate::{
backend::Backend,
buffer::{Buffer, Cell},
layout::Rect,
};
use std::{fmt::Write, io};
use unicode_width::UnicodeWidthStr;
/// A backend used for the integration tests.
///
/// # Example
///
/// ```rust
/// use ratatui::{backend::{Backend, TestBackend}, buffer::Buffer};
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let mut backend = TestBackend::new(10, 2);
/// backend.clear()?;
/// backend.assert_buffer(&Buffer::with_lines(vec![" "; 2]));
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct TestBackend {
width: u16,
@@ -17,6 +38,11 @@ pub struct TestBackend {
}
/// Returns a string representation of the given buffer for debugging purpose.
///
/// This function is used to visualize the buffer content in a human-readable format.
/// It iterates through the buffer content and appends each cell's symbol to the view string.
/// If a cell is hidden by a multi-width symbol, it is added to the overwritten vector and
/// displayed at the end of the line.
fn buffer_view(buffer: &Buffer) -> String {
let mut view = String::with_capacity(buffer.content.len() + buffer.area.height as usize * 3);
for cells in buffer.content.chunks(buffer.area.width as usize) {
@@ -27,18 +53,13 @@ fn buffer_view(buffer: &Buffer) -> String {
if skip == 0 {
view.push_str(&c.symbol);
} else {
overwritten.push((x, &c.symbol))
overwritten.push((x, &c.symbol));
}
skip = std::cmp::max(skip, c.symbol.width()).saturating_sub(1);
}
view.push('"');
if !overwritten.is_empty() {
write!(
&mut view,
" Hidden by multi-width symbols: {:?}",
overwritten
)
.unwrap();
write!(&mut view, " Hidden by multi-width symbols: {overwritten:?}").unwrap();
}
view.push('\n');
}
@@ -46,6 +67,7 @@ fn buffer_view(buffer: &Buffer) -> String {
}
impl TestBackend {
/// Creates a new TestBackend with the specified width and height.
pub fn new(width: u16, height: u16) -> TestBackend {
TestBackend {
width,
@@ -56,16 +78,21 @@ impl TestBackend {
}
}
/// Returns a reference to the internal buffer of the TestBackend.
pub fn buffer(&self) -> &Buffer {
&self.buffer
}
/// Resizes the TestBackend to the specified width and height.
pub fn resize(&mut self, width: u16, height: u16) {
self.buffer.resize(Rect::new(0, 0, width, height));
self.width = width;
self.height = height;
}
/// Asserts that the TestBackend's buffer is equal to the expected buffer.
/// If the buffers are not equal, a panic occurs with a detailed error message
/// showing the differences between the expected and actual buffers.
pub fn assert_buffer(&self, expected: &Buffer) {
assert_eq!(expected.area, self.buffer.area);
let diff = expected.diff(&self.buffer);
@@ -93,15 +120,20 @@ impl TestBackend {
.enumerate()
.map(|(i, (x, y, cell))| {
let expected_cell = expected.get(*x, *y);
format!(
"{}: at ({}, {}) expected {:?} got {:?}",
i, x, y, expected_cell, cell
)
format!("{i}: at ({x}, {y}) expected {expected_cell:?} got {cell:?}")
})
.collect::<Vec<String>>()
.join("\n");
debug_info.push_str(&nice_diff);
panic!("{}", debug_info);
panic!("{debug_info}");
}
}
impl Display for TestBackend {
/// Formats the TestBackend for display by calling the buffer_view function
/// on its internal buffer.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", buffer_view(&self.buffer))
}
}

View File

@@ -1,14 +1,20 @@
use crate::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Span, Spans},
use std::{
cmp::min,
fmt::{Debug, Formatter, Result},
};
use std::cmp::min;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[allow(deprecated)]
use crate::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span, Spans},
};
/// A buffer cell
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cell {
pub symbol: String,
pub fg: Color,
@@ -88,9 +94,9 @@ impl Default for Cell {
/// # Examples:
///
/// ```
/// use tui::buffer::{Buffer, Cell};
/// use tui::layout::Rect;
/// use tui::style::{Color, Style, Modifier};
/// use ratatui::buffer::{Buffer, Cell};
/// use ratatui::layout::Rect;
/// use ratatui::style::{Color, Style, Modifier};
///
/// let mut buf = Buffer::empty(Rect{x: 0, y: 0, width: 10, height: 5});
/// buf.get_mut(0, 2).set_symbol("x");
@@ -105,7 +111,7 @@ impl Default for Cell {
/// buf.get_mut(5, 0).set_char('x');
/// assert_eq!(buf.get(5, 0).symbol, "x");
/// ```
#[derive(Debug, Clone, PartialEq, Default)]
#[derive(Clone, PartialEq, Eq, Default)]
pub struct Buffer {
/// The area represented by this buffer
pub area: Rect,
@@ -117,7 +123,7 @@ pub struct Buffer {
impl Buffer {
/// Returns a Buffer with all cells set to the default one
pub fn empty(area: Rect) -> Buffer {
let cell: Cell = Default::default();
let cell = Cell::default();
Buffer::filled(area, &cell)
}
@@ -176,15 +182,15 @@ impl Buffer {
&mut self.content[i]
}
/// Returns the index in the Vec<Cell> for the given global (x, y) coordinates.
/// Returns the index in the `Vec<Cell>` for the given global (x, y) coordinates.
///
/// Global coordinates are offset by the Buffer's area offset (`x`/`y`).
///
/// # Examples
///
/// ```
/// # use tui::buffer::Buffer;
/// # use tui::layout::Rect;
/// # use ratatui::buffer::Buffer;
/// # use ratatui::layout::Rect;
/// let rect = Rect::new(200, 100, 10, 10);
/// let buffer = Buffer::empty(rect);
/// // Global coordinates to the top corner of this buffer's area
@@ -196,8 +202,8 @@ impl Buffer {
/// Panics when given an coordinate that is outside of this Buffer's area.
///
/// ```should_panic
/// # use tui::buffer::Buffer;
/// # use tui::layout::Rect;
/// # use ratatui::buffer::Buffer;
/// # use ratatui::layout::Rect;
/// let rect = Rect::new(200, 100, 10, 10);
/// let buffer = Buffer::empty(rect);
/// // Top coordinate is outside of the buffer in global coordinate space, as the Buffer's area
@@ -210,9 +216,7 @@ impl Buffer {
&& x < self.area.right()
&& y >= self.area.top()
&& y < self.area.bottom(),
"Trying to access position outside the buffer: x={}, y={}, area={:?}",
x,
y,
"Trying to access position outside the buffer: x={x}, y={y}, area={:?}",
self.area
);
((y - self.area.y) * self.area.width + (x - self.area.x)) as usize
@@ -225,8 +229,8 @@ impl Buffer {
/// # Examples
///
/// ```
/// # use tui::buffer::Buffer;
/// # use tui::layout::Rect;
/// # use ratatui::buffer::Buffer;
/// # use ratatui::layout::Rect;
/// let rect = Rect::new(200, 100, 10, 10);
/// let buffer = Buffer::empty(rect);
/// assert_eq!(buffer.pos_of(0), (200, 100));
@@ -238,8 +242,8 @@ impl Buffer {
/// Panics when given an index that is outside the Buffer's content.
///
/// ```should_panic
/// # use tui::buffer::Buffer;
/// # use tui::layout::Rect;
/// # use ratatui::buffer::Buffer;
/// # use ratatui::layout::Rect;
/// let rect = Rect::new(0, 0, 10, 10); // 100 cells in total
/// let buffer = Buffer::empty(rect);
/// // Index 100 is the 101th cell, which lies outside of the area of this Buffer.
@@ -248,13 +252,12 @@ impl Buffer {
pub fn pos_of(&self, i: usize) -> (u16, u16) {
debug_assert!(
i < self.content.len(),
"Trying to get the coords of a cell outside the buffer: i={} len={}",
i,
"Trying to get the coords of a cell outside the buffer: i={i} len={}",
self.content.len()
);
(
self.area.x + i as u16 % self.area.width,
self.area.y + i as u16 / self.area.width,
self.area.x + (i as u16) % self.area.width,
self.area.y + (i as u16) / self.area.width,
)
}
@@ -289,7 +292,7 @@ impl Buffer {
continue;
}
// `x_offset + width > max_offset` could be integer overflow on 32-bit machines if we
// change dimenstions to usize or u32 and someone resizes the terminal to 1x2^32.
// change dimensions to usize or u32 and someone resizes the terminal to 1x2^32.
if width > max_offset.saturating_sub(x_offset) {
break;
}
@@ -306,7 +309,9 @@ impl Buffer {
(x_offset as u16, y)
}
pub fn set_spans<'a>(&mut self, x: u16, y: u16, spans: &Spans<'a>, width: u16) -> (u16, u16) {
#[allow(deprecated)]
#[deprecated(note = "Use `Buffer::set_line` instead")]
pub fn set_spans(&mut self, x: u16, y: u16, spans: &Spans<'_>, width: u16) -> (u16, u16) {
let mut remaining_width = width;
let mut x = x;
for span in &spans.0 {
@@ -327,7 +332,28 @@ impl Buffer {
(x, y)
}
pub fn set_span<'a>(&mut self, x: u16, y: u16, span: &Span<'a>, width: u16) -> (u16, u16) {
pub fn set_line(&mut self, x: u16, y: u16, line: &Line<'_>, width: u16) -> (u16, u16) {
let mut remaining_width = width;
let mut x = x;
for span in &line.spans {
if remaining_width == 0 {
break;
}
let pos = self.set_stringn(
x,
y,
span.content.as_ref(),
remaining_width as usize,
span.style,
);
let w = pos.0.saturating_sub(x);
x = pos.0;
remaining_width = remaining_width.saturating_sub(w);
}
(x, y)
}
pub fn set_span(&mut self, x: u16, y: u16, span: &Span<'_>, width: u16) -> (u16, u16) {
self.set_stringn(x, y, span.content.as_ref(), width as usize, span.style)
}
@@ -358,7 +384,7 @@ impl Buffer {
if self.content.len() > length {
self.content.truncate(length);
} else {
self.content.resize(length, Default::default());
self.content.resize(length, Cell::default());
}
self.area = area;
}
@@ -373,7 +399,7 @@ impl Buffer {
/// Merge an other buffer into this one
pub fn merge(&mut self, other: &Buffer) {
let area = self.area.union(other.area);
let cell: Cell = Default::default();
let cell = Cell::default();
self.content.resize(area.area() as usize, cell.clone());
// Move original content to the appropriate space
@@ -431,18 +457,16 @@ impl Buffer {
pub fn diff<'a>(&self, other: &'a Buffer) -> Vec<(u16, u16, &'a Cell)> {
let previous_buffer = &self.content;
let next_buffer = &other.content;
let width = self.area.width;
let mut updates: Vec<(u16, u16, &Cell)> = vec![];
// Cells invalidated by drawing/replacing preceeding multi-width characters:
// Cells invalidated by drawing/replacing preceding multi-width characters:
let mut invalidated: usize = 0;
// Cells from the current buffer to skip due to preceeding multi-width characters taking their
// place (the skipped cells should be blank anyway):
// Cells from the current buffer to skip due to preceding multi-width characters taking
// their place (the skipped cells should be blank anyway):
let mut to_skip: usize = 0;
for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() {
if (current != previous || invalidated > 0) && to_skip == 0 {
let x = i as u16 % width;
let y = i as u16 / width;
let (x, y) = self.pos_of(i);
updates.push((x, y, &next_buffer[i]));
}
@@ -455,6 +479,111 @@ impl Buffer {
}
}
/// Assert that two buffers are equal by comparing their areas and content.
///
/// On panic, displays the areas or the content and a diff of the contents.
#[macro_export]
macro_rules! assert_buffer_eq {
($actual_expr:expr, $expected_expr:expr) => {
match (&$actual_expr, &$expected_expr) {
(actual, expected) => {
if actual.area != expected.area {
panic!(
indoc::indoc!(
"
buffer areas not equal
expected: {:?}
actual: {:?}"
),
expected, actual
);
}
let diff = expected.diff(&actual);
if !diff.is_empty() {
let nice_diff = diff
.iter()
.enumerate()
.map(|(i, (x, y, cell))| {
let expected_cell = expected.get(*x, *y);
indoc::formatdoc! {"
{i}: at ({x}, {y})
expected: {expected_cell:?}
actual: {cell:?}
"}
})
.collect::<Vec<String>>()
.join("\n");
panic!(
indoc::indoc!(
"
buffer contents not equal
expected: {:?}
actual: {:?}
diff:
{}"
),
expected, actual, nice_diff
);
}
// shouldn't get here, but this guards against future behavior
// that changes equality but not area or content
assert_eq!(actual, expected, "buffers not equal");
}
}
};
}
impl Debug for Buffer {
/// Writes a debug representation of the buffer to the given formatter.
///
/// The format is like a pretty printed struct, with the following fields:
/// * `area`: displayed as `Rect { x: 1, y: 2, width: 3, height: 4 }`
/// * `content`: displayed as a list of strings representing the content of the buffer
/// * `styles`: displayed as a list of: `{ x: 1, y: 2, fg: Color::Red, bg: Color::Blue,
/// modifier: Modifier::BOLD }` only showing a value when there is a change in style.
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
f.write_fmt(format_args!(
"Buffer {{\n area: {:?},\n content: [\n",
&self.area
))?;
let mut last_style = None;
let mut styles = vec![];
for (y, line) in self.content.chunks(self.area.width as usize).enumerate() {
let mut overwritten = vec![];
let mut skip: usize = 0;
f.write_str(" \"")?;
for (x, c) in line.iter().enumerate() {
if skip == 0 {
f.write_str(&c.symbol)?;
} else {
overwritten.push((x, &c.symbol));
}
skip = std::cmp::max(skip, c.symbol.width()).saturating_sub(1);
let style = (c.fg, c.bg, c.modifier);
if last_style != Some(style) {
last_style = Some(style);
styles.push((x, y, c.fg, c.bg, c.modifier));
}
}
if !overwritten.is_empty() {
f.write_fmt(format_args!(
"// hidden by multi-width symbols: {overwritten:?}"
))?;
}
f.write_str("\",\n")?;
}
f.write_str(" ],\n styles: [\n")?;
for s in styles {
f.write_fmt(format_args!(
" x: {}, y: {}, fg: {:?}, bg: {:?}, modifier: {:?},\n",
s.0, s.1, s.2, s.3, s.4
))?;
}
f.write_str(" ]\n}")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -465,6 +594,62 @@ mod tests {
cell
}
#[test]
fn it_implements_debug() {
let mut buf = Buffer::empty(Rect::new(0, 0, 12, 2));
buf.set_string(0, 0, "Hello World!", Style::default());
buf.set_string(
0,
1,
"G'day World!",
Style::default()
.fg(Color::Green)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
assert_eq!(
format!("{buf:?}"),
indoc::indoc!(
"
Buffer {
area: Rect { x: 0, y: 0, width: 12, height: 2 },
content: [
\"Hello World!\",
\"G'day World!\",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, modifier: NONE,
x: 0, y: 1, fg: Green, bg: Yellow, modifier: BOLD,
]
}"
)
);
}
#[test]
fn assert_buffer_eq_does_not_panic_on_equal_buffers() {
let buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
let other_buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
assert_buffer_eq!(buffer, other_buffer);
}
#[should_panic]
#[test]
fn assert_buffer_eq_panics_on_unequal_area() {
let buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
let other_buffer = Buffer::empty(Rect::new(0, 0, 6, 1));
assert_buffer_eq!(buffer, other_buffer);
}
#[should_panic]
#[test]
fn assert_buffer_eq_panics_on_unequal_style() {
let buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
let mut other_buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
other_buffer.set_string(0, 0, " ", Style::default().fg(Color::Red));
assert_buffer_eq!(buffer, other_buffer);
}
#[test]
fn it_translates_to_and_from_coordinates() {
let rect = Rect::new(200, 100, 50, 80);
@@ -506,21 +691,38 @@ mod tests {
// Zero-width
buffer.set_stringn(0, 0, "aaa", 0, Style::default());
assert_eq!(buffer, Buffer::with_lines(vec![" "]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" "]));
buffer.set_string(0, 0, "aaa", Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["aaa "]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["aaa "]));
// Width limit:
buffer.set_stringn(0, 0, "bbbbbbbbbbbbbb", 4, Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["bbbb "]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["bbbb "]));
buffer.set_string(0, 0, "12345", Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["12345"]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["12345"]));
// Width truncation:
buffer.set_string(0, 0, "123456", Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["12345"]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["12345"]));
// multi-line
buffer = Buffer::empty(Rect::new(0, 0, 5, 2));
buffer.set_string(0, 0, "12345", Style::default());
buffer.set_string(0, 1, "67890", Style::default());
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["12345", "67890"]));
}
#[test]
fn buffer_set_string_multi_width_overwrite() {
let area = Rect::new(0, 0, 5, 1);
let mut buffer = Buffer::empty(area);
// multi-width overwrite
buffer.set_string(0, 0, "aaaaa", Style::default());
buffer.set_string(0, 0, "称号", Style::default());
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["称号a"]));
}
#[test]
@@ -531,12 +733,12 @@ mod tests {
// Leading grapheme with zero width
let s = "\u{1}a";
buffer.set_stringn(0, 0, s, 1, Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["a"]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["a"]));
// Trailing grapheme with zero with
let s = "a\u{1}";
buffer.set_stringn(0, 0, s, 1, Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["a"]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["a"]));
}
#[test]
@@ -544,11 +746,11 @@ mod tests {
let area = Rect::new(0, 0, 5, 1);
let mut buffer = Buffer::empty(area);
buffer.set_string(0, 0, "コン", Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["コン "]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["コン "]));
// Only 1 space left.
buffer.set_string(0, 0, "コンピ", Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["コン "]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["コン "]));
}
#[test]
@@ -673,7 +875,7 @@ mod tests {
Cell::default().set_symbol("2"),
);
one.merge(&two);
assert_eq!(one, Buffer::with_lines(vec!["11", "11", "22", "22"]));
assert_buffer_eq!(one, Buffer::with_lines(vec!["11", "11", "22", "22"]));
}
#[test]
@@ -697,7 +899,7 @@ mod tests {
Cell::default().set_symbol("2"),
);
one.merge(&two);
assert_eq!(
assert_buffer_eq!(
one,
Buffer::with_lines(vec!["22 ", "22 ", " 11", " 11"])
);
@@ -731,6 +933,6 @@ mod tests {
width: 4,
height: 4,
};
assert_eq!(one, merged);
assert_buffer_eq!(one, merged);
}
}

View File

@@ -1,10 +1,15 @@
use std::cell::RefCell;
use std::cmp::{max, min};
use std::collections::HashMap;
use std::{
cell::RefCell,
cmp::{max, min},
collections::HashMap,
rc::Rc,
};
use cassowary::strength::{REQUIRED, WEAK};
use cassowary::WeightedRelation::*;
use cassowary::{Constraint as CassowaryConstraint, Expression, Solver, Variable};
use cassowary::{
strength::{MEDIUM, REQUIRED, WEAK},
Constraint as CassowaryConstraint, Expression, Solver, Variable,
WeightedRelation::{EQ, GE, LE},
};
#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq)]
pub enum Corner {
@@ -22,7 +27,6 @@ pub enum Direction {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Constraint {
// TODO: enforce range 0 - 100
Percentage(u16),
Ratio(u32, u32),
Length(u16),
@@ -33,10 +37,17 @@ pub enum Constraint {
impl Constraint {
pub fn apply(&self, length: u16) -> u16 {
match *self {
Constraint::Percentage(p) => length * p / 100,
Constraint::Ratio(num, den) => {
let r = num * u32::from(length) / den;
r as u16
Constraint::Percentage(p) => {
let p = p as f32 / 100.0;
let length = length as f32;
(p * length).min(length) as u16
}
Constraint::Ratio(numerator, denominator) => {
// avoid division by zero by using 1 when denominator is 0
// this results in 0/0 -> 0 and x/0 -> x for x != 0
let percentage = numerator as f32 / denominator.max(1) as f32;
let length = length as f32;
(percentage * length).min(length) as u16
}
Constraint::Length(l) => length.min(l),
Constraint::Max(m) => length.min(m),
@@ -51,7 +62,7 @@ pub struct Margin {
pub horizontal: u16,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Alignment {
Left,
Center,
@@ -68,12 +79,19 @@ pub struct Layout {
expand_to_fill: bool,
}
type Cache = HashMap<(Rect, Layout), Rc<[Rect]>>;
thread_local! {
static LAYOUT_CACHE: RefCell<HashMap<(Rect, Layout), Vec<Rect>>> = RefCell::new(HashMap::new());
static LAYOUT_CACHE: RefCell<Cache> = RefCell::new(HashMap::new());
}
impl Default for Layout {
fn default() -> Layout {
Layout::new()
}
}
impl Layout {
pub const fn new() -> Layout {
Layout {
direction: Direction::Vertical,
margin: Margin {
@@ -84,9 +102,7 @@ impl Default for Layout {
expand_to_fill: true,
}
}
}
impl Layout {
pub fn constraints<C>(mut self, constraints: C) -> Layout
where
C: Into<Vec<Constraint>>,
@@ -95,7 +111,7 @@ impl Layout {
self
}
pub fn margin(mut self, margin: u16) -> Layout {
pub const fn margin(mut self, margin: u16) -> Layout {
self.margin = Margin {
horizontal: margin,
vertical: margin,
@@ -103,22 +119,22 @@ impl Layout {
self
}
pub fn horizontal_margin(mut self, horizontal: u16) -> Layout {
pub const fn horizontal_margin(mut self, horizontal: u16) -> Layout {
self.margin.horizontal = horizontal;
self
}
pub fn vertical_margin(mut self, vertical: u16) -> Layout {
pub const fn vertical_margin(mut self, vertical: u16) -> Layout {
self.margin.vertical = vertical;
self
}
pub fn direction(mut self, direction: Direction) -> Layout {
pub const fn direction(mut self, direction: Direction) -> Layout {
self.direction = direction;
self
}
pub(crate) fn expand_to_fill(mut self, expand_to_fill: bool) -> Layout {
pub(crate) const fn expand_to_fill(mut self, expand_to_fill: bool) -> Layout {
self.expand_to_fill = expand_to_fill;
self
}
@@ -128,7 +144,7 @@ impl Layout {
///
/// # Examples
/// ```
/// # use tui::layout::{Rect, Constraint, Direction, Layout};
/// # use ratatui::layout::{Rect, Constraint, Direction, Layout};
/// let chunks = Layout::default()
/// .direction(Direction::Vertical)
/// .constraints([Constraint::Length(5), Constraint::Min(0)].as_ref())
@@ -139,8 +155,8 @@ impl Layout {
/// height: 10,
/// });
/// assert_eq!(
/// chunks,
/// vec![
/// chunks[..],
/// [
/// Rect {
/// x: 2,
/// y: 2,
@@ -166,8 +182,8 @@ impl Layout {
/// height: 2,
/// });
/// assert_eq!(
/// chunks,
/// vec![
/// chunks[..],
/// [
/// Rect {
/// x: 0,
/// y: 0,
@@ -183,7 +199,7 @@ impl Layout {
/// ]
/// );
/// ```
pub fn split(&self, area: Rect) -> Vec<Rect> {
pub fn split(&self, area: Rect) -> Rc<[Rect]> {
// TODO: Maybe use a fixed size cache ?
LAYOUT_CACHE.with(|c| {
c.borrow_mut()
@@ -194,7 +210,7 @@ impl Layout {
}
}
fn split(area: Rect, layout: &Layout) -> Vec<Rect> {
fn split(area: Rect, layout: &Layout) -> Rc<[Rect]> {
let mut solver = Solver::new();
let mut vars: HashMap<Variable, (usize, usize)> = HashMap::new();
let elements = layout
@@ -202,11 +218,13 @@ fn split(area: Rect, layout: &Layout) -> Vec<Rect> {
.iter()
.map(|_| Element::new())
.collect::<Vec<Element>>();
let mut results = layout
let mut res = layout
.constraints
.iter()
.map(|_| Rect::default())
.collect::<Vec<Rect>>();
.collect::<Rc<[Rect]>>();
let results = Rc::get_mut(&mut res).expect("newly created Rc should have no shared refs");
let dest_area = area.inner(&layout.margin);
for (i, e) in elements.iter().enumerate() {
@@ -248,18 +266,25 @@ fn split(area: Rect, layout: &Layout) -> Vec<Rect> {
ccs.push(elements[i].y | EQ(REQUIRED) | f64::from(dest_area.y));
ccs.push(elements[i].height | EQ(REQUIRED) | f64::from(dest_area.height));
ccs.push(match *size {
Constraint::Length(v) => elements[i].width | EQ(WEAK) | f64::from(v),
Constraint::Length(v) => elements[i].width | EQ(MEDIUM) | f64::from(v),
Constraint::Percentage(v) => {
elements[i].width | EQ(WEAK) | (f64::from(v * dest_area.width) / 100.0)
elements[i].width | EQ(MEDIUM) | (f64::from(v * dest_area.width) / 100.0)
}
Constraint::Ratio(n, d) => {
elements[i].width
| EQ(WEAK)
| EQ(MEDIUM)
| (f64::from(dest_area.width) * f64::from(n) / f64::from(d))
}
Constraint::Min(v) => elements[i].width | GE(WEAK) | f64::from(v),
Constraint::Max(v) => elements[i].width | LE(WEAK) | f64::from(v),
Constraint::Min(v) => elements[i].width | GE(MEDIUM) | f64::from(v),
Constraint::Max(v) => elements[i].width | LE(MEDIUM) | f64::from(v),
});
match *size {
Constraint::Min(v) | Constraint::Max(v) => {
ccs.push(elements[i].width | EQ(WEAK) | f64::from(v));
}
_ => {}
}
}
}
Direction::Vertical => {
@@ -270,18 +295,25 @@ fn split(area: Rect, layout: &Layout) -> Vec<Rect> {
ccs.push(elements[i].x | EQ(REQUIRED) | f64::from(dest_area.x));
ccs.push(elements[i].width | EQ(REQUIRED) | f64::from(dest_area.width));
ccs.push(match *size {
Constraint::Length(v) => elements[i].height | EQ(WEAK) | f64::from(v),
Constraint::Length(v) => elements[i].height | EQ(MEDIUM) | f64::from(v),
Constraint::Percentage(v) => {
elements[i].height | EQ(WEAK) | (f64::from(v * dest_area.height) / 100.0)
elements[i].height | EQ(MEDIUM) | (f64::from(v * dest_area.height) / 100.0)
}
Constraint::Ratio(n, d) => {
elements[i].height
| EQ(WEAK)
| EQ(MEDIUM)
| (f64::from(dest_area.height) * f64::from(n) / f64::from(d))
}
Constraint::Min(v) => elements[i].height | GE(WEAK) | f64::from(v),
Constraint::Max(v) => elements[i].height | LE(WEAK) | f64::from(v),
Constraint::Min(v) => elements[i].height | GE(MEDIUM) | f64::from(v),
Constraint::Max(v) => elements[i].height | LE(MEDIUM) | f64::from(v),
});
match *size {
Constraint::Min(v) | Constraint::Max(v) => {
ccs.push(elements[i].height | EQ(WEAK) | f64::from(v));
}
_ => {}
}
}
}
}
@@ -323,7 +355,7 @@ fn split(area: Rect, layout: &Layout) -> Vec<Rect> {
}
}
}
results
res
}
/// A container used by the solver inside split
@@ -361,7 +393,7 @@ impl Element {
}
}
/// A simple rectangle used in the computation of the layout and to give widgets an hint about the
/// A simple rectangle used in the computation of the layout and to give widgets a hint about the
/// area they are supposed to render to.
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)]
pub struct Rect {
@@ -394,23 +426,23 @@ impl Rect {
}
}
pub fn area(self) -> u16 {
pub const fn area(self) -> u16 {
self.width * self.height
}
pub fn left(self) -> u16 {
pub const fn left(self) -> u16 {
self.x
}
pub fn right(self) -> u16 {
pub const fn right(self) -> u16 {
self.x.saturating_add(self.width)
}
pub fn top(self) -> u16 {
pub const fn top(self) -> u16 {
self.y
}
pub fn bottom(self) -> u16 {
pub const fn bottom(self) -> u16 {
self.y.saturating_add(self.height)
}
@@ -453,7 +485,7 @@ impl Rect {
}
}
pub fn intersects(self, other: Rect) -> bool {
pub const fn intersects(self, other: Rect) -> bool {
self.x < other.x + other.width
&& self.x + self.width > other.x
&& self.y < other.y + other.height
@@ -504,7 +536,7 @@ mod tests {
- f64::from(width) / f64::from(height))
.abs()
< 1.0
)
);
}
}
@@ -533,4 +565,68 @@ mod tests {
assert_eq!(rect.width, 300);
assert_eq!(rect.height, 100);
}
#[test]
fn test_constraint_apply() {
assert_eq!(Constraint::Percentage(0).apply(100), 0);
assert_eq!(Constraint::Percentage(50).apply(100), 50);
assert_eq!(Constraint::Percentage(100).apply(100), 100);
assert_eq!(Constraint::Percentage(200).apply(100), 100);
assert_eq!(Constraint::Percentage(u16::MAX).apply(100), 100);
// 0/0 intentionally avoids a panic by returning 0.
assert_eq!(Constraint::Ratio(0, 0).apply(100), 0);
// 1/0 intentionally avoids a panic by returning 100% of the length.
assert_eq!(Constraint::Ratio(1, 0).apply(100), 100);
assert_eq!(Constraint::Ratio(0, 1).apply(100), 0);
assert_eq!(Constraint::Ratio(1, 2).apply(100), 50);
assert_eq!(Constraint::Ratio(2, 2).apply(100), 100);
assert_eq!(Constraint::Ratio(3, 2).apply(100), 100);
assert_eq!(Constraint::Ratio(u32::MAX, 2).apply(100), 100);
assert_eq!(Constraint::Length(0).apply(100), 0);
assert_eq!(Constraint::Length(50).apply(100), 50);
assert_eq!(Constraint::Length(100).apply(100), 100);
assert_eq!(Constraint::Length(200).apply(100), 100);
assert_eq!(Constraint::Length(u16::MAX).apply(100), 100);
assert_eq!(Constraint::Max(0).apply(100), 0);
assert_eq!(Constraint::Max(50).apply(100), 50);
assert_eq!(Constraint::Max(100).apply(100), 100);
assert_eq!(Constraint::Max(200).apply(100), 100);
assert_eq!(Constraint::Max(u16::MAX).apply(100), 100);
assert_eq!(Constraint::Min(0).apply(100), 100);
assert_eq!(Constraint::Min(50).apply(100), 100);
assert_eq!(Constraint::Min(100).apply(100), 100);
assert_eq!(Constraint::Min(200).apply(100), 200);
assert_eq!(Constraint::Min(u16::MAX).apply(100), u16::MAX);
}
#[test]
fn rect_can_be_const() {
const RECT: Rect = Rect {
x: 0,
y: 0,
width: 10,
height: 10,
};
const _AREA: u16 = RECT.area();
const _LEFT: u16 = RECT.left();
const _RIGHT: u16 = RECT.right();
const _TOP: u16 = RECT.top();
const _BOTTOM: u16 = RECT.bottom();
assert!(RECT.intersects(RECT));
}
#[test]
fn layout_can_be_const() {
const _LAYOUT: Layout = Layout::new();
const _DEFAULT_LAYOUT: Layout = Layout::new()
.direction(Direction::Horizontal)
.margin(1)
.expand_to_fill(false);
const _HORIZONTAL_LAYOUT: Layout = Layout::new().horizontal_margin(1);
const _VERTICAL_LAYOUT: Layout = Layout::new().vertical_margin(1);
}
}

View File

@@ -1,16 +1,17 @@
//! [tui](https://github.com/fdehau/tui-rs) is a library used to build rich
//! [ratatui](https://github.com/tui-rs-revival/ratatui) is a library used to build rich
//! terminal users interfaces and dashboards.
//!
//! ![](https://raw.githubusercontent.com/fdehau/tui-rs/master/assets/demo.gif)
//! ![](https://raw.githubusercontent.com/tui-rs-revival/ratatui/master/assets/demo.gif)
//!
//! # Get started
//!
//! ## Adding `tui` as a dependency
//! ## Adding `ratatui` as a dependency
//!
//! Add the following to your `Cargo.toml`:
//! ```toml
//! [dependencies]
//! tui = "0.18"
//! crossterm = "0.23"
//! crossterm = "0.26"
//! ratatui = "0.20"
//! ```
//!
//! The crate is using the `crossterm` backend by default that works on most platforms. But if for
@@ -20,21 +21,28 @@
//! ```toml
//! [dependencies]
//! termion = "1.5"
//! tui = { version = "0.18", default-features = false, features = ['termion'] }
//!
//! ratatui = { version = "0.20", default-features = false, features = ['termion'] }
//! ```
//!
//! The same logic applies for all other available backends.
//!
//! ### Features
//!
//! Widgets which add dependencies are gated behind feature flags to prevent unused transitive
//! dependencies. The available features are:
//!
//! * `widget-calendar` - enables [`widgets::calendar`] and adds a dependency on the [time
//! crate](https://crates.io/crates/time).
//!
//! ## Creating a `Terminal`
//!
//! Every application using `tui` should start by instantiating a `Terminal`. It is a light
//! Every application using `ratatui` should start by instantiating a `Terminal`. It is a light
//! abstraction over available backends that provides basic functionalities such as clearing the
//! screen, hiding the cursor, etc.
//!
//! ```rust,no_run
//! use std::io;
//! use tui::{backend::CrosstermBackend, Terminal};
//! use ratatui::{backend::CrosstermBackend, Terminal};
//!
//! fn main() -> Result<(), io::Error> {
//! let stdout = io::stdout();
@@ -49,7 +57,7 @@
//!
//! ```rust,ignore
//! use std::io;
//! use tui::{backend::TermionBackend, Terminal};
//! use ratatui::{backend::TermionBackend, Terminal};
//! use termion::raw::IntoRawMode;
//!
//! fn main() -> Result<(), io::Error> {
@@ -77,14 +85,13 @@
//!
//! ```rust,no_run
//! use std::{io, thread, time::Duration};
//! use tui::{
//! use ratatui::{
//! backend::CrosstermBackend,
//! widgets::{Widget, Block, Borders},
//! layout::{Layout, Constraint, Direction},
//! widgets::{Block, Borders},
//! Terminal
//! };
//! use crossterm::{
//! event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
//! event::{self, DisableMouseCapture, EnableMouseCapture},
//! execute,
//! terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
//! };
@@ -105,6 +112,12 @@
//! f.render_widget(block, size);
//! })?;
//!
//! // Start a thread to discard any input events. Without handling events, the
//! // stdin buffer will fill up, and be read into the shell when the program exits.
//! thread::spawn(|| loop {
//! event::read();
//! });
//!
//! thread::sleep(Duration::from_millis(5000));
//!
//! // restore terminal
@@ -127,7 +140,7 @@
//! full customization. And `Layout` is no exception:
//!
//! ```rust,no_run
//! use tui::{
//! use ratatui::{
//! backend::Backend,
//! layout::{Constraint, Direction, Layout},
//! widgets::{Block, Borders},
@@ -161,6 +174,9 @@
//! you might need a blank space somewhere, try to pass an additional constraint and don't use the
//! corresponding area.
// show the feature flags in the generated documentation
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
pub mod backend;
pub mod buffer;
pub mod layout;

View File

@@ -1,8 +1,44 @@
//! `style` contains the primitives used to control how your user interface will look.
//!
//! # Using the `Style` struct
//!
//! This is useful when creating style variables.
//! ## Example
//! ```
//! use ratatui::style::{Color, Modifier, Style};
//!
//! Style::default()
//! .fg(Color::Black)
//! .bg(Color::Green)
//! .add_modifier(Modifier::ITALIC | Modifier::BOLD);
//! ```
//!
//! # Using style shorthands
//!
//! This is best for consise styling.
//! ## Example
//! ```
//! use ratatui::{
//! style::{Color, Modifier, Style, Styled, Stylize},
//! text::Span,
//! };
//!
//! assert_eq!(
//! "hello".red().on_blue().bold(),
//! Span::styled("hello", Style::default().fg(Color::Red).bg(Color::Blue).add_modifier(Modifier::BOLD))
//! )
//! ```
mod stylized;
use std::{
fmt::{self, Debug},
str::FromStr,
};
use bitflags::bitflags;
pub use stylized::{Styled, Stylize};
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Color {
Reset,
@@ -34,11 +70,12 @@ bitflags! {
/// ## Examples
///
/// ```rust
/// # use tui::style::Modifier;
/// # use ratatui::style::Modifier;
///
/// let m = Modifier::BOLD | Modifier::ITALIC;
/// ```
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Modifier: u16 {
const BOLD = 0b0000_0000_0001;
const DIM = 0b0000_0000_0010;
@@ -52,10 +89,24 @@ bitflags! {
}
}
/// Implement the `Debug` trait for `Modifier` manually.
///
/// This will avoid printing the empty modifier as 'Borders(0x0)' and instead print it as 'NONE'.
impl fmt::Debug for Modifier {
/// Format the modifier as `NONE` if the modifier is empty or as a list of flags separated by
/// `|` otherwise.
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if self.is_empty() {
return write!(f, "NONE");
}
fmt::Debug::fmt(&self.0, f)
}
}
/// Style let you control the main characteristics of the displayed elements.
///
/// ```rust
/// # use tui::style::{Color, Modifier, Style};
/// # use ratatui::style::{Color, Modifier, Style};
/// Style::default()
/// .fg(Color::Black)
/// .bg(Color::Green)
@@ -67,9 +118,9 @@ bitflags! {
/// just S3.
///
/// ```rust
/// # use tui::style::{Color, Modifier, Style};
/// # use tui::buffer::Buffer;
/// # use tui::layout::Rect;
/// # use ratatui::style::{Color, Modifier, Style};
/// # use ratatui::buffer::Buffer;
/// # use ratatui::layout::Rect;
/// let styles = [
/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC),
/// Style::default().bg(Color::Red),
@@ -94,9 +145,9 @@ bitflags! {
/// reset all properties until that point use [`Style::reset`].
///
/// ```
/// # use tui::style::{Color, Modifier, Style};
/// # use tui::buffer::Buffer;
/// # use tui::layout::Rect;
/// # use ratatui::style::{Color, Modifier, Style};
/// # use ratatui::buffer::Buffer;
/// # use ratatui::layout::Rect;
/// let styles = [
/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC),
/// Style::reset().fg(Color::Yellow),
@@ -115,7 +166,7 @@ bitflags! {
/// buffer.get(0, 0).style(),
/// );
/// ```
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Style {
pub fg: Option<Color>,
@@ -126,6 +177,12 @@ pub struct Style {
impl Default for Style {
fn default() -> Style {
Style::new()
}
}
impl Style {
pub const fn new() -> Style {
Style {
fg: None,
bg: None,
@@ -133,11 +190,9 @@ impl Default for Style {
sub_modifier: Modifier::empty(),
}
}
}
impl Style {
/// Returns a `Style` resetting all properties.
pub fn reset() -> Style {
pub const fn reset() -> Style {
Style {
fg: Some(Color::Reset),
bg: Some(Color::Reset),
@@ -151,12 +206,12 @@ impl Style {
/// ## Examples
///
/// ```rust
/// # use tui::style::{Color, Style};
/// # use ratatui::style::{Color, Style};
/// let style = Style::default().fg(Color::Blue);
/// let diff = Style::default().fg(Color::Red);
/// assert_eq!(style.patch(diff), Style::default().fg(Color::Red));
/// ```
pub fn fg(mut self, color: Color) -> Style {
pub const fn fg(mut self, color: Color) -> Style {
self.fg = Some(color);
self
}
@@ -166,12 +221,12 @@ impl Style {
/// ## Examples
///
/// ```rust
/// # use tui::style::{Color, Style};
/// # use ratatui::style::{Color, Style};
/// let style = Style::default().bg(Color::Blue);
/// let diff = Style::default().bg(Color::Red);
/// assert_eq!(style.patch(diff), Style::default().bg(Color::Red));
/// ```
pub fn bg(mut self, color: Color) -> Style {
pub const fn bg(mut self, color: Color) -> Style {
self.bg = Some(color);
self
}
@@ -183,7 +238,7 @@ impl Style {
/// ## Examples
///
/// ```rust
/// # use tui::style::{Color, Modifier, Style};
/// # use ratatui::style::{Color, Modifier, Style};
/// let style = Style::default().add_modifier(Modifier::BOLD);
/// let diff = Style::default().add_modifier(Modifier::ITALIC);
/// let patched = style.patch(diff);
@@ -203,7 +258,7 @@ impl Style {
/// ## Examples
///
/// ```rust
/// # use tui::style::{Color, Modifier, Style};
/// # use ratatui::style::{Color, Modifier, Style};
/// let style = Style::default().add_modifier(Modifier::BOLD | Modifier::ITALIC);
/// let diff = Style::default().remove_modifier(Modifier::ITALIC);
/// let patched = style.patch(diff);
@@ -221,7 +276,7 @@ impl Style {
///
/// ## Examples
/// ```
/// # use tui::style::{Color, Modifier, Style};
/// # use ratatui::style::{Color, Modifier, Style};
/// let style_1 = Style::default().fg(Color::Yellow);
/// let style_2 = Style::default().bg(Color::Red);
/// let combined = style_1.patch(style_2);
@@ -242,6 +297,85 @@ impl Style {
}
}
/// Error type indicating a failure to parse a color string.
#[derive(Debug)]
pub struct ParseColorError;
impl std::fmt::Display for ParseColorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Failed to parse Colors")
}
}
impl std::error::Error for ParseColorError {}
/// Converts a string representation to a `Color` instance.
///
/// The `from_str` function attempts to parse the given string and convert it
/// to the corresponding `Color` variant. It supports named colors, RGB values,
/// and indexed colors. If the string cannot be parsed, a `ParseColorError` is returned.
///
/// # Examples
///
/// ```
/// # use std::str::FromStr;
/// # use ratatui::style::Color;
/// let color: Color = Color::from_str("blue").unwrap();
/// assert_eq!(color, Color::Blue);
///
/// let color: Color = Color::from_str("#FF0000").unwrap();
/// assert_eq!(color, Color::Rgb(255, 0, 0));
///
/// let color: Color = Color::from_str("10").unwrap();
/// assert_eq!(color, Color::Indexed(10));
///
/// let color: Result<Color, _> = Color::from_str("invalid_color");
/// assert!(color.is_err());
/// ```
impl FromStr for Color {
type Err = ParseColorError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.to_lowercase().as_ref() {
"reset" => Self::Reset,
"black" => Self::Black,
"red" => Self::Red,
"green" => Self::Green,
"yellow" => Self::Yellow,
"blue" => Self::Blue,
"magenta" => Self::Magenta,
"cyan" => Self::Cyan,
"gray" => Self::Gray,
"darkgray" | "dark gray" => Self::DarkGray,
"lightred" | "light red" => Self::LightRed,
"lightgreen" | "light green" => Self::LightGreen,
"lightyellow" | "light yellow" => Self::LightYellow,
"lightblue" | "light blue" => Self::LightBlue,
"lightmagenta" | "light magenta" => Self::LightMagenta,
"lightcyan" | "light cyan" => Self::LightCyan,
"white" => Self::White,
_ => {
if let Ok(index) = s.parse::<u8>() {
Self::Indexed(index)
} else if let (Ok(r), Ok(g), Ok(b)) = {
if !s.starts_with('#') || s.len() != 7 {
return Err(ParseColorError);
}
(
u8::from_str_radix(&s[1..3], 16),
u8::from_str_radix(&s[3..5], 16),
u8::from_str_radix(&s[5..7], 16),
)
} {
Self::Rgb(r, g, b)
} else {
return Err(ParseColorError);
}
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -278,4 +412,97 @@ mod tests {
}
}
}
#[test]
fn combine_individual_modifiers() {
use crate::{buffer::Buffer, layout::Rect};
let mods = vec![
Modifier::BOLD,
Modifier::DIM,
Modifier::ITALIC,
Modifier::UNDERLINED,
Modifier::SLOW_BLINK,
Modifier::RAPID_BLINK,
Modifier::REVERSED,
Modifier::HIDDEN,
Modifier::CROSSED_OUT,
];
let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
for m in &mods {
buffer.get_mut(0, 0).set_style(Style::reset());
buffer
.get_mut(0, 0)
.set_style(Style::default().add_modifier(*m));
let style = buffer.get(0, 0).style();
assert!(style.add_modifier.contains(*m));
assert!(!style.sub_modifier.contains(*m));
}
}
#[test]
fn test_modifier_debug() {
assert_eq!(format!("{:?}", Modifier::empty()), "NONE");
assert_eq!(format!("{:?}", Modifier::BOLD), "BOLD");
assert_eq!(format!("{:?}", Modifier::DIM), "DIM");
assert_eq!(format!("{:?}", Modifier::ITALIC), "ITALIC");
assert_eq!(format!("{:?}", Modifier::UNDERLINED), "UNDERLINED");
assert_eq!(format!("{:?}", Modifier::SLOW_BLINK), "SLOW_BLINK");
assert_eq!(format!("{:?}", Modifier::RAPID_BLINK), "RAPID_BLINK");
assert_eq!(format!("{:?}", Modifier::REVERSED), "REVERSED");
assert_eq!(format!("{:?}", Modifier::HIDDEN), "HIDDEN");
assert_eq!(format!("{:?}", Modifier::CROSSED_OUT), "CROSSED_OUT");
assert_eq!(
format!("{:?}", Modifier::BOLD | Modifier::DIM),
"BOLD | DIM"
);
assert_eq!(
format!("{:?}", Modifier::all()),
"BOLD | DIM | ITALIC | UNDERLINED | SLOW_BLINK | RAPID_BLINK | REVERSED | HIDDEN | CROSSED_OUT"
);
}
#[test]
fn test_rgb_color() {
let color: Color = Color::from_str("#FF0000").unwrap();
assert_eq!(color, Color::Rgb(255, 0, 0));
}
#[test]
fn test_indexed_color() {
let color: Color = Color::from_str("10").unwrap();
assert_eq!(color, Color::Indexed(10));
}
#[test]
fn test_custom_color() {
let color: Color = Color::from_str("lightblue").unwrap();
assert_eq!(color, Color::LightBlue);
}
#[test]
fn test_invalid_colors() {
let bad_colors = [
"invalid_color", // not a color string
"abcdef0", // 7 chars is not a color
" bcdefa", // doesn't start with a '#'
"blue ", // has space at end
" blue", // has space at start
"#abcdef00", // too many chars
];
for bad_color in bad_colors {
assert!(
Color::from_str(bad_color).is_err(),
"bad color: '{bad_color}'"
);
}
}
#[test]
fn style_can_be_const() {
const _DEFAULT_STYLE: Style = Style::new().fg(Color::Red).bg(Color::Black);
const _RESET: Style = Style::reset();
}
}

228
src/style/stylized.rs Normal file
View File

@@ -0,0 +1,228 @@
use crate::{
style::{Color, Modifier, Style},
text::Span,
};
pub trait Styled {
type Item;
fn style(&self) -> Style;
fn set_style(self, style: Style) -> Self::Item;
}
// Otherwise rustfmt behaves weirdly for some reason
macro_rules! calculated_docs {
($(#[doc = $doc:expr] $item:item)*) => { $(#[doc = $doc] $item)* };
}
macro_rules! modifier_method {
($method_name:ident Modifier::$modifier:ident) => {
calculated_docs! {
#[doc = concat!(
"Applies the [`",
stringify!($modifier),
"`](crate::style::Modifier::",
stringify!($modifier),
") modifier.",
)]
fn $method_name(self) -> T {
self.modifier(Modifier::$modifier)
}
}
};
}
macro_rules! color_method {
($method_name_fg:ident, $method_name_bg:ident Color::$color:ident) => {
calculated_docs! {
#[doc = concat!(
"Sets the foreground color to [`",
stringify!($color),
"`](Color::",
stringify!($color),
")."
)]
fn $method_name_fg(self) -> T {
self.fg(Color::$color)
}
#[doc = concat!(
"Sets the background color to [`",
stringify!($color),
"`](Color::",
stringify!($color),
")."
)]
fn $method_name_bg(self) -> T {
self.bg(Color::$color)
}
}
};
}
/// The trait that enables something to be have a style.
/// # Examples
/// ```
/// use ratatui::{
/// style::{Color, Modifier, Style, Styled, Stylize},
/// text::Span,
/// };
///
/// assert_eq!(
/// "hello".red().on_blue().bold(),
/// Span::styled("hello", Style::default().fg(Color::Red).bg(Color::Blue).add_modifier(Modifier::BOLD))
/// )
pub trait Stylize<'a, T>: Sized {
// Colors
fn fg<S: Into<Color>>(self, color: S) -> T;
fn bg(self, color: Color) -> T;
color_method!(black, on_black Color::Black);
color_method!(red, on_red Color::Red);
color_method!(green, on_green Color::Green);
color_method!(yellow, on_yellow Color::Yellow);
color_method!(blue, on_blue Color::Blue);
color_method!(magenta, on_magenta Color::Magenta);
color_method!(cyan, on_cyan Color::Cyan);
color_method!(gray, on_gray Color::Gray);
color_method!(dark_gray, on_dark_gray Color::DarkGray);
color_method!(light_red, on_light_red Color::LightRed);
color_method!(light_green, on_light_green Color::LightGreen);
color_method!(light_yellow, on_light_yellow Color::LightYellow);
color_method!(light_blue, on_light_blue Color::LightBlue);
color_method!(light_magenta, on_light_magenta Color::LightMagenta);
color_method!(light_cyan, on_light_cyan Color::LightCyan);
color_method!(white, on_white Color::White);
// Styles
fn reset(self) -> T;
// Modifiers
fn modifier(self, modifier: Modifier) -> T;
modifier_method!(bold Modifier::BOLD);
modifier_method!(dimmed Modifier::DIM);
modifier_method!(italic Modifier::ITALIC);
modifier_method!(underline Modifier::UNDERLINED);
modifier_method!(slow_blink Modifier::SLOW_BLINK);
modifier_method!(rapid_blink Modifier::RAPID_BLINK);
modifier_method!(reversed Modifier::REVERSED);
modifier_method!(hidden Modifier::HIDDEN);
modifier_method!(crossed_out Modifier::CROSSED_OUT);
}
impl<'a, T, U> Stylize<'a, T> for U
where
U: Styled<Item = T>,
{
fn fg<S: Into<Color>>(self, color: S) -> T {
let style = self.style().fg(color.into());
self.set_style(style)
}
fn modifier(self, modifier: Modifier) -> T {
let style = self.style().add_modifier(modifier);
self.set_style(style)
}
fn bg(self, color: Color) -> T {
let style = self.style().bg(color);
self.set_style(style)
}
fn reset(self) -> T {
self.set_style(Style::default())
}
}
impl<'a> Styled for &'a str {
type Item = Span<'a>;
fn style(&self) -> Style {
Style::default()
}
fn set_style(self, style: Style) -> Self::Item {
Span::styled(self, style)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reset() {
assert_eq!(
"hello".on_cyan().light_red().bold().underline().reset(),
Span::from("hello")
)
}
#[test]
fn fg() {
let cyan_fg = Style::default().fg(Color::Cyan);
assert_eq!("hello".cyan(), Span::styled("hello", cyan_fg));
}
#[test]
fn bg() {
let cyan_bg = Style::default().bg(Color::Cyan);
assert_eq!("hello".on_cyan(), Span::styled("hello", cyan_bg));
}
#[test]
fn color_modifier() {
let cyan_bold = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
assert_eq!("hello".cyan().bold(), Span::styled("hello", cyan_bold))
}
#[test]
fn fg_bg() {
let cyan_fg_bg = Style::default().bg(Color::Cyan).fg(Color::Cyan);
assert_eq!("hello".cyan().on_cyan(), Span::styled("hello", cyan_fg_bg))
}
#[test]
fn repeated_attributes() {
let cyan_bg = Style::default().bg(Color::Cyan);
let cyan_fg = Style::default().fg(Color::Cyan);
// Behavior: the last one set is the definitive one
assert_eq!("hello".on_red().on_cyan(), Span::styled("hello", cyan_bg));
assert_eq!("hello".red().cyan(), Span::styled("hello", cyan_fg));
}
#[test]
fn all_chained() {
let all_modifier_black = Style::default()
.bg(Color::Black)
.fg(Color::Black)
.add_modifier(
Modifier::UNDERLINED
| Modifier::BOLD
| Modifier::DIM
| Modifier::SLOW_BLINK
| Modifier::REVERSED
| Modifier::CROSSED_OUT,
);
assert_eq!(
"hello"
.on_black()
.black()
.bold()
.underline()
.dimmed()
.slow_blink()
.crossed_out()
.reversed(),
Span::styled("hello", all_modifier_black)
);
}
}

View File

@@ -228,6 +228,8 @@ pub enum Marker {
Dot,
/// One point per cell in shape of a block
Block,
/// One point per cell in the shape of a bar
Bar,
/// Up to 8 points per cell
Braille,
}

View File

@@ -1,36 +1,20 @@
use std::io;
use crate::{
backend::Backend,
backend::{Backend, ClearType},
buffer::Buffer,
layout::Rect,
widgets::{StatefulWidget, Widget},
};
use std::io;
#[derive(Debug, Clone, PartialEq)]
/// UNSTABLE
enum ResizeBehavior {
Fixed,
Auto,
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Viewport {
Fullscreen,
Inline(u16),
Fixed(Rect),
}
#[derive(Debug, Clone, PartialEq)]
/// UNSTABLE
pub struct Viewport {
area: Rect,
resize_behavior: ResizeBehavior,
}
impl Viewport {
/// UNSTABLE
pub fn fixed(area: Rect) -> Viewport {
Viewport {
area,
resize_behavior: ResizeBehavior::Fixed,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Eq)]
/// Options to pass to [`Terminal::with_options`]
pub struct TerminalOptions {
/// Viewport used to draw to the terminal
@@ -53,6 +37,12 @@ where
hidden_cursor: bool,
/// Viewport
viewport: Viewport,
viewport_area: Rect,
/// Last known size of the terminal. Used to detect if the internal buffers have to be resized.
last_known_size: Rect,
/// Last known position of the cursor. Used to find the new area when the viewport is inlined
/// and the terminal resized.
last_known_cursor_pos: (u16, u16),
}
/// Represents a consistent terminal interface for rendering.
@@ -73,9 +63,9 @@ impl<'a, B> Frame<'a, B>
where
B: Backend,
{
/// Terminal size, guaranteed not to change when rendering.
/// Frame size, guaranteed not to change when rendering.
pub fn size(&self) -> Rect {
self.terminal.viewport.area
self.terminal.viewport_area
}
/// Render a [`Widget`] to the current buffer using [`Widget::render`].
@@ -83,10 +73,10 @@ where
/// # Examples
///
/// ```rust
/// # use tui::Terminal;
/// # use tui::backend::TestBackend;
/// # use tui::layout::Rect;
/// # use tui::widgets::Block;
/// # use ratatui::Terminal;
/// # use ratatui::backend::TestBackend;
/// # use ratatui::layout::Rect;
/// # use ratatui::widgets::Block;
/// # let backend = TestBackend::new(5, 5);
/// # let mut terminal = Terminal::new(backend).unwrap();
/// let block = Block::default();
@@ -109,10 +99,10 @@ where
/// # Examples
///
/// ```rust
/// # use tui::Terminal;
/// # use tui::backend::TestBackend;
/// # use tui::layout::Rect;
/// # use tui::widgets::{List, ListItem, ListState};
/// # use ratatui::Terminal;
/// # use ratatui::backend::TestBackend;
/// # use ratatui::layout::Rect;
/// # use ratatui::widgets::{List, ListItem, ListState};
/// # let backend = TestBackend::new(5, 5);
/// # let mut terminal = Terminal::new(backend).unwrap();
/// let mut state = ListState::default();
@@ -144,7 +134,7 @@ where
}
}
/// CompletedFrame represents the state of the terminal after all changes performed in the last
/// `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> {
@@ -160,7 +150,7 @@ where
// Attempt to restore the cursor state
if self.hidden_cursor {
if let Err(err) = self.show_cursor() {
eprintln!("Failed to show the cursor: {}", err);
eprintln!("Failed to show the cursor: {err}");
}
}
}
@@ -173,29 +163,33 @@ where
/// Wrapper around Terminal initialization. Each buffer is initialized with a blank string and
/// default colors for the foreground and the background
pub fn new(backend: B) -> io::Result<Terminal<B>> {
let size = backend.size()?;
Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport {
area: size,
resize_behavior: ResizeBehavior::Auto,
},
viewport: Viewport::Fullscreen,
},
)
}
/// UNSTABLE
pub fn with_options(backend: B, options: TerminalOptions) -> io::Result<Terminal<B>> {
pub fn with_options(mut backend: B, options: TerminalOptions) -> io::Result<Terminal<B>> {
let size = match options.viewport {
Viewport::Fullscreen | Viewport::Inline(_) => backend.size()?,
Viewport::Fixed(area) => area,
};
let (viewport_area, cursor_pos) = match options.viewport {
Viewport::Fullscreen => (size, (0, 0)),
Viewport::Inline(height) => compute_inline_size(&mut backend, height, size, 0)?,
Viewport::Fixed(area) => (area, (area.left(), area.top())),
};
Ok(Terminal {
backend,
buffers: [
Buffer::empty(options.viewport.area),
Buffer::empty(options.viewport.area),
],
buffers: [Buffer::empty(viewport_area), Buffer::empty(viewport_area)],
current: 0,
hidden_cursor: false,
viewport: options.viewport,
viewport_area,
last_known_size: size,
last_known_cursor_pos: cursor_pos,
})
}
@@ -225,24 +219,46 @@ where
let previous_buffer = &self.buffers[1 - self.current];
let current_buffer = &self.buffers[self.current];
let updates = previous_buffer.diff(current_buffer);
if let Some((col, row, _)) = updates.last() {
self.last_known_cursor_pos = (*col, *row);
}
self.backend.draw(updates.into_iter())
}
/// Updates the Terminal so that internal buffers match the requested size. Requested size will
/// be saved so the size can remain consistent when rendering.
/// This leads to a full clear of the screen.
pub fn resize(&mut self, area: Rect) -> io::Result<()> {
pub fn resize(&mut self, size: Rect) -> io::Result<()> {
let next_area = match self.viewport {
Viewport::Fullscreen => size,
Viewport::Inline(height) => {
let offset_in_previous_viewport = self
.last_known_cursor_pos
.1
.saturating_sub(self.viewport_area.top());
compute_inline_size(&mut self.backend, height, size, offset_in_previous_viewport)?.0
}
Viewport::Fixed(area) => area,
};
self.set_viewport_area(next_area);
self.clear()?;
self.last_known_size = size;
Ok(())
}
fn set_viewport_area(&mut self, area: Rect) {
self.buffers[self.current].resize(area);
self.buffers[1 - self.current].resize(area);
self.viewport.area = area;
self.clear()
self.viewport_area = area;
}
/// Queries the backend for size and resizes if it doesn't match the previous size.
pub fn autoresize(&mut self) -> io::Result<()> {
if self.viewport.resize_behavior == ResizeBehavior::Auto {
// fixed viewports do not get autoresized
if matches!(self.viewport, Viewport::Fullscreen | Viewport::Inline(_)) {
let size = self.size()?;
if size != self.viewport.area {
if size != self.last_known_size {
self.resize(size)?;
}
};
@@ -277,15 +293,14 @@ where
}
}
// Swap buffers
self.buffers[1 - self.current].reset();
self.current = 1 - self.current;
self.swap_buffers();
// Flush
self.backend.flush()?;
Ok(CompletedFrame {
buffer: &self.buffers[1 - self.current],
area: self.viewport.area,
area: self.last_known_size,
})
}
@@ -306,19 +321,166 @@ where
}
pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
self.backend.set_cursor(x, y)
self.backend.set_cursor(x, y)?;
self.last_known_cursor_pos = (x, y);
Ok(())
}
/// Clear the terminal and force a full redraw on the next draw call.
pub fn clear(&mut self) -> io::Result<()> {
self.backend.clear()?;
match self.viewport {
Viewport::Fullscreen => self.backend.clear_region(ClearType::All)?,
Viewport::Inline(_) => {
self.backend
.set_cursor(self.viewport_area.left(), self.viewport_area.top())?;
self.backend.clear_region(ClearType::AfterCursor)?;
}
Viewport::Fixed(area) => {
for row in area.top()..area.bottom() {
self.backend.set_cursor(0, row)?;
self.backend.clear_region(ClearType::AfterCursor)?;
}
}
}
// Reset the back buffer to make sure the next update will redraw everything.
self.buffers[1 - self.current].reset();
Ok(())
}
/// Clears the inactive buffer and swaps it with the current buffer
pub fn swap_buffers(&mut self) {
self.buffers[1 - self.current].reset();
self.current = 1 - self.current;
}
/// Queries the real size of the backend.
pub fn size(&self) -> io::Result<Rect> {
self.backend.size()
}
/// Insert some content before the current inline viewport. This has no effect when the
/// viewport is fullscreen.
///
/// This function scrolls down the current viewport by the given height. The newly freed space
/// is then made available to the `draw_fn` closure through a writable `Buffer`.
///
/// Before:
/// ```ignore
/// +-------------------+
/// | |
/// | viewport |
/// | |
/// +-------------------+
/// ```
///
/// After:
/// ```ignore
/// +-------------------+
/// | buffer |
/// +-------------------+
/// +-------------------+
/// | |
/// | viewport |
/// | |
/// +-------------------+
/// ```
///
/// # Examples
///
/// ## Insert a single line before the current viewport
///
/// ```rust
/// # use ratatui::widgets::{Paragraph, Widget};
/// # use ratatui::text::{Line, Span};
/// # use ratatui::style::{Color, Style};
/// # use ratatui::{Terminal};
/// # use ratatui::backend::TestBackend;
/// # let backend = TestBackend::new(10, 10);
/// # let mut terminal = Terminal::new(backend).unwrap();
/// terminal.insert_before(1, |buf| {
/// Paragraph::new(Line::from(vec![
/// Span::raw("This line will be added "),
/// Span::styled("before", Style::default().fg(Color::Blue)),
/// Span::raw(" the current viewport")
/// ])).render(buf.area, buf);
/// });
/// ```
pub fn insert_before<F>(&mut self, height: u16, draw_fn: F) -> io::Result<()>
where
F: FnOnce(&mut Buffer),
{
if !matches!(self.viewport, Viewport::Inline(_)) {
return Ok(());
}
self.clear()?;
let height = height.min(self.last_known_size.height);
self.backend.append_lines(height)?;
let missing_lines =
height.saturating_sub(self.last_known_size.bottom() - self.viewport_area.top());
let area = Rect {
x: self.viewport_area.left(),
y: self.viewport_area.top().saturating_sub(missing_lines),
width: self.viewport_area.width,
height,
};
let mut buffer = Buffer::empty(area);
draw_fn(&mut buffer);
let iter = buffer.content.iter().enumerate().map(|(i, c)| {
let (x, y) = buffer.pos_of(i);
(x, y, c)
});
self.backend.draw(iter)?;
self.backend.flush()?;
let remaining_lines = self.last_known_size.height - area.bottom();
let missing_lines = self.viewport_area.height.saturating_sub(remaining_lines);
self.backend.append_lines(self.viewport_area.height)?;
self.set_viewport_area(Rect {
x: area.left(),
y: area.bottom().saturating_sub(missing_lines),
width: area.width,
height: self.viewport_area.height,
});
Ok(())
}
}
fn compute_inline_size<B: Backend>(
backend: &mut B,
height: u16,
size: Rect,
offset_in_previous_viewport: u16,
) -> io::Result<(Rect, (u16, u16))> {
let pos = backend.get_cursor()?;
let mut row = pos.1;
let max_height = size.height.min(height);
let lines_after_cursor = height
.saturating_sub(offset_in_previous_viewport)
.saturating_sub(1);
backend.append_lines(lines_after_cursor)?;
let available_lines = size.height.saturating_sub(row).saturating_sub(1);
let missing_lines = lines_after_cursor.saturating_sub(available_lines);
if missing_lines > 0 {
row = row.saturating_sub(missing_lines);
}
row = row.saturating_sub(offset_in_previous_viewport);
Ok((
Rect {
x: 0,
y: row,
width: size.width,
height: max_height,
},
pos,
))
}

View File

@@ -1,35 +1,35 @@
//! Primitives for styled text.
//!
//! A terminal UI is at its root a lot of strings. In order to make it accessible and stylish,
//! those strings may be associated to a set of styles. `tui` has three ways to represent them:
//! those strings may be associated to a set of styles. `ratatui` has three ways to represent them:
//! - A single line string where all graphemes have the same style is represented by a [`Span`].
//! - A single line string where each grapheme may have its own style is represented by [`Spans`].
//! - A single line string where each grapheme may have its own style is represented by [`Line`].
//! - A multiple line string where each grapheme may have its own style is represented by a
//! [`Text`].
//!
//! These types form a hierarchy: [`Spans`] is a collection of [`Span`] and each line of [`Text`]
//! is a [`Spans`].
//! These types form a hierarchy: [`Line`] is a collection of [`Span`] and each line of [`Text`]
//! is a [`Line`].
//!
//! Keep it mind that a lot of widgets will use those types to advertise what kind of string is
//! supported for their properties. Moreover, `tui` provides convenient `From` implementations so
//! that you can start by using simple `String` or `&str` and then promote them to the previous
//! supported for their properties. Moreover, `ratatui` provides convenient `From` implementations
//! so that you can start by using simple `String` or `&str` and then promote them to the previous
//! primitives when you need additional styling capabilities.
//!
//! For example, for the [`crate::widgets::Block`] widget, all the following calls are valid to set
//! its `title` property (which is a [`Spans`] under the hood):
//! its `title` property (which is a [`Line`] under the hood):
//!
//! ```rust
//! # use tui::widgets::Block;
//! # use tui::text::{Span, Spans};
//! # use tui::style::{Color, Style};
//! # use ratatui::widgets::Block;
//! # use ratatui::text::{Span, Line};
//! # use ratatui::style::{Color, Style};
//! // A simple string with no styling.
//! // Converted to Spans(vec![
//! // Converted to Line(vec![
//! // Span { content: Cow::Borrowed("My title"), style: Style { .. } }
//! // ])
//! let block = Block::default().title("My title");
//!
//! // A simple string with a unique style.
//! // Converted to Spans(vec![
//! // Converted to Line(vec![
//! // Span { content: Cow::Borrowed("My title"), style: Style { fg: Some(Color::Yellow), .. }
//! // ])
//! let block = Block::default().title(
@@ -37,7 +37,7 @@
//! );
//!
//! // A string with multiple styles.
//! // Converted to Spans(vec![
//! // Converted to Line(vec![
//! // Span { content: Cow::Borrowed("My"), style: Style { fg: Some(Color::Yellow), .. } },
//! // Span { content: Cow::Borrowed(" title"), .. }
//! // ])
@@ -46,20 +46,28 @@
//! Span::raw(" title"),
//! ]);
//! ```
use crate::style::Style;
use std::borrow::Cow;
use std::{borrow::Cow, fmt::Debug};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::style::{Style, Styled};
mod line;
mod masked;
mod spans;
#[allow(deprecated)]
pub use {line::Line, masked::Masked, spans::Spans};
/// A grapheme associated to a style.
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StyledGrapheme<'a> {
pub symbol: &'a str,
pub style: Style,
}
/// A string where all graphemes have the same style.
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Span<'a> {
pub content: Cow<'a, str>,
pub style: Style,
@@ -71,7 +79,7 @@ impl<'a> Span<'a> {
/// ## Examples
///
/// ```rust
/// # use tui::text::Span;
/// # use ratatui::text::Span;
/// Span::raw("My text");
/// Span::raw(String::from("My text"));
/// ```
@@ -90,8 +98,8 @@ impl<'a> Span<'a> {
/// # Examples
///
/// ```rust
/// # use tui::text::Span;
/// # use tui::style::{Color, Modifier, Style};
/// # use ratatui::text::Span;
/// # use ratatui::style::{Color, Modifier, Style};
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
/// Span::styled("My text", style);
/// Span::styled(String::from("My text"), style);
@@ -119,8 +127,8 @@ impl<'a> Span<'a> {
/// ## Examples
///
/// ```rust
/// # use tui::text::{Span, StyledGrapheme};
/// # use tui::style::{Color, Modifier, Style};
/// # use ratatui::text::{Span, StyledGrapheme};
/// # use ratatui::style::{Color, Modifier, Style};
/// # use std::iter::Iterator;
/// let style = Style::default().fg(Color::Yellow);
/// let span = Span::styled("Text", style);
@@ -179,6 +187,43 @@ impl<'a> Span<'a> {
})
.filter(|s| s.symbol != "\n")
}
/// Patches the style an existing Span, adding modifiers from the given style.
///
/// ## Examples
///
/// ```rust
/// # use ratatui::text::Span;
/// # use ratatui::style::{Color, Style, Modifier};
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
/// let mut raw_span = Span::raw("My text");
/// let mut styled_span = Span::styled("My text", style);
///
/// assert_ne!(raw_span, styled_span);
///
/// raw_span.patch_style(style);
/// assert_eq!(raw_span, styled_span);
/// ```
pub fn patch_style(&mut self, style: Style) {
self.style = self.style.patch(style);
}
/// Resets the style of the Span.
/// Equivalent to calling `patch_style(Style::reset())`.
///
/// ## Examples
///
/// ```rust
/// # use ratatui::text::Span;
/// # use ratatui::style::{Color, Style, Modifier};
/// let mut span = Span::styled("My text", Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC));
///
/// span.reset_style();
/// assert_eq!(Style::reset(), span.style);
/// ```
pub fn reset_style(&mut self) {
self.patch_style(Style::reset());
}
}
impl<'a> From<String> for Span<'a> {
@@ -193,59 +238,14 @@ impl<'a> From<&'a str> for Span<'a> {
}
}
/// A string composed of clusters of graphemes, each with their own style.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Spans<'a>(pub Vec<Span<'a>>);
impl<'a> Spans<'a> {
/// Returns the width of the underlying string.
///
/// ## Examples
///
/// ```rust
/// # use tui::text::{Span, Spans};
/// # use tui::style::{Color, Style};
/// let spans = Spans::from(vec![
/// Span::styled("My", Style::default().fg(Color::Yellow)),
/// Span::raw(" text"),
/// ]);
/// assert_eq!(7, spans.width());
/// ```
pub fn width(&self) -> usize {
self.0.iter().map(Span::width).sum()
impl<'a> Styled for Span<'a> {
type Item = Span<'a>;
fn style(&self) -> Style {
self.style
}
}
impl<'a> From<String> for Spans<'a> {
fn from(s: String) -> Spans<'a> {
Spans(vec![Span::from(s)])
}
}
impl<'a> From<&'a str> for Spans<'a> {
fn from(s: &'a str) -> Spans<'a> {
Spans(vec![Span::from(s)])
}
}
impl<'a> From<Vec<Span<'a>>> for Spans<'a> {
fn from(spans: Vec<Span<'a>>) -> Spans<'a> {
Spans(spans)
}
}
impl<'a> From<Span<'a>> for Spans<'a> {
fn from(span: Span<'a>) -> Spans<'a> {
Spans(vec![span])
}
}
impl<'a> From<Spans<'a>> for String {
fn from(line: Spans<'a>) -> String {
line.0.iter().fold(String::new(), |mut acc, s| {
acc.push_str(s.content.as_ref());
acc
})
fn set_style(self, style: Style) -> Self {
Self::styled(self.content, style)
}
}
@@ -257,8 +257,8 @@ impl<'a> From<Spans<'a>> for String {
/// [`core::iter::Extend`] which enables the concatenation of several [`Text`] blocks.
///
/// ```rust
/// # use tui::text::Text;
/// # use tui::style::{Color, Modifier, Style};
/// # use ratatui::text::Text;
/// # use ratatui::style::{Color, Modifier, Style};
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
///
/// // An initial two lines of `Text` built from a `&str`
@@ -273,9 +273,9 @@ impl<'a> From<Spans<'a>> for String {
/// text.extend(Text::styled("Some more lines\nnow with more style!", style));
/// assert_eq!(6, text.height());
/// ```
#[derive(Debug, Clone, PartialEq, Default)]
#[derive(Debug, Clone, PartialEq, Default, Eq)]
pub struct Text<'a> {
pub lines: Vec<Spans<'a>>,
pub lines: Vec<Line<'a>>,
}
impl<'a> Text<'a> {
@@ -284,7 +284,7 @@ impl<'a> Text<'a> {
/// ## Examples
///
/// ```rust
/// # use tui::text::Text;
/// # use ratatui::text::Text;
/// Text::raw("The first line\nThe second line");
/// Text::raw(String::from("The first line\nThe second line"));
/// ```
@@ -292,12 +292,14 @@ impl<'a> 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(),
},
}
let lines: Vec<_> = match content.into() {
Cow::Borrowed("") => vec![Line::from("")],
Cow::Borrowed(s) => s.lines().map(Line::from).collect(),
Cow::Owned(s) if s.is_empty() => vec![Line::from("")],
Cow::Owned(s) => s.lines().map(|l| Line::from(l.to_owned())).collect(),
};
Text::from(lines)
}
/// Create some text (potentially multiple lines) with a style.
@@ -305,8 +307,8 @@ impl<'a> Text<'a> {
/// # Examples
///
/// ```rust
/// # use tui::text::Text;
/// # use tui::style::{Color, Modifier, Style};
/// # use ratatui::text::Text;
/// # use ratatui::style::{Color, Modifier, Style};
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
/// Text::styled("The first line\nThe second line", style);
/// Text::styled(String::from("The first line\nThe second line"), style);
@@ -325,16 +327,12 @@ impl<'a> Text<'a> {
/// ## Examples
///
/// ```rust
/// use tui::text::Text;
/// use ratatui::text::Text;
/// let text = Text::from("The first line\nThe second line");
/// assert_eq!(15, text.width());
/// ```
pub fn width(&self) -> usize {
self.lines
.iter()
.map(Spans::width)
.max()
.unwrap_or_default()
self.lines.iter().map(Line::width).max().unwrap_or_default()
}
/// Returns the height.
@@ -342,7 +340,7 @@ impl<'a> Text<'a> {
/// ## Examples
///
/// ```rust
/// use tui::text::Text;
/// use ratatui::text::Text;
/// let text = Text::from("The first line\nThe second line");
/// assert_eq!(2, text.height());
/// ```
@@ -350,13 +348,13 @@ impl<'a> Text<'a> {
self.lines.len()
}
/// Apply a new style to existing text.
/// Patches the style of each line in an existing Text, adding modifiers from the given style.
///
/// # Examples
///
/// ```rust
/// # use tui::text::Text;
/// # use tui::style::{Color, Modifier, Style};
/// # use ratatui::text::Text;
/// # use ratatui::style::{Color, Modifier, Style};
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
/// let mut raw_text = Text::raw("The first line\nThe second line");
/// let styled_text = Text::styled(String::from("The first line\nThe second line"), style);
@@ -367,9 +365,31 @@ impl<'a> Text<'a> {
/// ```
pub fn patch_style(&mut self, style: Style) {
for line in &mut self.lines {
for span in &mut line.0 {
span.style = span.style.patch(style);
}
line.patch_style(style);
}
}
/// Resets the style of the Text.
/// Equivalent to calling `patch_style(Style::reset())`.
///
/// ## Examples
///
/// ```rust
/// # use ratatui::text::{Span, Line, Text};
/// # use ratatui::style::{Color, Style, Modifier};
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
/// let mut text = Text::styled("The first line\nThe second line", style);
///
/// text.reset_style();
/// for line in &text.lines {
/// for span in &line.spans {
/// assert_eq!(Style::reset(), span.style);
/// }
/// }
/// ```
pub fn reset_style(&mut self) {
for line in &mut self.lines {
line.reset_style();
}
}
}
@@ -395,25 +415,43 @@ impl<'a> From<Cow<'a, str>> for Text<'a> {
impl<'a> From<Span<'a>> for Text<'a> {
fn from(span: Span<'a>) -> Text<'a> {
Text {
lines: vec![Spans::from(span)],
lines: vec![Line::from(span)],
}
}
}
#[allow(deprecated)]
impl<'a> From<Spans<'a>> for Text<'a> {
fn from(spans: Spans<'a>) -> Text<'a> {
Text { lines: vec![spans] }
Text {
lines: vec![spans.into()],
}
}
}
impl<'a> From<Line<'a>> for Text<'a> {
fn from(line: Line<'a>) -> Text<'a> {
Text { lines: vec![line] }
}
}
#[allow(deprecated)]
impl<'a> From<Vec<Spans<'a>>> for Text<'a> {
fn from(lines: Vec<Spans<'a>>) -> Text<'a> {
Text {
lines: lines.into_iter().map(|l| l.0.into()).collect(),
}
}
}
impl<'a> From<Vec<Line<'a>>> for Text<'a> {
fn from(lines: Vec<Line<'a>>) -> Text<'a> {
Text { lines }
}
}
impl<'a> IntoIterator for Text<'a> {
type Item = Spans<'a>;
type Item = Line<'a>;
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
@@ -421,8 +459,12 @@ impl<'a> IntoIterator for Text<'a> {
}
}
impl<'a> Extend<Spans<'a>> for Text<'a> {
fn extend<T: IntoIterator<Item = Spans<'a>>>(&mut self, iter: T) {
self.lines.extend(iter);
impl<'a, T> Extend<T> for Text<'a>
where
T: Into<Line<'a>>,
{
fn extend<I: IntoIterator<Item = T>>(&mut self, iter: I) {
let lines = iter.into_iter().map(Into::into);
self.lines.extend(lines);
}
}

251
src/text/line.rs Normal file
View File

@@ -0,0 +1,251 @@
#![allow(deprecated)]
use super::{Span, Spans, Style};
use crate::layout::Alignment;
#[derive(Debug, Clone, PartialEq, Default, Eq)]
pub struct Line<'a> {
pub spans: Vec<Span<'a>>,
pub alignment: Option<Alignment>,
}
impl<'a> Line<'a> {
/// Returns the width of the underlying string.
///
/// ## Examples
///
/// ```rust
/// # use ratatui::text::{Span, Line};
/// # use ratatui::style::{Color, Style};
/// let line = Line::from(vec![
/// Span::styled("My", Style::default().fg(Color::Yellow)),
/// Span::raw(" text"),
/// ]);
/// assert_eq!(7, line.width());
/// ```
pub fn width(&self) -> usize {
self.spans.iter().map(Span::width).sum()
}
/// Patches the style of each Span in an existing Line, adding modifiers from the given style.
///
/// ## Examples
///
/// ```rust
/// # use ratatui::text::{Span, Line};
/// # use ratatui::style::{Color, Style, Modifier};
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
/// let mut raw_line = Line::from(vec![
/// Span::raw("My"),
/// Span::raw(" text"),
/// ]);
/// let mut styled_line = Line::from(vec![
/// Span::styled("My", style),
/// Span::styled(" text", style),
/// ]);
///
/// assert_ne!(raw_line, styled_line);
///
/// raw_line.patch_style(style);
/// assert_eq!(raw_line, styled_line);
/// ```
pub fn patch_style(&mut self, style: Style) {
for span in &mut self.spans {
span.patch_style(style);
}
}
/// Resets the style of each Span in the Line.
/// Equivalent to calling `patch_style(Style::reset())`.
///
/// ## Examples
///
/// ```rust
/// # use ratatui::text::{Span, Line};
/// # use ratatui::style::{Color, Style, Modifier};
/// let mut line = Line::from(vec![
/// Span::styled("My", Style::default().fg(Color::Yellow)),
/// Span::styled(" text", Style::default().add_modifier(Modifier::BOLD)),
/// ]);
///
/// line.reset_style();
/// assert_eq!(Style::reset(), line.spans[0].style);
/// assert_eq!(Style::reset(), line.spans[1].style);
/// ```
pub fn reset_style(&mut self) {
for span in &mut self.spans {
span.reset_style();
}
}
/// Sets the target alignment for this line of text.
/// Defaults to: [`None`], meaning the alignment is determined by the rendering widget.
///
/// ## Examples
///
/// ```rust
/// # use std::borrow::Cow;
/// # use ratatui::layout::Alignment;
/// # use ratatui::text::{Span, Line};
/// # use ratatui::style::{Color, Style, Modifier};
/// let mut line = Line::from("Hi, what's up?");
/// assert_eq!(None, line.alignment);
/// assert_eq!(Some(Alignment::Right), line.alignment(Alignment::Right).alignment)
/// ```
pub fn alignment(self, alignment: Alignment) -> Self {
Self {
alignment: Some(alignment),
..self
}
}
}
impl<'a> From<String> for Line<'a> {
fn from(s: String) -> Self {
Self::from(vec![Span::from(s)])
}
}
impl<'a> From<&'a str> for Line<'a> {
fn from(s: &'a str) -> Self {
Self::from(vec![Span::from(s)])
}
}
impl<'a> From<Vec<Span<'a>>> for Line<'a> {
fn from(spans: Vec<Span<'a>>) -> Self {
Self {
spans,
..Default::default()
}
}
}
impl<'a> From<Span<'a>> for Line<'a> {
fn from(span: Span<'a>) -> Self {
Self::from(vec![span])
}
}
impl<'a> From<Line<'a>> for String {
fn from(line: Line<'a>) -> String {
line.spans.iter().fold(String::new(), |mut acc, s| {
acc.push_str(s.content.as_ref());
acc
})
}
}
impl<'a> From<Spans<'a>> for Line<'a> {
fn from(value: Spans<'a>) -> Self {
Self::from(value.0)
}
}
#[cfg(test)]
mod tests {
use crate::{
layout::Alignment,
style::{Color, Modifier, Style},
text::{Line, Span, Spans},
};
#[test]
fn test_width() {
let line = Line::from(vec![
Span::styled("My", Style::default().fg(Color::Yellow)),
Span::raw(" text"),
]);
assert_eq!(7, line.width());
let empty_line = Line::default();
assert_eq!(0, empty_line.width());
}
#[test]
fn test_patch_style() {
let style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC);
let mut raw_line = Line::from(vec![Span::raw("My"), Span::raw(" text")]);
let styled_line = Line::from(vec![
Span::styled("My", style),
Span::styled(" text", style),
]);
assert_ne!(raw_line, styled_line);
raw_line.patch_style(style);
assert_eq!(raw_line, styled_line);
}
#[test]
fn test_reset_style() {
let mut line = Line::from(vec![
Span::styled("My", Style::default().fg(Color::Yellow)),
Span::styled(" text", Style::default().add_modifier(Modifier::BOLD)),
]);
line.reset_style();
assert_eq!(Style::reset(), line.spans[0].style);
assert_eq!(Style::reset(), line.spans[1].style);
}
#[test]
fn test_from_string() {
let s = String::from("Hello, world!");
let line = Line::from(s);
assert_eq!(vec![Span::from("Hello, world!")], line.spans);
}
#[test]
fn test_from_str() {
let s = "Hello, world!";
let line = Line::from(s);
assert_eq!(vec![Span::from("Hello, world!")], line.spans);
}
#[test]
fn test_from_vec() {
let spans = vec![
Span::styled("Hello,", Style::default().fg(Color::Red)),
Span::styled(" world!", Style::default().fg(Color::Green)),
];
let line = Line::from(spans.clone());
assert_eq!(spans, line.spans);
}
#[test]
fn test_from_span() {
let span = Span::styled("Hello, world!", Style::default().fg(Color::Yellow));
let line = Line::from(span.clone());
assert_eq!(vec![span], line.spans);
}
#[test]
fn test_from_spans() {
let spans = vec![
Span::styled("Hello,", Style::default().fg(Color::Red)),
Span::styled(" world!", Style::default().fg(Color::Green)),
];
assert_eq!(Line::from(Spans::from(spans.clone())), Line::from(spans));
}
#[test]
fn test_into_string() {
let line = Line::from(vec![
Span::styled("Hello,", Style::default().fg(Color::Red)),
Span::styled(" world!", Style::default().fg(Color::Green)),
]);
let s: String = line.into();
assert_eq!("Hello, world!", s);
}
#[test]
fn test_alignment() {
let line = Line::from("This is left").alignment(Alignment::Left);
assert_eq!(Some(Alignment::Left), line.alignment);
let line = Line::from("This is default");
assert_eq!(None, line.alignment);
}
}

128
src/text/masked.rs Normal file
View File

@@ -0,0 +1,128 @@
use std::{
borrow::Cow,
fmt::{self, Debug, Display},
};
use super::Text;
/// A wrapper around a string that is masked when displayed.
///
/// The masked string is displayed as a series of the same character.
/// This might be used to display a password field or similar secure data.
///
/// # Examples
///
/// ```rust
/// use ratatui::{buffer::Buffer, layout::Rect, text::Masked, widgets::{Paragraph, Widget}};
///
/// let mut buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
/// let password = Masked::new("12345", 'x');
///
/// Paragraph::new(password).render(buffer.area, &mut buffer);
/// assert_eq!(buffer, Buffer::with_lines(vec!["xxxxx"]));
/// ```
#[derive(Clone)]
pub struct Masked<'a> {
inner: Cow<'a, str>,
mask_char: char,
}
impl<'a> Masked<'a> {
pub fn new(s: impl Into<Cow<'a, str>>, mask_char: char) -> Self {
Self {
inner: s.into(),
mask_char,
}
}
/// The character to use for masking.
pub fn mask_char(&self) -> char {
self.mask_char
}
/// The underlying string, with all characters masked.
pub fn value(&self) -> Cow<'a, str> {
self.inner.chars().map(|_| self.mask_char).collect()
}
}
impl Debug for Masked<'_> {
/// Debug representation of a masked string is the underlying string
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.inner).map_err(|_| fmt::Error)
}
}
impl Display for Masked<'_> {
/// Display representation of a masked string is the masked string
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.value()).map_err(|_| fmt::Error)
}
}
impl<'a> From<&'a Masked<'a>> for Cow<'a, str> {
fn from(masked: &'a Masked) -> Cow<'a, str> {
masked.value()
}
}
impl<'a> From<Masked<'a>> for Cow<'a, str> {
fn from(masked: Masked<'a>) -> Cow<'a, str> {
masked.value()
}
}
impl<'a> From<&'a Masked<'_>> for Text<'a> {
fn from(masked: &'a Masked) -> Text<'a> {
Text::raw(masked.value())
}
}
impl<'a> From<Masked<'a>> for Text<'a> {
fn from(masked: Masked<'a>) -> Text<'a> {
Text::raw(masked.value())
}
}
#[cfg(test)]
mod tests {
use std::borrow::Borrow;
use super::*;
use crate::text::Line;
#[test]
fn test_masked_value() {
let masked = Masked::new("12345", 'x');
assert_eq!(masked.value(), "xxxxx");
}
#[test]
fn test_masked_debug() {
let masked = Masked::new("12345", 'x');
assert_eq!(format!("{masked:?}"), "12345");
}
#[test]
fn test_masked_display() {
let masked = Masked::new("12345", 'x');
assert_eq!(format!("{masked}"), "xxxxx");
}
#[test]
fn test_masked_conversions() {
let masked = Masked::new("12345", 'x');
let text: Text = masked.borrow().into();
assert_eq!(text.lines, vec![Line::from("xxxxx")]);
let text: Text = masked.to_owned().into();
assert_eq!(text.lines, vec![Line::from("xxxxx")]);
let cow: Cow<str> = masked.borrow().into();
assert_eq!(cow, "xxxxx");
let cow: Cow<str> = masked.to_owned().into();
assert_eq!(cow, "xxxxx");
}
}

225
src/text/spans.rs Normal file
View File

@@ -0,0 +1,225 @@
#![allow(deprecated)]
use super::{Span, Style};
use crate::{layout::Alignment, text::Line};
/// A string composed of clusters of graphemes, each with their own style.
///
/// `Spans` has been deprecated in favor of `Line`, and will be removed in the
/// future. All methods that accept Spans have been replaced with methods that
/// accept Into<Line<'a>> (which is implemented on `Spans`) to allow users of
/// this crate to gradually transition to Line.
#[derive(Debug, Clone, PartialEq, Default, Eq)]
#[deprecated(note = "Use `ratatui::text::Line` instead")]
pub struct Spans<'a>(pub Vec<Span<'a>>);
impl<'a> Spans<'a> {
/// Returns the width of the underlying string.
///
/// ## Examples
///
/// ```rust
/// # use ratatui::text::{Span, Spans};
/// # use ratatui::style::{Color, Style};
/// let spans = Spans::from(vec![
/// Span::styled("My", Style::default().fg(Color::Yellow)),
/// Span::raw(" text"),
/// ]);
/// assert_eq!(7, spans.width());
/// ```
pub fn width(&self) -> usize {
self.0.iter().map(Span::width).sum()
}
/// Patches the style of each Span in an existing Spans, adding modifiers from the given style.
///
/// ## Examples
///
/// ```rust
/// # use ratatui::text::{Span, Spans};
/// # use ratatui::style::{Color, Style, Modifier};
/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC);
/// let mut raw_spans = Spans::from(vec![
/// Span::raw("My"),
/// Span::raw(" text"),
/// ]);
/// let mut styled_spans = Spans::from(vec![
/// Span::styled("My", style),
/// Span::styled(" text", style),
/// ]);
///
/// assert_ne!(raw_spans, styled_spans);
///
/// raw_spans.patch_style(style);
/// assert_eq!(raw_spans, styled_spans);
/// ```
pub fn patch_style(&mut self, style: Style) {
for span in &mut self.0 {
span.patch_style(style);
}
}
/// Resets the style of each Span in the Spans.
/// Equivalent to calling `patch_style(Style::reset())`.
///
/// ## Examples
///
/// ```rust
/// # use ratatui::text::{Span, Spans};
/// # use ratatui::style::{Color, Style, Modifier};
/// let mut spans = Spans::from(vec![
/// Span::styled("My", Style::default().fg(Color::Yellow)),
/// Span::styled(" text", Style::default().add_modifier(Modifier::BOLD)),
/// ]);
///
/// spans.reset_style();
/// assert_eq!(Style::reset(), spans.0[0].style);
/// assert_eq!(Style::reset(), spans.0[1].style);
/// ```
pub fn reset_style(&mut self) {
for span in &mut self.0 {
span.reset_style();
}
}
/// Sets the target alignment for this line of text.
/// Defaults to: [`None`], meaning the alignment is determined by the rendering widget.
///
/// ## Examples
///
/// ```rust
/// # use std::borrow::Cow;
/// # use ratatui::layout::Alignment;
/// # use ratatui::text::{Span, Spans};
/// # use ratatui::style::{Color, Style, Modifier};
/// let mut line = Spans::from("Hi, what's up?").alignment(Alignment::Right);
/// assert_eq!(Some(Alignment::Right), line.alignment)
/// ```
pub fn alignment(self, alignment: Alignment) -> Line<'a> {
let line = Line::from(self);
line.alignment(alignment)
}
}
impl<'a> From<String> for Spans<'a> {
fn from(s: String) -> Spans<'a> {
Spans(vec![Span::from(s)])
}
}
impl<'a> From<&'a str> for Spans<'a> {
fn from(s: &'a str) -> Spans<'a> {
Spans(vec![Span::from(s)])
}
}
impl<'a> From<Vec<Span<'a>>> for Spans<'a> {
fn from(spans: Vec<Span<'a>>) -> Spans<'a> {
Spans(spans)
}
}
impl<'a> From<Span<'a>> for Spans<'a> {
fn from(span: Span<'a>) -> Spans<'a> {
Spans(vec![span])
}
}
impl<'a> From<Spans<'a>> for String {
fn from(line: Spans<'a>) -> String {
line.0.iter().fold(String::new(), |mut acc, s| {
acc.push_str(s.content.as_ref());
acc
})
}
}
#[cfg(test)]
mod tests {
use crate::{
style::{Color, Modifier, Style},
text::{Span, Spans},
};
#[test]
fn test_width() {
let spans = Spans::from(vec![
Span::styled("My", Style::default().fg(Color::Yellow)),
Span::raw(" text"),
]);
assert_eq!(7, spans.width());
let empty_spans = Spans::default();
assert_eq!(0, empty_spans.width());
}
#[test]
fn test_patch_style() {
let style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC);
let mut raw_spans = Spans::from(vec![Span::raw("My"), Span::raw(" text")]);
let styled_spans = Spans::from(vec![
Span::styled("My", style),
Span::styled(" text", style),
]);
assert_ne!(raw_spans, styled_spans);
raw_spans.patch_style(style);
assert_eq!(raw_spans, styled_spans);
}
#[test]
fn test_reset_style() {
let mut spans = Spans::from(vec![
Span::styled("My", Style::default().fg(Color::Yellow)),
Span::styled(" text", Style::default().add_modifier(Modifier::BOLD)),
]);
spans.reset_style();
assert_eq!(Style::reset(), spans.0[0].style);
assert_eq!(Style::reset(), spans.0[1].style);
}
#[test]
fn test_from_string() {
let s = String::from("Hello, world!");
let spans = Spans::from(s);
assert_eq!(vec![Span::from("Hello, world!")], spans.0);
}
#[test]
fn test_from_str() {
let s = "Hello, world!";
let spans = Spans::from(s);
assert_eq!(vec![Span::from("Hello, world!")], spans.0);
}
#[test]
fn test_from_vec() {
let spans_vec = vec![
Span::styled("Hello,", Style::default().fg(Color::Red)),
Span::styled(" world!", Style::default().fg(Color::Green)),
];
let spans = Spans::from(spans_vec.clone());
assert_eq!(spans_vec, spans.0);
}
#[test]
fn test_from_span() {
let span = Span::styled("Hello, world!", Style::default().fg(Color::Yellow));
let spans = Spans::from(span.clone());
assert_eq!(vec![span], spans.0);
}
#[test]
fn test_into_string() {
let spans = Spans::from(vec![
Span::styled("Hello,", Style::default().fg(Color::Red)),
Span::styled(" world!", Style::default().fg(Color::Green)),
]);
let s: String = spans.into();
assert_eq!("Hello, world!", s);
}
}

57
src/title.rs Normal file
View File

@@ -0,0 +1,57 @@
use crate::{layout::Alignment, text::Line};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Title<'a> {
pub content: Line<'a>,
/// Defaults to Left if unset
pub alignment: Option<Alignment>,
/// Defaults to Top if unset
pub position: Option<Position>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Position {
#[default]
Top,
Bottom,
}
impl<'a> Title<'a> {
pub fn content<T>(mut self, content: T) -> Title<'a>
where
T: Into<Line<'a>>,
{
self.content = content.into();
self
}
pub fn alignment(mut self, alignment: Alignment) -> Title<'a> {
self.alignment = Some(alignment);
self
}
pub fn position(mut self, position: Position) -> Title<'a> {
self.position = Some(position);
self
}
}
impl<'a, T> From<T> for Title<'a>
where
T: Into<Line<'a>>,
{
fn from(value: T) -> Self {
Self::default().content(value.into())
}
}
impl<'a> Default for Title<'a> {
fn default() -> Self {
Self {
content: Line::from(""),
alignment: Some(Alignment::Left),
position: Some(Position::Top),
}
}
}

View File

@@ -1,3 +1,7 @@
use std::cmp::min;
use unicode_width::UnicodeWidthStr;
use crate::{
buffer::Buffer,
layout::Rect,
@@ -5,16 +9,14 @@ use crate::{
symbols,
widgets::{Block, Widget},
};
use std::cmp::min;
use unicode_width::UnicodeWidthStr;
/// Display multiple bars in a single widgets
///
/// # Examples
///
/// ```
/// # use tui::widgets::{Block, Borders, BarChart};
/// # use tui::style::{Style, Color, Modifier};
/// # use ratatui::widgets::{Block, Borders, BarChart};
/// # use ratatui::style::{Style, Color, Modifier};
/// BarChart::default()
/// .block(Block::default().title("BarChart").borders(Borders::ALL))
/// .bar_width(3)
@@ -63,9 +65,9 @@ impl<'a> Default for BarChart<'a> {
bar_width: 1,
bar_gap: 1,
bar_set: symbols::bar::NINE_LEVELS,
value_style: Default::default(),
label_style: Default::default(),
style: Default::default(),
value_style: Style::default(),
label_style: Style::default(),
style: Style::default(),
}
}
}
@@ -75,7 +77,7 @@ impl<'a> BarChart<'a> {
self.data = data;
self.values = Vec::with_capacity(self.data.len());
for &(_, v) in self.data {
self.values.push(format!("{}", v));
self.values.push(format!("{v}"));
}
self
}

View File

@@ -1,13 +1,16 @@
#[path = "../title.rs"]
pub mod title;
use self::title::{Position, Title};
use crate::{
buffer::Buffer,
layout::{Alignment, Rect},
style::Style,
style::{Style, Styled},
symbols::line,
text::{Span, Spans},
widgets::{Borders, Widget},
};
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BorderType {
Plain,
Rounded,
@@ -16,7 +19,7 @@ pub enum BorderType {
}
impl BorderType {
pub fn line_symbols(border_type: BorderType) -> line::Set {
pub const fn line_symbols(border_type: BorderType) -> line::Set {
match border_type {
BorderType::Plain => line::NORMAL,
BorderType::Rounded => line::ROUNDED,
@@ -26,14 +29,69 @@ impl BorderType {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Padding {
pub left: u16,
pub right: u16,
pub top: u16,
pub bottom: u16,
}
impl Padding {
pub const fn new(left: u16, right: u16, top: u16, bottom: u16) -> Self {
Padding {
left,
right,
top,
bottom,
}
}
pub const fn zero() -> Self {
Padding {
left: 0,
right: 0,
top: 0,
bottom: 0,
}
}
pub const fn horizontal(value: u16) -> Self {
Padding {
left: value,
right: value,
top: 0,
bottom: 0,
}
}
pub const fn vertical(value: u16) -> Self {
Padding {
left: 0,
right: 0,
top: value,
bottom: value,
}
}
pub const fn uniform(value: u16) -> Self {
Padding {
left: value,
right: value,
top: value,
bottom: value,
}
}
}
/// Base widget to be used with all upper level ones. It may be used to display a box border around
/// the widget and/or add a title.
///
/// # Examples
///
/// ```
/// # use tui::widgets::{Block, BorderType, Borders};
/// # use tui::style::{Style, Color};
/// # use ratatui::widgets::{Block, BorderType, Borders};
/// # use ratatui::style::{Style, Color};
/// Block::default()
/// .title("Block")
/// .borders(Borders::LEFT | Borders::RIGHT)
@@ -41,91 +99,204 @@ impl BorderType {
/// .border_type(BorderType::Rounded)
/// .style(Style::default().bg(Color::Black));
/// ```
#[derive(Debug, Clone, PartialEq)]
///
/// You may also use multiple titles like in the following:
/// ```
/// # use ratatui::widgets::{Block, BorderType, Borders, block::title::{Position, Title}};
/// # use ratatui::style::{Style, Color};
/// Block::default()
/// .title("Title 1")
/// .title(Title::from("Title 2").position(Position::Bottom))
/// .borders(Borders::LEFT | Borders::RIGHT)
/// .border_style(Style::default().fg(Color::White))
/// .border_type(BorderType::Rounded)
/// .style(Style::default().bg(Color::Black));
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Block<'a> {
/// Optional title place on the upper left of the block
title: Option<Spans<'a>>,
/// Title alignment. The default is top left of the block, but one can choose to place
/// title in the top middle, or top right of the block
title_alignment: Alignment,
/// List of titles
titles: Vec<Title<'a>>,
/// The style to be patched to all titles of the block
titles_style: Style,
/// The default alignment of the titles that don't have one
titles_alignment: Alignment,
/// The default position of the titles that don't have one
titles_position: Position,
/// Visible borders
borders: Borders,
/// Border style
border_style: Style,
/// Type of the border. The default is plain lines but one can choose to have rounded corners
/// or doubled lines instead.
/// Type of the border. The default is plain lines but one can choose to have rounded or
/// doubled lines instead.
border_type: BorderType,
/// Widget style
style: Style,
/// Block padding
padding: Padding,
}
impl<'a> Default for Block<'a> {
fn default() -> Block<'a> {
Block {
title: None,
title_alignment: Alignment::Left,
borders: Borders::NONE,
border_style: Default::default(),
border_type: BorderType::Plain,
style: Default::default(),
}
Block::new()
}
}
impl<'a> Block<'a> {
pub const fn new() -> Self {
Self {
titles: Vec::new(),
titles_style: Style::new(),
titles_alignment: Alignment::Left,
titles_position: Position::Top,
borders: Borders::NONE,
border_style: Style::new(),
border_type: BorderType::Plain,
style: Style::new(),
padding: Padding::zero(),
}
}
/// # Example
/// ```
/// # use ratatui::widgets::{Block, block::title::Title};
/// # use ratatui::layout::Alignment;
/// Block::default()
/// .title("Title") // By default in the top right corner
/// .title(Title::from("Left").alignment(Alignment::Left))
/// .title(
/// Title::from("Center")
/// .alignment(Alignment::Center),
/// );
/// ```
/// Adds a title to the block.
///
/// The `title` function allows you to add a title to the block. You can call this function
/// multiple times to add multiple titles.
///
/// Each title will be rendered with a single space separating titles that are in the same
/// position or alignment. When both centered and non-centered titles are rendered, the centered
/// space is calculated based on the full width of the block, rather than the leftover width.
///
/// You can provide various types as the title, including strings, string slices, borrowed
/// strings (`Cow<str>`), spans, or vectors of spans (`Vec<Span>`).
///
/// By default, the titles will avoid being rendered in the corners of the block but will align
/// against the left or right edge of the block if there is no border on that edge.
///
/// Note: If the block is too small and multiple titles overlap, the border might get cut off at
/// a corner.
pub fn title<T>(mut self, title: T) -> Block<'a>
where
T: Into<Spans<'a>>,
T: Into<Title<'a>>,
{
self.title = Some(title.into());
self.titles.push(title.into());
self
}
#[deprecated(
since = "0.10.0",
note = "You should use styling capabilities of `text::Spans` given as argument of the `title` method to apply styling to the title."
)]
pub fn title_style(mut self, style: Style) -> Block<'a> {
if let Some(t) = self.title {
let title = String::from(t);
self.title = Some(Spans::from(Span::styled(title, style)));
}
/// Applies the style to all titles. If a title already has a style, it will add on top of it.
pub const fn title_style(mut self, style: Style) -> Block<'a> {
self.titles_style = style;
self
}
pub fn title_alignment(mut self, alignment: Alignment) -> Block<'a> {
self.title_alignment = alignment;
/// Aligns all elements that don't have an alignment
/// # Example
/// This example aligns all titles in the center except "right" title
/// ```
/// # use ratatui::widgets::{Block, block::title::Title};
/// # use ratatui::layout::Alignment;
/// Block::default()
/// // This title won't be aligned in the center
/// .title(Title::from("right").alignment(Alignment::Right))
/// .title("foo")
/// .title("bar")
/// .title_alignment(Alignment::Center);
/// ```
pub const fn title_alignment(mut self, alignment: Alignment) -> Block<'a> {
self.titles_alignment = alignment;
self
}
pub fn border_style(mut self, style: Style) -> Block<'a> {
#[deprecated(since = "0.22.0", note = "You should use a `title_position` instead.")]
/// This method just calls `title_position` with Position::Bottom
pub fn title_on_bottom(self) -> Block<'a> {
self.title_position(Position::Bottom)
}
/// Positions all titles that don't have a position
/// # Example
/// This example position all titles on the bottom except "top" title
/// ```
/// # use ratatui::widgets::{Block, BorderType, Borders, block::title::{Position, Title}};
/// Block::default()
/// // This title won't be aligned in the center
/// .title(Title::from("top").position(Position::Top))
/// .title("foo")
/// .title("bar")
/// .title_position(Position::Bottom);
/// ```
pub const fn title_position(mut self, position: Position) -> Block<'a> {
self.titles_position = position;
self
}
pub const fn border_style(mut self, style: Style) -> Block<'a> {
self.border_style = style;
self
}
pub fn style(mut self, style: Style) -> Block<'a> {
pub const fn style(mut self, style: Style) -> Block<'a> {
self.style = style;
self
}
pub fn borders(mut self, flag: Borders) -> Block<'a> {
pub const fn borders(mut self, flag: Borders) -> Block<'a> {
self.borders = flag;
self
}
pub fn border_type(mut self, border_type: BorderType) -> Block<'a> {
pub const fn border_type(mut self, border_type: BorderType) -> Block<'a> {
self.border_type = border_type;
self
}
/// Compute the inner area of a block based on its border visibility rules.
///
/// # Examples
///
/// ```
/// // Draw a block nested within another block
/// use ratatui::{backend::TestBackend, buffer::Buffer, terminal::Terminal, widgets::{Block, Borders}};
/// let backend = TestBackend::new(15, 5);
/// let mut terminal = Terminal::new(backend).unwrap();
/// let outer_block = Block::default()
/// .title("Outer Block")
/// .borders(Borders::ALL);
/// let inner_block = Block::default()
/// .title("Inner Block")
/// .borders(Borders::ALL);
/// terminal.draw(|f| {
/// let inner_area = outer_block.inner(f.size());
/// f.render_widget(outer_block, f.size());
/// f.render_widget(inner_block, inner_area);
/// });
/// let expected = Buffer::with_lines(vec![
/// "┌Outer Block──┐",
/// "│┌Inner Block┐│",
/// "││ ││",
/// "│└───────────┘│",
/// "└─────────────┘",
/// ]);
/// terminal.backend().assert_buffer(&expected);
/// ```
pub fn inner(&self, area: Rect) -> Rect {
let mut inner = area;
if self.borders.intersects(Borders::LEFT) {
inner.x = inner.x.saturating_add(1).min(inner.right());
inner.width = inner.width.saturating_sub(1);
}
if self.borders.intersects(Borders::TOP) || self.title.is_some() {
if self.borders.intersects(Borders::TOP) || !self.titles.is_empty() {
inner.y = inner.y.saturating_add(1).min(inner.bottom());
inner.height = inner.height.saturating_sub(1);
}
@@ -135,15 +306,26 @@ impl<'a> Block<'a> {
if self.borders.intersects(Borders::BOTTOM) {
inner.height = inner.height.saturating_sub(1);
}
inner.x = inner.x.saturating_add(self.padding.left);
inner.y = inner.y.saturating_add(self.padding.top);
inner.width = inner
.width
.saturating_sub(self.padding.left + self.padding.right);
inner.height = inner
.height
.saturating_sub(self.padding.top + self.padding.bottom);
inner
}
}
impl<'a> Widget for Block<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.area() == 0 {
return;
}
pub const fn padding(mut self, padding: Padding) -> Block<'a> {
self.padding = padding;
self
}
fn render_borders(&self, area: Rect, buf: &mut Buffer) {
buf.set_style(area, self.style);
let symbols = BorderType::line_symbols(self.border_type);
@@ -200,41 +382,133 @@ impl<'a> Widget for Block<'a> {
.set_symbol(symbols.top_left)
.set_style(self.border_style);
}
}
// Title
if let Some(title) = self.title {
let left_border_dx = if self.borders.intersects(Borders::LEFT) {
1
} else {
0
};
let right_border_dx = if self.borders.intersects(Borders::RIGHT) {
1
} else {
0
};
let title_area_width = area
.width
.saturating_sub(left_border_dx)
.saturating_sub(right_border_dx);
let title_dx = match self.title_alignment {
Alignment::Left => left_border_dx,
Alignment::Center => area.width.saturating_sub(title.width() as u16) / 2,
Alignment::Right => area
.width
.saturating_sub(title.width() as u16)
.saturating_sub(right_border_dx),
};
let title_x = area.left() + title_dx;
let title_y = area.top();
buf.set_spans(title_x, title_y, &title, title_area_width);
/* Titles Rendering */
fn get_title_y(&self, position: Position, area: Rect) -> u16 {
match position {
Position::Bottom => area.bottom() - 1,
Position::Top => area.top(),
}
}
fn title_filter(&self, title: &Title, alignment: Alignment, position: Position) -> bool {
title.alignment.unwrap_or(self.titles_alignment) == alignment
&& title.position.unwrap_or(self.titles_position) == position
}
fn calculate_title_area_offsets(&self, area: Rect) -> (u16, u16, u16) {
let left_border_dx = u16::from(self.borders.intersects(Borders::LEFT));
let right_border_dx = u16::from(self.borders.intersects(Borders::RIGHT));
let title_area_width = area
.width
.saturating_sub(left_border_dx)
.saturating_sub(right_border_dx);
(left_border_dx, right_border_dx, title_area_width)
}
fn render_left_titles(&self, position: Position, area: Rect, buf: &mut Buffer) {
let (left_border_dx, _, title_area_width) = self.calculate_title_area_offsets(area);
let mut current_offset = left_border_dx;
self.titles
.iter()
.filter(|title| self.title_filter(title, Alignment::Left, position))
.for_each(|title| {
let title_x = current_offset;
current_offset += title.content.width() as u16 + 1;
buf.set_line(
title_x + area.left(),
self.get_title_y(position, area),
&title.content,
title_area_width,
);
});
}
fn render_center_titles(&self, position: Position, area: Rect, buf: &mut Buffer) {
let (_, _, title_area_width) = self.calculate_title_area_offsets(area);
let titles = self
.titles
.iter()
.filter(|title| self.title_filter(title, Alignment::Center, position));
let titles_sum = titles
.clone()
.fold(-1, |acc, f| acc + f.content.width() as i16 + 1); // First element isn't spaced
let mut current_offset = area.width.saturating_sub(titles_sum as u16) / 2;
titles.for_each(|title| {
let title_x = current_offset;
current_offset += title.content.width() as u16 + 1;
buf.set_line(
title_x + area.left(),
self.get_title_y(position, area),
&title.content,
title_area_width,
);
});
}
fn render_right_titles(&self, position: Position, area: Rect, buf: &mut Buffer) {
let (_, right_border_dx, title_area_width) = self.calculate_title_area_offsets(area);
let mut current_offset = right_border_dx;
self.titles
.iter()
.filter(|title| self.title_filter(title, Alignment::Right, position))
.rev() // so that the titles appear in the order they have been set
.for_each(|title| {
current_offset += title.content.width() as u16 + 1;
let title_x = current_offset - 1; // First element isn't spaced
buf.set_line(
area.width.saturating_sub(title_x) + area.left(),
self.get_title_y(position, area),
&title.content,
title_area_width,
);
});
}
fn render_title_position(&self, position: Position, area: Rect, buf: &mut Buffer) {
// Note: the order in which these functions are called define the overlapping behavior
self.render_right_titles(position, area, buf);
self.render_center_titles(position, area, buf);
self.render_left_titles(position, area, buf);
}
fn render_titles(&self, area: Rect, buf: &mut Buffer) {
self.render_title_position(Position::Top, area, buf);
self.render_title_position(Position::Bottom, area, buf);
}
}
impl<'a> Widget for Block<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.area() == 0 {
return;
}
self.render_borders(area, buf);
self.render_titles(area, buf);
}
}
impl<'a> Styled for Block<'a> {
type Item = Block<'a>;
fn style(&self) -> Style {
self.style
}
fn set_style(self, style: Style) -> Self::Item {
self.style(style)
}
}
#[cfg(test)]
@@ -537,8 +811,7 @@ mod tests {
);
assert_eq!(
Block::default()
.title("Test")
.title_alignment(Alignment::Center)
.title(Title::from("Test").alignment(Alignment::Center))
.inner(Rect {
x: 0,
y: 0,
@@ -554,8 +827,7 @@ mod tests {
);
assert_eq!(
Block::default()
.title("Test")
.title_alignment(Alignment::Right)
.title(Title::from("Test").alignment(Alignment::Right))
.inner(Rect {
x: 0,
y: 0,
@@ -570,4 +842,34 @@ mod tests {
},
);
}
#[test]
fn border_type_can_be_const() {
const _PLAIN: line::Set = BorderType::line_symbols(BorderType::Plain);
}
#[test]
fn padding_can_be_const() {
const PADDING: Padding = Padding::new(1, 1, 1, 1);
const UNI_PADDING: Padding = Padding::uniform(1);
assert_eq!(PADDING, UNI_PADDING);
const _NO_PADDING: Padding = Padding::zero();
const _HORIZONTAL: Padding = Padding::horizontal(1);
const _VERTICAL: Padding = Padding::vertical(1);
}
#[test]
fn block_can_be_const() {
const _DEFAULT_STYLE: Style = Style::new();
const _DEFAULT_PADDING: Padding = Padding::uniform(1);
const _DEFAULT_BLOCK: Block = Block::new()
.title_style(_DEFAULT_STYLE)
.title_alignment(Alignment::Left)
.title_position(Position::Top)
.borders(Borders::ALL)
.border_style(_DEFAULT_STYLE)
.style(_DEFAULT_STYLE)
.padding(_DEFAULT_PADDING);
}
}

246
src/widgets/calendar.rs Normal file
View File

@@ -0,0 +1,246 @@
//! A simple calendar widget. `(feature: widget-calendar)`
//!
//!
//!
//! The [`Monthly`] widget will display a calendar for the monh provided in `display_date`. Days are
//! styled using the default style unless:
//! * `show_surrounding` is set, then days not in the `display_date` month will use that style.
//! * a style is returned by the [`DateStyler`] for the day
//!
//! [`Monthly`] has several controls for what should be displayed
use std::collections::HashMap;
use time::{Date, Duration, OffsetDateTime};
use crate::{
buffer::Buffer,
layout::Rect,
style::Style,
text::Span,
widgets::{Block, Widget},
};
/// Display a month calendar for the month containing `display_date`
pub struct Monthly<'a, S: DateStyler> {
display_date: Date,
events: S,
show_surrounding: Option<Style>,
show_weekday: Option<Style>,
show_month: Option<Style>,
default_style: Style,
block: Option<Block<'a>>,
}
impl<'a, S: DateStyler> Monthly<'a, S> {
/// Construct a calendar for the `display_date` and highlight the `events`
pub fn new(display_date: Date, events: S) -> Self {
Self {
display_date,
events,
show_surrounding: None,
show_weekday: None,
show_month: None,
default_style: Style::default(),
block: None,
}
}
/// Fill the calendar slots for days not in the current month also, this causes each line to be
/// completely filled. If there is an event style for a date, this style will be patched with
/// the event's style
pub fn show_surrounding(mut self, style: Style) -> Self {
self.show_surrounding = Some(style);
self
}
/// Display a header containing weekday abbreviations
pub fn show_weekdays_header(mut self, style: Style) -> Self {
self.show_weekday = Some(style);
self
}
/// Display a header containing the month and year
pub fn show_month_header(mut self, style: Style) -> Self {
self.show_month = Some(style);
self
}
/// How to render otherwise unstyled dates
pub fn default_style(mut self, s: Style) -> Self {
self.default_style = s;
self
}
/// Render the calendar within a [Block](crate::widgets::Block)
pub fn block(mut self, b: Block<'a>) -> Self {
self.block = Some(b);
self
}
/// Return a style with only the background from the default style
fn default_bg(&self) -> Style {
match self.default_style.bg {
None => Style::default(),
Some(c) => Style::default().bg(c),
}
}
/// All logic to style a date goes here.
fn format_date(&self, date: Date) -> Span {
if date.month() != self.display_date.month() {
match self.show_surrounding {
None => Span::styled(" ", self.default_bg()),
Some(s) => {
let style = self
.default_style
.patch(s)
.patch(self.events.get_style(date));
Span::styled(format!("{:2?}", date.day()), style)
}
}
} else {
Span::styled(
format!("{:2?}", date.day()),
self.default_style.patch(self.events.get_style(date)),
)
}
}
}
impl<'a, S: DateStyler> Widget for Monthly<'a, S> {
fn render(mut self, area: Rect, buf: &mut Buffer) {
// Block is used for borders and such
// Draw that first, and use the blank area inside the block for our own purposes
let mut area = match self.block.take() {
None => area,
Some(b) => {
let inner = b.inner(area);
b.render(area, buf);
inner
}
};
// Draw the month name and year
if let Some(style) = self.show_month {
let line = Span::styled(
format!("{} {}", self.display_date.month(), self.display_date.year()),
style,
);
// cal is 21 cells wide, so hard code the 11
let x_off = 11_u16.saturating_sub(line.width() as u16 / 2);
buf.set_line(area.x + x_off, area.y, &line.into(), area.width);
area.y += 1
}
// Draw days of week
if let Some(style) = self.show_weekday {
let days = String::from(" Su Mo Tu We Th Fr Sa");
buf.set_string(area.x, area.y, days, style);
area.y += 1;
}
// Set the start of the calendar to the Sunday before the 1st (or the sunday of the first)
let first_of_month = self.display_date.replace_day(1).unwrap();
let offset = Duration::days(first_of_month.weekday().number_days_from_sunday().into());
let mut curr_day = first_of_month - offset;
// go through all the weeks containing a day in the target month.
while curr_day.month() as u8 != self.display_date.month().next() as u8 {
let mut spans = Vec::with_capacity(14);
for i in 0..7 {
// Draw the gutter. Do it here so we can avoid worrying about
// styling the ' ' in the format_date method
if i == 0 {
spans.push(Span::styled(" ", Style::default()));
} else {
spans.push(Span::styled(" ", self.default_bg()));
}
spans.push(self.format_date(curr_day));
curr_day += Duration::DAY;
}
buf.set_line(area.x, area.y, &spans.into(), area.width);
area.y += 1;
}
}
}
/// Provides a method for styling a given date. [Monthly] is generic on this trait, so any type
/// that implements this trait can be used.
pub trait DateStyler {
/// Given a date, return a style for that date
fn get_style(&self, date: Date) -> Style;
}
/// A simple `DateStyler` based on a [`HashMap`]
pub struct CalendarEventStore(pub HashMap<Date, Style>);
impl CalendarEventStore {
/// Construct a store that has the current date styled.
pub fn today(style: Style) -> Self {
let mut res = Self::default();
res.add(OffsetDateTime::now_local().unwrap().date(), style);
res
}
/// Add a date and style to the store
pub fn add(&mut self, date: Date, style: Style) {
// to simplify style nonsense, last write wins
let _ = self.0.insert(date, style);
}
/// Helper for trait impls
fn lookup_style(&self, date: Date) -> Style {
self.0.get(&date).copied().unwrap_or_default()
}
}
impl DateStyler for CalendarEventStore {
fn get_style(&self, date: Date) -> Style {
self.lookup_style(date)
}
}
impl DateStyler for &CalendarEventStore {
fn get_style(&self, date: Date) -> Style {
self.lookup_style(date)
}
}
impl Default for CalendarEventStore {
fn default() -> Self {
Self(HashMap::with_capacity(4))
}
}
#[cfg(test)]
mod tests {
use time::Month;
use super::*;
use crate::style::Color;
#[test]
fn event_store() {
let a = (
Date::from_calendar_date(2023, Month::January, 1).unwrap(),
Style::default(),
);
let b = (
Date::from_calendar_date(2023, Month::January, 2).unwrap(),
Style::default().bg(Color::Red).fg(Color::Blue),
);
let mut s = CalendarEventStore::default();
s.add(b.0, b.1);
assert_eq!(
s.get_style(a.0),
a.1,
"Date not added to the styler should look up as Style::default()"
);
assert_eq!(
s.get_style(b.0),
b.1,
"Date added to styler should return the provided style"
);
}
}

View File

@@ -0,0 +1,66 @@
use crate::{
style::Color,
widgets::canvas::{Painter, Shape},
};
/// Shape to draw a circle with a given center and radius and with the given color
#[derive(Debug, Clone)]
pub struct Circle {
pub x: f64,
pub y: f64,
pub radius: f64,
pub color: Color,
}
impl Shape for Circle {
fn draw(&self, painter: &mut Painter<'_, '_>) {
for angle in 0..360 {
let radians = f64::from(angle).to_radians();
let circle_x = self.radius.mul_add(radians.cos(), self.x);
let circle_y = self.radius.mul_add(radians.sin(), self.y);
if let Some((x, y)) = painter.get_point(circle_x, circle_y) {
painter.paint(x, y, self.color);
}
}
}
}
#[cfg(test)]
mod tests {
use crate::{
buffer::Buffer,
layout::Rect,
style::Color,
symbols::Marker,
widgets::{
canvas::{Canvas, Circle},
Widget,
},
};
#[test]
fn test_it_draws_a_circle() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 5));
let canvas = Canvas::default()
.paint(|ctx| {
ctx.draw(&Circle {
x: 5.0,
y: 2.0,
radius: 5.0,
color: Color::Reset,
});
})
.marker(Marker::Braille)
.x_bounds([-10.0, 10.0])
.y_bounds([-10.0, 10.0]);
canvas.render(buffer.area, &mut buffer);
let expected = Buffer::with_lines(vec![
" ⢀⣠⢤⣀ ",
" ⢰⠋ ⠈⣇",
" ⠘⣆⡀ ⣠⠇",
" ⠉⠉⠁ ",
" ",
]);
assert_eq!(buffer, expected);
}
}

View File

@@ -15,13 +15,11 @@ pub struct Line {
impl Shape for Line {
fn draw(&self, painter: &mut Painter) {
let (x1, y1) = match painter.get_point(self.x1, self.y1) {
Some(c) => c,
None => return,
let Some((x1, y1)) = painter.get_point(self.x1, self.y1) else {
return;
};
let (x2, y2) = match painter.get_point(self.x2, self.y2) {
Some(c) => c,
None => return,
let Some((x2, y2)) = painter.get_point(self.x2, self.y2) else {
return;
};
let (dx, x_range) = if x2 >= x1 {
(x2 - x1, x1..=x2)

View File

@@ -1,23 +1,27 @@
mod circle;
mod line;
mod map;
mod points;
mod rectangle;
mod world;
pub use self::line::Line;
pub use self::map::{Map, MapResolution};
pub use self::points::Points;
pub use self::rectangle::Rectangle;
use std::fmt::Debug;
pub use self::{
circle::Circle,
line::Line,
map::{Map, MapResolution},
points::Points,
rectangle::Rectangle,
};
use crate::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
symbols,
text::Spans,
text::Line as TextLine,
widgets::{Block, Widget},
};
use std::fmt::Debug;
/// Interface for all shapes that may be drawn on a Canvas widget.
pub trait Shape {
@@ -29,7 +33,7 @@ pub trait Shape {
pub struct Label<'a> {
x: f64,
y: f64,
spans: Spans<'a>,
line: TextLine<'a>,
}
#[derive(Debug, Clone)]
@@ -183,7 +187,7 @@ impl<'a, 'b> Painter<'a, 'b> {
///
/// # Examples:
/// ```
/// use tui::{symbols, widgets::canvas::{Painter, Context}};
/// use ratatui::{symbols, widgets::canvas::{Painter, Context}};
///
/// let mut ctx = Context::new(2, 2, [1.0, 2.0], [0.0, 2.0], symbols::Marker::Braille);
/// let mut painter = Painter::from(&mut ctx);
@@ -220,7 +224,7 @@ impl<'a, 'b> Painter<'a, 'b> {
///
/// # Examples:
/// ```
/// use tui::{style::Color, symbols, widgets::canvas::{Painter, Context}};
/// use ratatui::{style::Color, symbols, widgets::canvas::{Painter, Context}};
///
/// let mut ctx = Context::new(1, 1, [0.0, 2.0], [0.0, 2.0], symbols::Marker::Braille);
/// let mut painter = Painter::from(&mut ctx);
@@ -260,9 +264,13 @@ impl<'a> Context<'a> {
y_bounds: [f64; 2],
marker: symbols::Marker,
) -> Context<'a> {
let dot = symbols::DOT.chars().next().unwrap();
let block = symbols::block::FULL.chars().next().unwrap();
let bar = symbols::bar::HALF.chars().next().unwrap();
let grid: Box<dyn Grid> = match marker {
symbols::Marker::Dot => Box::new(CharGrid::new(width, height, '•')),
symbols::Marker::Block => Box::new(CharGrid::new(width, height, '▄')),
symbols::Marker::Dot => Box::new(CharGrid::new(width, height, dot)),
symbols::Marker::Block => Box::new(CharGrid::new(width, height, block)),
symbols::Marker::Bar => Box::new(CharGrid::new(width, height, bar)),
symbols::Marker::Braille => Box::new(BrailleGrid::new(width, height)),
};
Context {
@@ -293,21 +301,21 @@ impl<'a> Context<'a> {
}
/// Print a string on the canvas at the given position
pub fn print<T>(&mut self, x: f64, y: f64, spans: T)
pub fn print<T>(&mut self, x: f64, y: f64, line: T)
where
T: Into<Spans<'a>>,
T: Into<TextLine<'a>>,
{
self.labels.push(Label {
x,
y,
spans: spans.into(),
line: line.into(),
});
}
/// Push the last layer if necessary
fn finish(&mut self) {
if self.dirty {
self.layer()
self.layer();
}
}
}
@@ -317,10 +325,10 @@ impl<'a> Context<'a> {
/// # Examples
///
/// ```
/// # use tui::widgets::{Block, Borders};
/// # use tui::layout::Rect;
/// # use tui::widgets::canvas::{Canvas, Shape, Line, Rectangle, Map, MapResolution};
/// # use tui::style::Color;
/// # use ratatui::widgets::{Block, Borders};
/// # use ratatui::layout::Rect;
/// # use ratatui::widgets::canvas::{Canvas, Shape, Line, Rectangle, Map, MapResolution};
/// # use ratatui::style::Color;
/// Canvas::default()
/// .block(Block::default().title("Canvas").borders(Borders::ALL))
/// .x_bounds([-180.0, 180.0])
@@ -384,11 +392,18 @@ where
self
}
/// Define the viewport of the canvas.
/// If you were to "zoom" to a certain part of the world you may want to choose different
/// bounds.
pub fn x_bounds(mut self, bounds: [f64; 2]) -> Canvas<'a, F> {
self.x_bounds = bounds;
self
}
/// Define the viewport of the canvas.
///
/// If you were to "zoom" to a certain part of the world you may want to choose different
/// bounds.
pub fn y_bounds(mut self, bounds: [f64; 2]) -> Canvas<'a, F> {
self.y_bounds = bounds;
self
@@ -412,8 +427,8 @@ where
/// # Examples
///
/// ```
/// # use tui::widgets::canvas::Canvas;
/// # use tui::symbols;
/// # use ratatui::widgets::canvas::Canvas;
/// # use ratatui::symbols;
/// Canvas::default().marker(symbols::Marker::Braille).paint(|ctx| {});
///
/// Canvas::default().marker(symbols::Marker::Dot).paint(|ctx| {});
@@ -444,9 +459,8 @@ where
let width = canvas_area.width as usize;
let painter = match self.painter {
Some(ref p) => p,
None => return,
let Some(ref painter) = self.painter else {
return;
};
// Create a blank context that match the size of the canvas
@@ -461,7 +475,7 @@ where
painter(&mut ctx);
ctx.finish();
// Retreive painted points for each layer
// Retrieve painted points for each layer
for layer in ctx.layers {
for (i, (ch, color)) in layer
.string
@@ -497,7 +511,108 @@ where
{
let x = ((label.x - left) * resolution.0 / width) as u16 + canvas_area.left();
let y = ((top - label.y) * resolution.1 / height) as u16 + canvas_area.top();
buf.set_spans(x, y, &label.spans, canvas_area.right() - x);
buf.set_line(x, y, &label.line, canvas_area.right() - x);
}
}
}
#[cfg(test)]
mod tests {
use indoc::indoc;
use super::*;
use crate::{buffer::Cell, symbols::Marker};
// helper to test the canvas checks that drawing a vertical and horizontal line
// results in the expected output
fn test_marker(marker: Marker, expected: &str) {
let area = Rect::new(0, 0, 5, 5);
let mut cell = Cell::default();
cell.set_char('x');
let mut buf = Buffer::filled(area, &cell);
let horizontal_line = Line {
x1: 0.0,
y1: 0.0,
x2: 10.0,
y2: 0.0,
color: Color::Reset,
};
let vertical_line = Line {
x1: 0.0,
y1: 0.0,
x2: 0.0,
y2: 10.0,
color: Color::Reset,
};
Canvas::default()
.marker(marker)
.paint(|ctx| {
ctx.draw(&vertical_line);
ctx.draw(&horizontal_line);
})
.x_bounds([0.0, 10.0])
.y_bounds([0.0, 10.0])
.render(area, &mut buf);
assert_eq!(buf, Buffer::with_lines(expected.lines().collect()));
}
#[test]
fn test_bar_marker() {
test_marker(
Marker::Bar,
indoc!(
"
▄xxxx
▄xxxx
▄xxxx
▄xxxx
▄▄▄▄▄"
),
);
}
#[test]
fn test_block_marker() {
test_marker(
Marker::Block,
indoc!(
"
█xxxx
█xxxx
█xxxx
█xxxx
█████"
),
);
}
#[test]
fn test_braille_marker() {
test_marker(
Marker::Braille,
indoc!(
"
⡇xxxx
⡇xxxx
⡇xxxx
⡇xxxx
⣇⣀⣀⣀⣀"
),
);
}
#[test]
fn test_dot_marker() {
test_marker(
Marker::Dot,
indoc!(
"
•xxxx
•xxxx
•xxxx
•xxxx
•••••"
),
);
}
}

View File

@@ -2,13 +2,12 @@ use std::{borrow::Cow, cmp::max};
use unicode_width::UnicodeWidthStr;
use crate::layout::Alignment;
use crate::{
buffer::Buffer,
layout::{Constraint, Rect},
layout::{Alignment, Constraint, Rect},
style::{Color, Style},
symbols,
text::{Span, Spans},
text::{Line as TextLine, Span},
widgets::{
canvas::{Canvas, Line, Points},
Block, Borders, Widget,
@@ -19,7 +18,7 @@ use crate::{
#[derive(Debug, Clone)]
pub struct Axis<'a> {
/// Title displayed next to axis end
title: Option<Spans<'a>>,
title: Option<TextLine<'a>>,
/// Bounds for the axis (all data points outside these limits will not be represented)
bounds: [f64; 2],
/// A list of labels to put to the left or below the axis
@@ -36,7 +35,7 @@ impl<'a> Default for Axis<'a> {
title: None,
bounds: [0.0, 0.0],
labels: None,
style: Default::default(),
style: Style::default(),
labels_alignment: Alignment::Left,
}
}
@@ -45,7 +44,7 @@ impl<'a> Default for Axis<'a> {
impl<'a> Axis<'a> {
pub fn title<T>(mut self, title: T) -> Axis<'a>
where
T: Into<Spans<'a>>,
T: Into<TextLine<'a>>,
{
self.title = Some(title.into());
self
@@ -53,12 +52,12 @@ impl<'a> Axis<'a> {
#[deprecated(
since = "0.10.0",
note = "You should use styling capabilities of `text::Spans` given as argument of the `title` method to apply styling to the title."
note = "You should use styling capabilities of `text::Line` given as argument of the `title` method to apply styling to the title."
)]
pub fn title_style(mut self, style: Style) -> Axis<'a> {
if let Some(t) = self.title {
let title = String::from(t);
self.title = Some(Spans::from(Span::styled(title, style)));
self.title = Some(TextLine::from(Span::styled(title, style)));
}
self
}
@@ -181,10 +180,10 @@ struct ChartLayout {
/// # Examples
///
/// ```
/// # use tui::symbols;
/// # use tui::widgets::{Block, Borders, Chart, Axis, Dataset, GraphType};
/// # use tui::style::{Style, Color};
/// # use tui::text::Span;
/// # use ratatui::symbols;
/// # use ratatui::widgets::{Block, Borders, Chart, Axis, Dataset, GraphType};
/// # use ratatui::style::{Style, Color};
/// # use ratatui::text::Span;
/// let datasets = vec![
/// Dataset::default()
/// .name("data1")
@@ -234,7 +233,7 @@ impl<'a> Chart<'a> {
block: None,
x_axis: Axis::default(),
y_axis: Axis::default(),
style: Default::default(),
style: Style::default(),
datasets,
hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)),
}
@@ -265,8 +264,8 @@ impl<'a> Chart<'a> {
/// # Examples
///
/// ```
/// # use tui::widgets::Chart;
/// # use tui::layout::Constraint;
/// # use ratatui::widgets::Chart;
/// # use ratatui::layout::Constraint;
/// let constraints = (
/// Constraint::Ratio(1, 3),
/// Constraint::Ratio(1, 4)
@@ -365,8 +364,9 @@ impl<'a> Chart<'a> {
let first_label_width = first_x_label.content.width() as u16;
let width_left_of_y_axis = match self.x_axis.labels_alignment {
Alignment::Left => {
// The last character of the label should be below the Y-Axis when it exists, not on its left
let y_axis_offset = if has_y_axis { 1 } else { 0 };
// The last character of the label should be below the Y-Axis when it exists,
// not on its left
let y_axis_offset = u16::from(has_y_axis);
first_label_width.saturating_sub(y_axis_offset)
}
Alignment::Center => first_label_width / 2,
@@ -385,10 +385,7 @@ impl<'a> Chart<'a> {
chart_area: Rect,
graph_area: Rect,
) {
let y = match layout.label_x {
Some(y) => y,
None => return,
};
let Some(y) = layout.label_x else { return };
let labels = self.x_axis.labels.as_ref().unwrap();
let labels_len = labels.len() as u16;
if labels_len < 2 {
@@ -414,7 +411,8 @@ impl<'a> Chart<'a> {
Self::render_label(buf, labels.first().unwrap(), label_area, label_alignment);
for (i, label) in labels[1..labels.len() - 1].iter().enumerate() {
// We add 1 to x (and width-1 below) to leave at least one space before each intermediate labels
// We add 1 to x (and width-1 below) to leave at least one space before each
// intermediate labels
let x = graph_area.left() + (i + 1) as u16 * width_between_ticks + 1;
let label_area = Rect::new(x, y, width_between_ticks.saturating_sub(1), 1);
@@ -470,10 +468,7 @@ impl<'a> Chart<'a> {
chart_area: Rect,
graph_area: Rect,
) {
let x = match layout.label_y {
Some(x) => x,
None => return,
};
let Some(x) = layout.label_y else { return };
let labels = self.y_axis.labels.as_ref().unwrap();
let labels_len = labels.len() as u16;
for (i, label) in labels.iter().enumerate() {
@@ -563,7 +558,7 @@ impl<'a> Widget for Chart<'a> {
x2: data[1].0,
y2: data[1].1,
color: dataset.style.fg.unwrap_or(Color::Reset),
})
});
}
}
})
@@ -597,7 +592,7 @@ impl<'a> Widget for Chart<'a> {
},
original_style,
);
buf.set_spans(x, y, &title, width);
buf.set_line(x, y, &title, width);
}
if let Some((x, y)) = layout.title_y {
@@ -612,7 +607,7 @@ impl<'a> Widget for Chart<'a> {
},
original_style,
);
buf.set_spans(x, y, &title, width);
buf.set_line(x, y, &title, width);
}
}
}
@@ -645,7 +640,7 @@ mod tests {
for case in &cases {
let datasets = (0..10)
.map(|i| {
let name = format!("Dataset #{}", i);
let name = format!("Dataset #{i}");
Dataset::default().name(name).data(&data)
})
.collect::<Vec<_>>();

View File

@@ -2,16 +2,16 @@ use crate::{buffer::Buffer, layout::Rect, widgets::Widget};
/// A widget to clear/reset a certain area to allow overdrawing (e.g. for popups).
///
/// This widget **cannot be used to clear the terminal on the first render** as `tui` assumes the
/// render area is empty. Use [`crate::Terminal::clear`] instead.
/// This widget **cannot be used to clear the terminal on the first render** as `ratatui` assumes
/// the render area is empty. Use [`crate::Terminal::clear`] instead.
///
/// # Examples
///
/// ```
/// # use tui::widgets::{Clear, Block, Borders};
/// # use tui::layout::Rect;
/// # use tui::Frame;
/// # use tui::backend::Backend;
/// # use ratatui::widgets::{Clear, Block, Borders};
/// # use ratatui::layout::Rect;
/// # use ratatui::Frame;
/// # use ratatui::backend::Backend;
/// fn draw_on_clear<B: Backend>(f: &mut Frame<B>, area: Rect) {
/// let block = Block::default().title("Block").borders(Borders::ALL);
/// f.render_widget(Clear, area); // <- this will clear/reset the area first

View File

@@ -3,7 +3,7 @@ use crate::{
layout::Rect,
style::{Color, Style},
symbols,
text::{Span, Spans},
text::{Line, Span},
widgets::{Block, Widget},
};
@@ -12,8 +12,8 @@ use crate::{
/// # Examples:
///
/// ```
/// # use tui::widgets::{Widget, Gauge, Block, Borders};
/// # use tui::style::{Style, Color, Modifier};
/// # use ratatui::widgets::{Widget, Gauge, Block, Borders};
/// # use ratatui::style::{Style, Color, Modifier};
/// Gauge::default()
/// .block(Block::default().borders(Borders::ALL).title("Progress"))
/// .gauge_style(Style::default().fg(Color::White).bg(Color::Black).add_modifier(Modifier::ITALIC))
@@ -111,8 +111,7 @@ impl<'a> Widget for Gauge<'a> {
// label is put at the center of the gauge_area
let label = {
let pct = f64::round(self.ratio * 100.0);
self.label
.unwrap_or_else(|| Span::from(format!("{}%", pct)))
self.label.unwrap_or_else(|| Span::from(format!("{pct}%")))
};
let clamped_label_width = gauge_area.width.min(label.width() as u16);
let label_col = gauge_area.left() + (gauge_area.width - clamped_label_width) / 2;
@@ -128,18 +127,26 @@ impl<'a> Widget for Gauge<'a> {
for y in gauge_area.top()..gauge_area.bottom() {
// render the filled area (left to end)
for x in gauge_area.left()..end {
// spaces are needed to apply the background styling
buf.get_mut(x, y)
.set_symbol(" ")
.set_fg(self.gauge_style.bg.unwrap_or(Color::Reset))
.set_bg(self.gauge_style.fg.unwrap_or(Color::Reset));
let cell = buf.get_mut(x, y);
if self.use_unicode {
cell.set_symbol(symbols::block::FULL)
.set_fg(self.gauge_style.fg.unwrap_or(Color::Reset))
.set_bg(self.gauge_style.bg.unwrap_or(Color::Reset));
} else {
// spaces are needed to apply the background styling.
// note that the background and foreground colors are swapped
// otherwise the gauge will be inverted
cell.set_symbol(" ")
.set_fg(self.gauge_style.bg.unwrap_or(Color::Reset))
.set_bg(self.gauge_style.fg.unwrap_or(Color::Reset));
}
}
if self.use_unicode && self.ratio < 1.0 {
buf.get_mut(end, y)
.set_symbol(get_unicode_block(filled_width % 1.0));
}
}
// set the span
// render the label
buf.set_span(label_col, label_row, &label, clamped_label_width);
}
}
@@ -163,9 +170,9 @@ fn get_unicode_block<'a>(frac: f64) -> &'a str {
/// # Examples:
///
/// ```
/// # use tui::widgets::{Widget, LineGauge, Block, Borders};
/// # use tui::style::{Style, Color, Modifier};
/// # use tui::symbols;
/// # use ratatui::widgets::{Widget, LineGauge, Block, Borders};
/// # use ratatui::style::{Style, Color, Modifier};
/// # use ratatui::symbols;
/// LineGauge::default()
/// .block(Block::default().borders(Borders::ALL).title("Progress"))
/// .gauge_style(Style::default().fg(Color::White).bg(Color::Black).add_modifier(Modifier::BOLD))
@@ -175,7 +182,7 @@ fn get_unicode_block<'a>(frac: f64) -> &'a str {
pub struct LineGauge<'a> {
block: Option<Block<'a>>,
ratio: f64,
label: Option<Spans<'a>>,
label: Option<Line<'a>>,
line_set: symbols::line::Set,
style: Style,
gauge_style: Style,
@@ -216,7 +223,7 @@ impl<'a> LineGauge<'a> {
pub fn label<T>(mut self, label: T) -> Self
where
T: Into<Spans<'a>>,
T: Into<Line<'a>>,
{
self.label = Some(label.into());
self
@@ -252,8 +259,8 @@ impl<'a> Widget for LineGauge<'a> {
let ratio = self.ratio;
let label = self
.label
.unwrap_or_else(move || Spans::from(format!("{:.0}%", ratio * 100.0)));
let (col, row) = buf.set_spans(
.unwrap_or_else(move || Line::from(format!("{:.0}%", ratio * 100.0)));
let (col, row) = buf.set_line(
gauge_area.left(),
gauge_area.top(),
&label,

View File

@@ -1,3 +1,5 @@
use unicode_width::UnicodeWidthStr;
use crate::{
buffer::Buffer,
layout::{Corner, Rect},
@@ -5,7 +7,6 @@ use crate::{
text::Text,
widgets::{Block, StatefulWidget, Widget},
};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Default)]
pub struct ListState {
@@ -14,6 +15,24 @@ pub struct ListState {
}
impl ListState {
pub fn offset(&self) -> usize {
self.offset
}
pub fn offset_mut(&mut self) -> &mut usize {
&mut self.offset
}
pub fn with_selected(mut self, selected: Option<usize>) -> Self {
self.selected = selected;
self
}
pub fn with_offset(mut self, offset: usize) -> Self {
self.offset = offset;
self
}
pub fn selected(&self) -> Option<usize> {
self.selected
}
@@ -26,7 +45,7 @@ impl ListState {
}
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListItem<'a> {
content: Text<'a>,
style: Style,
@@ -51,6 +70,10 @@ impl<'a> ListItem<'a> {
pub fn height(&self) -> usize {
self.content.height()
}
pub fn width(&self) -> usize {
self.content.width()
}
}
/// A widget to display several items among which one can be selected (optional)
@@ -58,8 +81,8 @@ impl<'a> ListItem<'a> {
/// # Examples
///
/// ```
/// # use tui::widgets::{Block, Borders, List, ListItem};
/// # use tui::style::{Style, Color, Modifier};
/// # use ratatui::widgets::{Block, Borders, List, ListItem};
/// # use ratatui::style::{Style, Color, Modifier};
/// let items = [ListItem::new("Item 1"), ListItem::new("Item 2"), ListItem::new("Item 3")];
/// List::new(items)
/// .block(Block::default().title("List").borders(Borders::ALL))
@@ -128,6 +151,14 @@ impl<'a> List<'a> {
self
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
fn get_items_bounds(
&self,
selected: Option<usize>,
@@ -205,16 +236,13 @@ impl<'a> StatefulWidget for List<'a> {
.skip(state.offset)
.take(end - start)
{
let (x, y) = match self.start_corner {
Corner::BottomLeft => {
current_height += item.height() as u16;
(list_area.left(), list_area.bottom() - current_height)
}
_ => {
let pos = (list_area.left(), list_area.top() + current_height);
current_height += item.height() as u16;
pos
}
let (x, y) = if self.start_corner == Corner::BottomLeft {
current_height += item.height() as u16;
(list_area.left(), list_area.bottom() - current_height)
} else {
let pos = (list_area.left(), list_area.top() + current_height);
current_height += item.height() as u16;
pos
};
let area = Rect {
x,
@@ -225,9 +253,9 @@ impl<'a> StatefulWidget for List<'a> {
let item_style = self.style.patch(item.style);
buf.set_style(area, item_style);
let is_selected = state.selected.map(|s| s == i).unwrap_or(false);
let is_selected = state.selected.map_or(false, |s| s == i);
for (j, line) in item.content.lines.iter().enumerate() {
// if the item is selected, we need to display the hightlight symbol:
// if the item is selected, we need to display the highlight symbol:
// - either for the first line of the item only,
// - or for each line of the item if the appropriate option is set
let symbol = if is_selected && (j == 0 || self.repeat_highlight_symbol) {
@@ -243,11 +271,11 @@ impl<'a> StatefulWidget for List<'a> {
list_area.width as usize,
item_style,
);
(elem_x, (list_area.width - (elem_x - x)) as u16)
(elem_x, (list_area.width - (elem_x - x)))
} else {
(x, list_area.width)
};
buf.set_spans(elem_x, y + j as u16, line, max_element_width as u16);
buf.set_line(elem_x, y + j as u16, line, max_element_width);
}
if is_selected {
buf.set_style(area, self.highlight_style);
@@ -262,3 +290,594 @@ impl<'a> Widget for List<'a> {
StatefulWidget::render(self, area, buf, &mut state);
}
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use super::*;
use crate::{
assert_buffer_eq,
style::Color,
text::{Line, Span},
widgets::{Borders, StatefulWidget, Widget},
};
#[test]
fn test_list_state_selected() {
let mut state = ListState::default();
assert_eq!(state.selected(), None);
state.select(Some(1));
assert_eq!(state.selected(), Some(1));
state.select(None);
assert_eq!(state.selected(), None);
}
#[test]
fn test_list_state_select() {
let mut state = ListState::default();
assert_eq!(state.selected, None);
assert_eq!(state.offset, 0);
state.select(Some(2));
assert_eq!(state.selected, Some(2));
assert_eq!(state.offset, 0);
state.select(None);
assert_eq!(state.selected, None);
assert_eq!(state.offset, 0);
}
#[test]
fn test_list_item_new_from_str() {
let item = ListItem::new("Test item");
assert_eq!(item.content, Text::from("Test item"));
assert_eq!(item.style, Style::default());
}
#[test]
fn test_list_item_new_from_string() {
let item = ListItem::new("Test item".to_string());
assert_eq!(item.content, Text::from("Test item"));
assert_eq!(item.style, Style::default());
}
#[test]
fn test_list_item_new_from_cow_str() {
let item = ListItem::new(Cow::Borrowed("Test item"));
assert_eq!(item.content, Text::from("Test item"));
assert_eq!(item.style, Style::default());
}
#[test]
fn test_list_item_new_from_span() {
let span = Span::styled("Test item", Style::default().fg(Color::Blue));
let item = ListItem::new(span.clone());
assert_eq!(item.content, Text::from(span));
assert_eq!(item.style, Style::default());
}
#[test]
fn test_list_item_new_from_spans() {
let spans = Line::from(vec![
Span::styled("Test ", Style::default().fg(Color::Blue)),
Span::styled("item", Style::default().fg(Color::Red)),
]);
let item = ListItem::new(spans.clone());
assert_eq!(item.content, Text::from(spans));
assert_eq!(item.style, Style::default());
}
#[test]
fn test_list_item_new_from_vec_spans() {
let lines = vec![
Line::from(vec![
Span::styled("Test ", Style::default().fg(Color::Blue)),
Span::styled("item", Style::default().fg(Color::Red)),
]),
Line::from(vec![
Span::styled("Second ", Style::default().fg(Color::Green)),
Span::styled("line", Style::default().fg(Color::Yellow)),
]),
];
let item = ListItem::new(lines.clone());
assert_eq!(item.content, Text::from(lines));
assert_eq!(item.style, Style::default());
}
#[test]
fn test_list_item_style() {
let item = ListItem::new("Test item").style(Style::default().bg(Color::Red));
assert_eq!(item.content, Text::from("Test item"));
assert_eq!(item.style, Style::default().bg(Color::Red));
}
#[test]
fn test_list_item_height() {
let item = ListItem::new("Test item");
assert_eq!(item.height(), 1);
let item = ListItem::new("Test item\nSecond line");
assert_eq!(item.height(), 2);
}
#[test]
fn test_list_item_width() {
let item = ListItem::new("Test item");
assert_eq!(item.width(), 9);
}
/// helper method to take a vector of strings and return a vector of list items
fn list_items(items: Vec<&str>) -> Vec<ListItem> {
items.iter().map(|i| ListItem::new(i.to_string())).collect()
}
/// helper method to render a widget to an empty buffer with the default state
fn render_widget(widget: List<'_>, width: u16, height: u16) -> Buffer {
let mut buffer = Buffer::empty(Rect::new(0, 0, width, height));
Widget::render(widget, buffer.area, &mut buffer);
buffer
}
/// helper method to render a widget to an empty buffer with a given state
fn render_stateful_widget(
widget: List<'_>,
state: &mut ListState,
width: u16,
height: u16,
) -> Buffer {
let mut buffer = Buffer::empty(Rect::new(0, 0, width, height));
StatefulWidget::render(widget, buffer.area, &mut buffer, state);
buffer
}
#[test]
fn test_list_does_not_render_in_small_space() {
let items = list_items(vec!["Item 0", "Item 1", "Item 2"]);
let list = List::new(items.clone()).highlight_symbol(">>");
let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 3));
// attempt to render into an area of the buffer with 0 width
Widget::render(list.clone(), Rect::new(0, 0, 0, 3), &mut buffer);
assert_buffer_eq!(buffer, Buffer::empty(buffer.area));
// attempt to render into an area of the buffer with 0 height
Widget::render(list.clone(), Rect::new(0, 0, 15, 0), &mut buffer);
assert_buffer_eq!(buffer, Buffer::empty(buffer.area));
let list = List::new(items)
.highlight_symbol(">>")
.block(Block::default().borders(Borders::all()));
// attempt to render into an area of the buffer with zero height after
// setting the block borders
Widget::render(list, Rect::new(0, 0, 15, 2), &mut buffer);
assert_buffer_eq!(
buffer,
Buffer::with_lines(vec![
"┌─────────────┐",
"└─────────────┘",
" "
])
);
}
#[test]
fn test_list_combinations() {
fn test_case_render(items: &[ListItem], expected_lines: Vec<&str>) {
let list = List::new(items.to_owned()).highlight_symbol(">>");
let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 5));
Widget::render(list, buffer.area, &mut buffer);
let expected = Buffer::with_lines(expected_lines);
assert_buffer_eq!(buffer, expected);
}
fn test_case_render_stateful(
items: &[ListItem],
selected: Option<usize>,
expected_lines: Vec<&str>,
) {
let list = List::new(items.to_owned()).highlight_symbol(">>");
let mut state = ListState::default().with_selected(selected);
let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 5));
StatefulWidget::render(list, buffer.area, &mut buffer, &mut state);
let expected = Buffer::with_lines(expected_lines);
assert_buffer_eq!(buffer, expected);
}
let empty_items: Vec<ListItem> = Vec::new();
let single_item = list_items(vec!["Item 0"]);
let multiple_items = list_items(vec!["Item 0", "Item 1", "Item 2"]);
let multi_line_items = list_items(vec!["Item 0\nLine 2", "Item 1", "Item 2"]);
// empty list
test_case_render(
&empty_items,
vec![
" ",
" ",
" ",
" ",
" ",
],
);
test_case_render_stateful(
&empty_items,
None,
vec![
" ",
" ",
" ",
" ",
" ",
],
);
test_case_render_stateful(
&empty_items,
Some(0),
vec![
" ",
" ",
" ",
" ",
" ",
],
);
// single item
test_case_render(
&single_item,
vec![
"Item 0 ",
" ",
" ",
" ",
" ",
],
);
test_case_render_stateful(
&single_item,
None,
vec![
"Item 0 ",
" ",
" ",
" ",
" ",
],
);
test_case_render_stateful(
&single_item,
Some(0),
vec![
">>Item 0 ",
" ",
" ",
" ",
" ",
],
);
test_case_render_stateful(
&single_item,
Some(1),
vec![
" Item 0 ",
" ",
" ",
" ",
" ",
],
);
// multiple items
test_case_render(
&multiple_items,
vec![
"Item 0 ",
"Item 1 ",
"Item 2 ",
" ",
" ",
],
);
test_case_render_stateful(
&multiple_items,
None,
vec![
"Item 0 ",
"Item 1 ",
"Item 2 ",
" ",
" ",
],
);
test_case_render_stateful(
&multiple_items,
Some(0),
vec![
">>Item 0 ",
" Item 1 ",
" Item 2 ",
" ",
" ",
],
);
test_case_render_stateful(
&multiple_items,
Some(1),
vec![
" Item 0 ",
">>Item 1 ",
" Item 2 ",
" ",
" ",
],
);
test_case_render_stateful(
&multiple_items,
Some(3),
vec![
" Item 0 ",
" Item 1 ",
" Item 2 ",
" ",
" ",
],
);
// multi line items
test_case_render(
&multi_line_items,
vec![
"Item 0 ",
"Line 2 ",
"Item 1 ",
"Item 2 ",
" ",
],
);
test_case_render_stateful(
&multi_line_items,
None,
vec![
"Item 0 ",
"Line 2 ",
"Item 1 ",
"Item 2 ",
" ",
],
);
test_case_render_stateful(
&multi_line_items,
Some(0),
vec![
">>Item 0 ",
" Line 2 ",
" Item 1 ",
" Item 2 ",
" ",
],
);
test_case_render_stateful(
&multi_line_items,
Some(1),
vec![
" Item 0 ",
" Line 2 ",
">>Item 1 ",
" Item 2 ",
" ",
],
);
}
#[test]
fn test_list_with_empty_strings() {
let items = list_items(vec!["Item 0", "", "", "Item 1", "Item 2"]);
let list = List::new(items).block(Block::default().title("List").borders(Borders::ALL));
let buffer = render_widget(list, 10, 7);
let expected = Buffer::with_lines(vec![
"┌List────┐",
"│Item 0 │",
"│ │",
"│ │",
"│Item 1 │",
"│Item 2 │",
"└────────┘",
]);
assert_buffer_eq!(buffer, expected);
}
#[test]
fn test_list_block() {
let items = list_items(vec!["Item 0", "Item 1", "Item 2"]);
let list = List::new(items).block(Block::default().title("List").borders(Borders::ALL));
let buffer = render_widget(list, 10, 7);
let expected = Buffer::with_lines(vec![
"┌List────┐",
"│Item 0 │",
"│Item 1 │",
"│Item 2 │",
"│ │",
"│ │",
"└────────┘",
]);
assert_buffer_eq!(buffer, expected);
}
#[test]
fn test_list_style() {
let items = list_items(vec!["Item 0", "Item 1", "Item 2"]);
let list = List::new(items).style(Style::default().fg(Color::Red));
let buffer = render_widget(list, 10, 5);
let mut expected = Buffer::with_lines(vec![
"Item 0 ",
"Item 1 ",
"Item 2 ",
" ",
" ",
]);
expected.set_style(buffer.area, Style::default().fg(Color::Red));
assert_buffer_eq!(buffer, expected);
}
#[test]
fn test_list_highlight_symbol_and_style() {
let items = list_items(vec!["Item 0", "Item 1", "Item 2"]);
let list = List::new(items)
.highlight_symbol(">>")
.highlight_style(Style::default().fg(Color::Yellow));
let mut state = ListState::default();
state.select(Some(1));
let buffer = render_stateful_widget(list, &mut state, 10, 5);
let mut expected = Buffer::with_lines(vec![
" Item 0 ",
">>Item 1 ",
" Item 2 ",
" ",
" ",
]);
expected.set_style(Rect::new(0, 1, 10, 1), Style::default().fg(Color::Yellow));
assert_buffer_eq!(buffer, expected);
}
#[test]
fn test_list_repeat_highlight_symbol() {
let items = list_items(vec!["Item 0\nLine 2", "Item 1", "Item 2"]);
let list = List::new(items)
.highlight_symbol(">>")
.highlight_style(Style::default().fg(Color::Yellow))
.repeat_highlight_symbol(true);
let mut state = ListState::default();
state.select(Some(0));
let buffer = render_stateful_widget(list, &mut state, 10, 5);
let mut expected = Buffer::with_lines(vec![
">>Item 0 ",
">>Line 2 ",
" Item 1 ",
" Item 2 ",
" ",
]);
expected.set_style(Rect::new(0, 0, 10, 2), Style::default().fg(Color::Yellow));
assert_buffer_eq!(buffer, expected);
}
#[test]
fn test_list_start_corner_top_left() {
let items = list_items(vec!["Item 0", "Item 1", "Item 2"]);
let list = List::new(items).start_corner(Corner::TopLeft);
let buffer = render_widget(list, 10, 5);
let expected = Buffer::with_lines(vec![
"Item 0 ",
"Item 1 ",
"Item 2 ",
" ",
" ",
]);
assert_buffer_eq!(buffer, expected);
}
#[test]
fn test_list_start_corner_bottom_left() {
let items = list_items(vec!["Item 0", "Item 1", "Item 2"]);
let list = List::new(items).start_corner(Corner::BottomLeft);
let buffer = render_widget(list, 10, 5);
let expected = Buffer::with_lines(vec![
" ",
" ",
"Item 2 ",
"Item 1 ",
"Item 0 ",
]);
assert_buffer_eq!(buffer, expected);
}
#[test]
fn test_list_truncate_items() {
let items = list_items(vec!["Item 0", "Item 1", "Item 2", "Item 3", "Item 4"]);
let list = List::new(items);
let buffer = render_widget(list, 10, 3);
let expected = Buffer::with_lines(vec!["Item 0 ", "Item 1 ", "Item 2 "]);
assert_buffer_eq!(buffer, expected);
}
#[test]
fn test_list_long_lines() {
let items = list_items(vec![
"Item 0 with a very long line that will be truncated",
"Item 1",
"Item 2",
]);
let list = List::new(items).highlight_symbol(">>");
fn test_case(list: List, selected: Option<usize>, expected_lines: Vec<&str>) {
let mut state = ListState::default();
state.select(selected);
let buffer = render_stateful_widget(list.clone(), &mut state, 15, 3);
let expected = Buffer::with_lines(expected_lines);
assert_buffer_eq!(buffer, expected);
}
test_case(
list.clone(),
None,
vec!["Item 0 with a v", "Item 1 ", "Item 2 "],
);
test_case(
list,
Some(0),
vec![">>Item 0 with a", " Item 1 ", " Item 2 "],
);
}
#[test]
fn test_list_selected_item_ensures_selected_item_is_visible_when_offset_is_before_visible_range(
) {
let items = list_items(vec![
"Item 0", "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6",
]);
let list = List::new(items).highlight_symbol(">>");
// Set the initial visible range to items 3, 4, and 5
let mut state = ListState::default().with_selected(Some(1)).with_offset(3);
let buffer = render_stateful_widget(list, &mut state, 10, 3);
let expected = Buffer::with_lines(vec![">>Item 1 ", " Item 2 ", " Item 3 "]);
assert_buffer_eq!(buffer, expected);
assert_eq!(state.selected, Some(1));
assert_eq!(
state.offset, 1,
"did not scroll the selected item into view"
);
}
#[test]
fn test_list_selected_item_ensures_selected_item_is_visible_when_offset_is_after_visible_range()
{
let items = list_items(vec![
"Item 0", "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6",
]);
let list = List::new(items).highlight_symbol(">>");
// Set the initial visible range to items 3, 4, and 5
let mut state = ListState::default().with_selected(Some(6)).with_offset(3);
let buffer = render_stateful_widget(list, &mut state, 10, 3);
let expected = Buffer::with_lines(vec![" Item 4 ", " Item 5 ", ">>Item 6 "]);
assert_buffer_eq!(buffer, expected);
assert_eq!(state.selected, Some(6));
assert_eq!(
state.offset, 4,
"did not scroll the selected item into view"
);
}
}

View File

@@ -13,10 +13,13 @@
//! - [`BarChart`]
//! - [`Gauge`]
//! - [`Sparkline`]
//! - [`calendar::Monthly`]
//! - [`Clear`]
mod barchart;
mod block;
pub mod block;
#[cfg(feature = "widget-calendar")]
pub mod calendar;
pub mod canvas;
mod chart;
mod clear;
@@ -24,39 +27,76 @@ mod gauge;
mod list;
mod paragraph;
mod reflow;
pub mod scrollbar;
mod sparkline;
mod table;
mod tabs;
pub use self::barchart::BarChart;
pub use self::block::{Block, BorderType};
pub use self::chart::{Axis, Chart, Dataset, GraphType};
pub use self::clear::Clear;
pub use self::gauge::{Gauge, LineGauge};
pub use self::list::{List, ListItem, ListState};
pub use self::paragraph::{Paragraph, Wrap};
pub use self::sparkline::Sparkline;
pub use self::table::{Cell, Row, Table, TableState};
pub use self::tabs::Tabs;
use std::fmt::{self, Debug};
use crate::{buffer::Buffer, layout::Rect};
use bitflags::bitflags;
pub use self::{
barchart::BarChart,
block::{Block, BorderType, Padding},
chart::{Axis, Chart, Dataset, GraphType},
clear::Clear,
gauge::{Gauge, LineGauge},
list::{List, ListItem, ListState},
paragraph::{Paragraph, Wrap},
scrollbar::{ScrollDirection, Scrollbar, ScrollbarOrientation, ScrollbarState},
sparkline::{RenderDirection, Sparkline},
table::{Cell, Row, Table, TableState},
tabs::Tabs,
};
use crate::{buffer::Buffer, layout::Rect};
bitflags! {
/// Bitflags that can be composed to set the visible borders essentially on the block widget.
pub struct Borders: u32 {
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub struct Borders: u8 {
/// Show no border (default)
const NONE = 0b0000_0001;
const NONE = 0b0000;
/// Show the top border
const TOP = 0b0000_0010;
const TOP = 0b0001;
/// Show the right border
const RIGHT = 0b0000_0100;
const RIGHT = 0b0010;
/// Show the bottom border
const BOTTOM = 0b000_1000;
const BOTTOM = 0b0100;
/// Show the left border
const LEFT = 0b0001_0000;
const LEFT = 0b1000;
/// Show all borders
const ALL = Self::TOP.bits | Self::RIGHT.bits | Self::BOTTOM.bits | Self::LEFT.bits;
const ALL = Self::TOP.bits() | Self::RIGHT.bits() | Self::BOTTOM.bits() | Self::LEFT.bits();
}
}
/// Implement the `Debug` trait for the `Borders` bitflags. This is a manual implementation to
/// display the flags in a more readable way. The default implementation would display the
/// flags as 'Border(0x0)' for `Borders::NONE` for example.
impl Debug for Borders {
/// Display the Borders bitflags as a list of names. For example, `Borders::NONE` will be
/// displayed as `NONE` and `Borders::ALL` will be displayed as `ALL`. If multiple flags are
/// set, they will be displayed separated by a pipe character.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_empty() {
return write!(f, "NONE");
}
if self.is_all() {
return write!(f, "ALL");
}
let mut first = true;
for (name, border) in self.iter_names() {
if border == Borders::NONE {
continue;
}
if first {
write!(f, "{name}")?;
first = false;
} else {
write!(f, " | {name}")?;
}
}
Ok(())
}
}
@@ -86,9 +126,9 @@ pub trait Widget {
///
/// ```rust,no_run
/// # use std::io;
/// # use tui::Terminal;
/// # use tui::backend::{Backend, TestBackend};
/// # use tui::widgets::{Widget, List, ListItem, ListState};
/// # use ratatui::Terminal;
/// # use ratatui::backend::{Backend, TestBackend};
/// # use ratatui::widgets::{Widget, List, ListItem, ListState};
///
/// // Let's say we have some events to display.
/// struct Events {
@@ -165,8 +205,8 @@ pub trait Widget {
/// 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();
/// // that is understood by ratatui.
/// let items: Vec<ListItem>= events.items.iter().map(|i| ListItem::new(i.as_str())).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
@@ -182,3 +222,62 @@ pub trait StatefulWidget {
type State;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State);
}
/// Macro that constructs and returns a [`Borders`] object from TOP, BOTTOM, LEFT, RIGHT, NONE, and
/// ALL. Internally it creates an empty `Borders` object and then inserts each bit flag specified
/// into it using `Borders::insert()`.
///
/// ## Examples
///
///```
/// # use ratatui::widgets::{Block, Borders};
/// # use ratatui::style::{Style, Color};
/// # use ratatui::border;
///
/// Block::default()
/// //Construct a `Borders` object and use it in place
/// .borders(border!(TOP, BOTTOM));
///
/// //`border!` can be called with any order of individual sides
/// let bottom_first = border!(BOTTOM, LEFT, TOP);
/// //with the ALL keyword which works as expected
/// let all = border!(ALL);
/// //or with nothing to return a `Borders::NONE' bitflag.
/// let none = border!(NONE);
/// ```
#[cfg(feature = "macros")]
#[macro_export]
macro_rules! border {
( $($b:tt), +) => {{
let mut border = Borders::empty();
$(
border.insert(Borders::$b);
)*
border
}};
() =>{
Borders::NONE
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_borders_debug() {
assert_eq!(format!("{:?}", Borders::empty()), "NONE");
assert_eq!(format!("{:?}", Borders::NONE), "NONE");
assert_eq!(format!("{:?}", Borders::TOP), "TOP");
assert_eq!(format!("{:?}", Borders::BOTTOM), "BOTTOM");
assert_eq!(format!("{:?}", Borders::LEFT), "LEFT");
assert_eq!(format!("{:?}", Borders::RIGHT), "RIGHT");
assert_eq!(format!("{:?}", Borders::ALL), "ALL");
assert_eq!(format!("{:?}", Borders::all()), "ALL");
assert_eq!(
format!("{:?}", Borders::TOP | Borders::BOTTOM),
"TOP | BOTTOM"
);
}
}

View File

@@ -1,15 +1,15 @@
use unicode_width::UnicodeWidthStr;
use crate::{
buffer::Buffer,
layout::{Alignment, Rect},
style::Style,
style::{Style, Styled},
text::{StyledGrapheme, Text},
widgets::{
reflow::{LineComposer, LineTruncator, WordWrapper},
Block, Widget,
},
};
use std::iter;
use unicode_width::UnicodeWidthStr;
fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 {
match alignment {
@@ -24,17 +24,17 @@ fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment)
/// # Examples
///
/// ```
/// # use tui::text::{Text, Spans, Span};
/// # use tui::widgets::{Block, Borders, Paragraph, Wrap};
/// # use tui::style::{Style, Color, Modifier};
/// # use tui::layout::{Alignment};
/// # use ratatui::text::{Text, Line, Span};
/// # use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
/// # use ratatui::style::{Style, Color, Modifier};
/// # use ratatui::layout::{Alignment};
/// let text = vec![
/// Spans::from(vec![
/// Line::from(vec![
/// Span::raw("First"),
/// Span::styled("line",Style::default().add_modifier(Modifier::ITALIC)),
/// Span::raw("."),
/// ]),
/// Spans::from(Span::styled("Second line", Style::default().fg(Color::Red))),
/// Line::from(Span::styled("Second line", Style::default().fg(Color::Red))),
/// ];
/// Paragraph::new(text)
/// .block(Block::default().title("Paragraph").borders(Borders::ALL))
@@ -63,8 +63,8 @@ pub struct Paragraph<'a> {
/// ## Examples
///
/// ```
/// # use tui::widgets::{Paragraph, Wrap};
/// # use tui::text::Text;
/// # use ratatui::widgets::{Paragraph, Wrap};
/// # use ratatui::text::Text;
/// let bullet_points = Text::from(r#"Some indented points:
/// - First thing goes here and is long so that it wraps
/// - Here is another point that is long enough to wrap"#);
@@ -98,7 +98,7 @@ impl<'a> Paragraph<'a> {
{
Paragraph {
block: None,
style: Default::default(),
style: Style::default(),
wrap: None,
text: text.into(),
scroll: (0, 0),
@@ -149,33 +149,34 @@ impl<'a> Widget for Paragraph<'a> {
}
let style = self.style;
let mut styled = self.text.lines.iter().flat_map(|spans| {
spans
.0
.iter()
.flat_map(|span| span.styled_graphemes(style))
// Required given the way composers work but might be refactored out if we change
// composers to operate on lines instead of a stream of graphemes.
.chain(iter::once(StyledGrapheme {
symbol: "\n",
style: self.style,
}))
let styled = self.text.lines.iter().map(|line| {
(
line.spans
.iter()
.flat_map(|span| span.styled_graphemes(style)),
line.alignment.unwrap_or(self.alignment),
)
});
let mut line_composer: Box<dyn LineComposer> = if let Some(Wrap { trim }) = self.wrap {
Box::new(WordWrapper::new(&mut styled, text_area.width, trim))
Box::new(WordWrapper::new(styled, text_area.width, trim))
} else {
let mut line_composer = Box::new(LineTruncator::new(&mut styled, text_area.width));
if let Alignment::Left = self.alignment {
line_composer.set_horizontal_offset(self.scroll.1);
}
let mut line_composer = Box::new(LineTruncator::new(styled, text_area.width));
line_composer.set_horizontal_offset(self.scroll.1);
line_composer
};
let mut y = 0;
while let Some((current_line, current_line_width)) = line_composer.next_line() {
while let Some((current_line, current_line_width, current_line_alignment)) =
line_composer.next_line()
{
if y >= self.scroll.0 {
let mut x = get_line_offset(current_line_width, text_area.width, self.alignment);
let mut x =
get_line_offset(current_line_width, text_area.width, current_line_alignment);
for StyledGrapheme { symbol, style } in current_line {
let width = symbol.width();
if width == 0 {
continue;
}
buf.get_mut(text_area.left() + x, text_area.top() + y - self.scroll.0)
.set_symbol(if symbol.is_empty() {
// If the symbol is empty, the last char which rendered last time will
@@ -185,7 +186,7 @@ impl<'a> Widget for Paragraph<'a> {
symbol
})
.set_style(*style);
x += symbol.width() as u16;
x += width as u16;
}
}
y += 1;
@@ -195,3 +196,510 @@ impl<'a> Widget for Paragraph<'a> {
}
}
}
impl<'a> Styled for Paragraph<'a> {
type Item = Paragraph<'a>;
fn style(&self) -> Style {
self.style
}
fn set_style(self, style: Style) -> Self::Item {
self.style(style)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{
backend::TestBackend,
style::Color,
text::{Line, Span},
widgets::Borders,
Terminal,
};
/// Tests the [`Paragraph`] widget against the expected [`Buffer`] by rendering it onto an equal
/// area and comparing the rendered and expected content.
/// This can be used for easy testing of varying configured paragraphs with the same expected
/// buffer or any other test case really.
fn test_case(paragraph: &Paragraph, expected: Buffer) {
let backend = TestBackend::new(expected.area.width, expected.area.height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let size = f.size();
f.render_widget(paragraph.clone(), size);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
}
#[test]
fn zero_width_char_at_end_of_line() {
let line = "foo\0";
let paragraphs = vec![
Paragraph::new(line),
Paragraph::new(line).wrap(Wrap { trim: false }),
Paragraph::new(line).wrap(Wrap { trim: true }),
];
for paragraph in paragraphs {
test_case(&paragraph, Buffer::with_lines(vec!["foo"]));
test_case(&paragraph, Buffer::with_lines(vec!["foo "]));
test_case(&paragraph, Buffer::with_lines(vec!["foo ", " "]));
test_case(&paragraph, Buffer::with_lines(vec!["foo", " "]));
}
}
#[test]
fn test_render_empty_paragraph() {
let paragraphs = vec![
Paragraph::new(""),
Paragraph::new("").wrap(Wrap { trim: false }),
Paragraph::new("").wrap(Wrap { trim: true }),
];
for paragraph in paragraphs {
test_case(&paragraph, Buffer::with_lines(vec![" "]));
test_case(&paragraph, Buffer::with_lines(vec![" "]));
test_case(&paragraph, Buffer::with_lines(vec![" "; 10]));
test_case(&paragraph, Buffer::with_lines(vec![" ", " "]));
}
}
#[test]
fn test_render_single_line_paragraph() {
let text = "Hello, world!";
let truncated_paragraph = Paragraph::new(text);
let wrapped_paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
let trimmed_paragraph = Paragraph::new(text).wrap(Wrap { trim: true });
let paragraphs = vec![&truncated_paragraph, &wrapped_paragraph, &trimmed_paragraph];
for paragraph in paragraphs {
test_case(paragraph, Buffer::with_lines(vec!["Hello, world! "]));
test_case(paragraph, Buffer::with_lines(vec!["Hello, world!"]));
test_case(
paragraph,
Buffer::with_lines(vec!["Hello, world! ", " "]),
);
test_case(
paragraph,
Buffer::with_lines(vec!["Hello, world!", " "]),
);
}
}
#[test]
fn test_render_multi_line_paragraph() {
let text = "This is a\nmultiline\nparagraph.";
let paragraphs = vec![
Paragraph::new(text),
Paragraph::new(text).wrap(Wrap { trim: false }),
Paragraph::new(text).wrap(Wrap { trim: true }),
];
for paragraph in paragraphs {
test_case(
&paragraph,
Buffer::with_lines(vec!["This is a ", "multiline ", "paragraph."]),
);
test_case(
&paragraph,
Buffer::with_lines(vec![
"This is a ",
"multiline ",
"paragraph. ",
]),
);
test_case(
&paragraph,
Buffer::with_lines(vec![
"This is a ",
"multiline ",
"paragraph. ",
" ",
" ",
]),
);
}
}
#[test]
fn test_render_paragraph_with_block() {
// We use the slightly unconventional "worlds" instead of "world" here to make sure when we
// can truncate this without triggering the typos linter.
let text = "Hello, worlds!";
let truncated_paragraph =
Paragraph::new(text).block(Block::default().title("Title").borders(Borders::ALL));
let wrapped_paragraph = truncated_paragraph.clone().wrap(Wrap { trim: false });
let trimmed_paragraph = truncated_paragraph.clone().wrap(Wrap { trim: true });
let paragraphs = vec![&truncated_paragraph, &wrapped_paragraph, &trimmed_paragraph];
for paragraph in paragraphs {
test_case(
paragraph,
Buffer::with_lines(vec![
"┌Title─────────┐",
"│Hello, worlds!│",
"└──────────────┘",
]),
);
test_case(
paragraph,
Buffer::with_lines(vec![
"┌Title───────────┐",
"│Hello, worlds! │",
"└────────────────┘",
]),
);
test_case(
paragraph,
Buffer::with_lines(vec![
"┌Title────────────┐",
"│Hello, worlds! │",
"│ │",
"└─────────────────┘",
]),
);
}
test_case(
&truncated_paragraph,
Buffer::with_lines(vec![
"┌Title───────┐",
"│Hello, world│",
"│ │",
"└────────────┘",
]),
);
test_case(
&wrapped_paragraph,
Buffer::with_lines(vec![
"┌Title──────┐",
"│Hello, │",
"│worlds! │",
"└───────────┘",
]),
);
test_case(
&trimmed_paragraph,
Buffer::with_lines(vec![
"┌Title──────┐",
"│Hello, │",
"│worlds! │",
"└───────────┘",
]),
);
}
#[test]
fn test_render_paragraph_with_word_wrap() {
let text = "This is a long line of text that should wrap and contains a superultramegagigalong word.";
let wrapped_paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
let trimmed_paragraph = Paragraph::new(text).wrap(Wrap { trim: true });
test_case(
&wrapped_paragraph,
Buffer::with_lines(vec![
"This is a long line",
"of text that should",
"wrap and ",
"contains a ",
"superultramegagigal",
"ong word. ",
]),
);
test_case(
&wrapped_paragraph,
Buffer::with_lines(vec![
"This is a ",
"long line of",
"text that ",
"should wrap ",
" and ",
"contains a ",
"superultrame",
"gagigalong ",
"word. ",
]),
);
test_case(
&trimmed_paragraph,
Buffer::with_lines(vec![
"This is a long line",
"of text that should",
"wrap and ",
"contains a ",
"superultramegagigal",
"ong word. ",
]),
);
test_case(
&trimmed_paragraph,
Buffer::with_lines(vec![
"This is a ",
"long line of",
"text that ",
"should wrap ",
"and contains",
"a ",
"superultrame",
"gagigalong ",
"word. ",
]),
);
}
#[test]
fn test_render_paragraph_with_line_truncation() {
let text = "This is a long line of text that should be truncated.";
let truncated_paragraph = Paragraph::new(text);
test_case(
&truncated_paragraph,
Buffer::with_lines(vec!["This is a long line of"]),
);
test_case(
&truncated_paragraph,
Buffer::with_lines(vec!["This is a long line of te"]),
);
test_case(
&truncated_paragraph,
Buffer::with_lines(vec!["This is a long line of "]),
);
test_case(
&truncated_paragraph.clone().scroll((0, 2)),
Buffer::with_lines(vec!["is is a long line of te"]),
);
}
#[test]
fn test_render_paragraph_with_left_alignment() {
let text = "Hello, world!";
let truncated_paragraph = Paragraph::new(text).alignment(Alignment::Left);
let wrapped_paragraph = truncated_paragraph.clone().wrap(Wrap { trim: false });
let trimmed_paragraph = truncated_paragraph.clone().wrap(Wrap { trim: true });
let paragraphs = vec![&truncated_paragraph, &wrapped_paragraph, &trimmed_paragraph];
for paragraph in paragraphs {
test_case(paragraph, Buffer::with_lines(vec!["Hello, world! "]));
test_case(paragraph, Buffer::with_lines(vec!["Hello, world!"]));
}
test_case(&truncated_paragraph, Buffer::with_lines(vec!["Hello, wor"]));
test_case(
&wrapped_paragraph,
Buffer::with_lines(vec!["Hello, ", "world! "]),
);
test_case(
&trimmed_paragraph,
Buffer::with_lines(vec!["Hello, ", "world! "]),
);
}
#[test]
fn test_render_paragraph_with_center_alignment() {
let text = "Hello, world!";
let truncated_paragraph = Paragraph::new(text).alignment(Alignment::Center);
let wrapped_paragraph = truncated_paragraph.clone().wrap(Wrap { trim: false });
let trimmed_paragraph = truncated_paragraph.clone().wrap(Wrap { trim: true });
let paragraphs = vec![&truncated_paragraph, &wrapped_paragraph, &trimmed_paragraph];
for paragraph in paragraphs {
test_case(paragraph, Buffer::with_lines(vec![" Hello, world! "]));
test_case(paragraph, Buffer::with_lines(vec![" Hello, world! "]));
test_case(paragraph, Buffer::with_lines(vec![" Hello, world! "]));
test_case(paragraph, Buffer::with_lines(vec!["Hello, world!"]));
}
test_case(&truncated_paragraph, Buffer::with_lines(vec!["Hello, wor"]));
test_case(
&wrapped_paragraph,
Buffer::with_lines(vec![" Hello, ", " world! "]),
);
test_case(
&trimmed_paragraph,
Buffer::with_lines(vec![" Hello, ", " world! "]),
);
}
#[test]
fn test_render_paragraph_with_right_alignment() {
let text = "Hello, world!";
let truncated_paragraph = Paragraph::new(text).alignment(Alignment::Right);
let wrapped_paragraph = truncated_paragraph.clone().wrap(Wrap { trim: false });
let trimmed_paragraph = truncated_paragraph.clone().wrap(Wrap { trim: true });
let paragraphs = vec![&truncated_paragraph, &wrapped_paragraph, &trimmed_paragraph];
for paragraph in paragraphs {
test_case(paragraph, Buffer::with_lines(vec![" Hello, world!"]));
test_case(paragraph, Buffer::with_lines(vec!["Hello, world!"]));
}
test_case(&truncated_paragraph, Buffer::with_lines(vec!["Hello, wor"]));
test_case(
&wrapped_paragraph,
Buffer::with_lines(vec![" Hello,", " world!"]),
);
test_case(
&trimmed_paragraph,
Buffer::with_lines(vec![" Hello,", " world!"]),
);
}
#[test]
fn test_render_paragraph_with_scroll_offset() {
let text = "This is a\ncool\nmultiline\nparagraph.";
let truncated_paragraph = Paragraph::new(text).scroll((2, 0));
let wrapped_paragraph = truncated_paragraph.clone().wrap(Wrap { trim: false });
let trimmed_paragraph = truncated_paragraph.clone().wrap(Wrap { trim: true });
let paragraphs = vec![&truncated_paragraph, &wrapped_paragraph, &trimmed_paragraph];
for paragraph in paragraphs {
test_case(
paragraph,
Buffer::with_lines(vec!["multiline ", "paragraph. ", " "]),
);
test_case(paragraph, Buffer::with_lines(vec!["multiline "]));
}
test_case(
&truncated_paragraph.clone().scroll((2, 4)),
Buffer::with_lines(vec!["iline ", "graph. "]),
);
test_case(
&wrapped_paragraph,
Buffer::with_lines(vec!["cool ", "multili", "ne "]),
);
}
#[test]
fn test_render_paragraph_with_zero_width_area() {
let text = "Hello, world!";
let paragraphs = vec![
Paragraph::new(text),
Paragraph::new(text).wrap(Wrap { trim: false }),
Paragraph::new(text).wrap(Wrap { trim: true }),
];
let area = Rect::new(0, 0, 0, 3);
for paragraph in paragraphs {
test_case(&paragraph, Buffer::empty(area));
test_case(&paragraph.clone().scroll((2, 4)), Buffer::empty(area));
}
}
#[test]
fn test_render_paragraph_with_zero_height_area() {
let text = "Hello, world!";
let paragraphs = vec![
Paragraph::new(text),
Paragraph::new(text).wrap(Wrap { trim: false }),
Paragraph::new(text).wrap(Wrap { trim: true }),
];
let area = Rect::new(0, 0, 10, 0);
for paragraph in paragraphs {
test_case(&paragraph, Buffer::empty(area));
test_case(&paragraph.clone().scroll((2, 4)), Buffer::empty(area));
}
}
#[test]
fn test_render_paragraph_with_styled_text() {
let text = Line::from(vec![
Span::styled("Hello, ", Style::default().fg(Color::Red)),
Span::styled("world!", Style::default().fg(Color::Blue)),
]);
let paragraphs = vec![
Paragraph::new(text.clone()),
Paragraph::new(text.clone()).wrap(Wrap { trim: false }),
Paragraph::new(text.clone()).wrap(Wrap { trim: true }),
];
let mut expected_buffer = Buffer::with_lines(vec!["Hello, world!"]);
expected_buffer.set_style(
Rect::new(0, 0, 7, 1),
Style::default().fg(Color::Red).bg(Color::Green),
);
expected_buffer.set_style(
Rect::new(7, 0, 6, 1),
Style::default().fg(Color::Blue).bg(Color::Green),
);
for paragraph in paragraphs {
test_case(
&paragraph.style(Style::default().bg(Color::Green)),
expected_buffer.clone(),
);
}
}
#[test]
fn test_render_paragraph_with_special_characters() {
let text = "Hello, <world>!";
let paragraphs = vec![
Paragraph::new(text),
Paragraph::new(text).wrap(Wrap { trim: false }),
Paragraph::new(text).wrap(Wrap { trim: true }),
];
for paragraph in paragraphs {
test_case(&paragraph, Buffer::with_lines(vec!["Hello, <world>!"]));
test_case(&paragraph, Buffer::with_lines(vec!["Hello, <world>! "]));
test_case(
&paragraph,
Buffer::with_lines(vec!["Hello, <world>! ", " "]),
);
test_case(
&paragraph,
Buffer::with_lines(vec!["Hello, <world>!", " "]),
);
}
}
#[test]
fn test_render_paragraph_with_unicode_characters() {
let text = "こんにちは, 世界! 😃";
let truncated_paragraph = Paragraph::new(text);
let wrapped_paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
let trimmed_paragraph = Paragraph::new(text).wrap(Wrap { trim: true });
let paragraphs = vec![&truncated_paragraph, &wrapped_paragraph, &trimmed_paragraph];
for paragraph in paragraphs {
test_case(paragraph, Buffer::with_lines(vec!["こんにちは, 世界! 😃"]));
test_case(
paragraph,
Buffer::with_lines(vec!["こんにちは, 世界! 😃 "]),
);
}
test_case(
&truncated_paragraph,
Buffer::with_lines(vec!["こんにちは, 世 "]),
);
test_case(
&wrapped_paragraph,
Buffer::with_lines(vec!["こんにちは, ", "世界! 😃 "]),
);
test_case(
&trimmed_paragraph,
Buffer::with_lines(vec!["こんにちは, ", "世界! 😃 "]),
);
}
}

View File

@@ -1,143 +1,236 @@
use crate::text::StyledGrapheme;
use std::{collections::VecDeque, vec::IntoIter};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{layout::Alignment, text::StyledGrapheme};
const NBSP: &str = "\u{00a0}";
/// A state machine to pack styled symbols into lines.
/// Cannot implement it as Iterator since it yields slices of the internal buffer (need streaming
/// iterators for that).
pub trait LineComposer<'a> {
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)>;
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16, Alignment)>;
}
/// A state machine that wraps lines on word boundaries.
pub struct WordWrapper<'a, 'b> {
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
pub struct WordWrapper<'a, O, I>
where
// Outer iterator providing the individual lines
O: Iterator<Item = (I, Alignment)>,
// Inner iterator providing the styled symbols of a line Each line consists of an alignment and
// a series of symbols
I: Iterator<Item = StyledGrapheme<'a>>,
{
/// The given, unprocessed lines
input_lines: O,
max_line_width: u16,
wrapped_lines: Option<IntoIter<Vec<StyledGrapheme<'a>>>>,
current_alignment: Alignment,
current_line: Vec<StyledGrapheme<'a>>,
next_line: Vec<StyledGrapheme<'a>>,
/// Removes the leading whitespace from lines
trim: bool,
}
impl<'a, 'b> WordWrapper<'a, 'b> {
pub fn new(
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
max_line_width: u16,
trim: bool,
) -> WordWrapper<'a, 'b> {
impl<'a, O, I> WordWrapper<'a, O, I>
where
O: Iterator<Item = (I, Alignment)>,
I: Iterator<Item = StyledGrapheme<'a>>,
{
pub fn new(lines: O, max_line_width: u16, trim: bool) -> WordWrapper<'a, O, I> {
WordWrapper {
symbols,
input_lines: lines,
max_line_width,
wrapped_lines: None,
current_alignment: Alignment::Left,
current_line: vec![],
next_line: vec![],
trim,
}
}
}
impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> {
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> {
impl<'a, O, I> LineComposer<'a> for WordWrapper<'a, O, I>
where
O: Iterator<Item = (I, Alignment)>,
I: Iterator<Item = StyledGrapheme<'a>>,
{
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16, Alignment)> {
if self.max_line_width == 0 {
return None;
}
std::mem::swap(&mut self.current_line, &mut self.next_line);
self.next_line.truncate(0);
let mut current_line_width = self
.current_line
.iter()
.map(|StyledGrapheme { symbol, .. }| symbol.width() as u16)
.sum();
let mut current_line: Option<Vec<StyledGrapheme<'a>>> = None;
let mut line_width: u16 = 0;
let mut symbols_to_last_word_end: usize = 0;
let mut width_to_last_word_end: u16 = 0;
let mut prev_whitespace = false;
let mut symbols_exhausted = true;
for StyledGrapheme { symbol, style } in &mut self.symbols {
symbols_exhausted = false;
let symbol_whitespace = symbol.chars().all(&char::is_whitespace) && symbol != NBSP;
// Ignore characters wider that the total max width.
if symbol.width() as u16 > self.max_line_width
// Skip leading whitespace when trim is enabled.
|| self.trim && symbol_whitespace && symbol != "\n" && current_line_width == 0
{
continue;
}
// Break on newline and discard it.
if symbol == "\n" {
if prev_whitespace {
current_line_width = width_to_last_word_end;
self.current_line.truncate(symbols_to_last_word_end);
// Try to repeatedly retrieve next line
while current_line.is_none() {
// Retrieve next preprocessed wrapped line
if let Some(line_iterator) = &mut self.wrapped_lines {
if let Some(line) = line_iterator.next() {
line_width = line
.iter()
.map(|grapheme| grapheme.symbol.width())
.sum::<usize>() as u16;
current_line = Some(line);
}
break;
}
// Mark the previous symbol as word end.
if symbol_whitespace && !prev_whitespace {
symbols_to_last_word_end = self.current_line.len();
width_to_last_word_end = current_line_width;
}
// When no more preprocessed wrapped lines
if current_line.is_none() {
// Try to calculate next wrapped lines based on current whole line
if let Some((line_symbols, line_alignment)) = &mut self.input_lines.next() {
// Save the whole line's alignment
self.current_alignment = *line_alignment;
let mut wrapped_lines = vec![]; // Saves the wrapped lines
// Saves the unfinished wrapped line
let (mut current_line, mut current_line_width) = (vec![], 0);
// Saves the partially processed word
let (mut unfinished_word, mut word_width) = (vec![], 0);
// Saves the whitespaces of the partially unfinished word
let (mut unfinished_whitespaces, mut whitespace_width) =
(VecDeque::<StyledGrapheme>::new(), 0);
self.current_line.push(StyledGrapheme { symbol, style });
current_line_width += symbol.width() as u16;
let mut has_seen_non_whitespace = false;
for StyledGrapheme { symbol, style } in line_symbols {
let symbol_whitespace =
symbol.chars().all(&char::is_whitespace) && symbol != NBSP;
let symbol_width = symbol.width() as u16;
// Ignore characters wider than the total max width
if symbol_width > self.max_line_width {
continue;
}
if current_line_width > self.max_line_width {
// If there was no word break in the text, wrap at the end of the line.
let (truncate_at, truncated_width) = if symbols_to_last_word_end != 0 {
(symbols_to_last_word_end, width_to_last_word_end)
} else {
(self.current_line.len() - 1, self.max_line_width)
};
// Append finished word to current line
if has_seen_non_whitespace && symbol_whitespace
// Append if trimmed (whitespaces removed) word would overflow
|| word_width + symbol_width > self.max_line_width && current_line.is_empty() && self.trim
// Append if removed whitespace would overflow -> reset whitespace counting to prevent overflow
|| whitespace_width + symbol_width > self.max_line_width && current_line.is_empty() && self.trim
// Append if complete word would overflow
|| word_width + whitespace_width + symbol_width > self.max_line_width && current_line.is_empty() && !self.trim
{
if !current_line.is_empty() || !self.trim {
// Also append whitespaces if not trimming or current line is not
// empty
current_line.extend(
std::mem::take(&mut unfinished_whitespaces).into_iter(),
);
current_line_width += whitespace_width;
}
// Append trimmed word
current_line.append(&mut unfinished_word);
current_line_width += word_width;
// Push the remainder to the next line but strip leading whitespace:
{
let remainder = &self.current_line[truncate_at..];
if let Some(remainder_nonwhite) =
remainder.iter().position(|StyledGrapheme { symbol, .. }| {
!symbol.chars().all(&char::is_whitespace)
})
{
self.next_line
.extend_from_slice(&remainder[remainder_nonwhite..]);
// Clear whitespace buffer
unfinished_whitespaces.clear();
whitespace_width = 0;
word_width = 0;
}
// Append the unfinished wrapped line to wrapped lines if it is as wide as
// max line width
if current_line_width >= self.max_line_width
// or if it would be too long with the current partially processed word added
|| current_line_width + whitespace_width + word_width >= self.max_line_width && symbol_width > 0
{
let mut remaining_width =
(self.max_line_width as i32 - current_line_width as i32).max(0)
as u16;
wrapped_lines.push(std::mem::take(&mut current_line));
current_line_width = 0;
// Remove all whitespaces till end of just appended wrapped line + next
// whitespace
let mut first_whitespace = unfinished_whitespaces.pop_front();
while let Some(grapheme) = first_whitespace.as_ref() {
let symbol_width = grapheme.symbol.width() as u16;
whitespace_width -= symbol_width;
if symbol_width > remaining_width {
break;
}
remaining_width -= symbol_width;
first_whitespace = unfinished_whitespaces.pop_front();
}
// In case all whitespaces have been exhausted
if symbol_whitespace && first_whitespace.is_none() {
// Prevent first whitespace to count towards next word
continue;
}
}
// Append symbol to unfinished, partially processed word
if symbol_whitespace {
whitespace_width += symbol_width;
unfinished_whitespaces.push_back(StyledGrapheme { symbol, style });
} else {
word_width += symbol_width;
unfinished_word.push(StyledGrapheme { symbol, style });
}
has_seen_non_whitespace = !symbol_whitespace;
}
}
self.current_line.truncate(truncate_at);
current_line_width = truncated_width;
break;
}
prev_whitespace = symbol_whitespace;
// Append remaining text parts
if !unfinished_word.is_empty() || !unfinished_whitespaces.is_empty() {
if current_line.is_empty() && unfinished_word.is_empty() {
wrapped_lines.push(vec![]);
} else if !self.trim || !current_line.is_empty() {
current_line.extend(unfinished_whitespaces.into_iter());
}
current_line.append(&mut unfinished_word);
}
if !current_line.is_empty() {
wrapped_lines.push(current_line);
}
if wrapped_lines.is_empty() {
// Append empty line if there was nothing to wrap in the first place
wrapped_lines.push(vec![]);
}
self.wrapped_lines = Some(wrapped_lines.into_iter());
} else {
// No more whole lines available -> stop repeatedly retrieving next wrapped line
break;
}
}
}
// Even if the iterator is exhausted, pass the previous remainder.
if symbols_exhausted && self.current_line.is_empty() {
None
if let Some(line) = current_line {
self.current_line = line;
Some((&self.current_line[..], line_width, self.current_alignment))
} else {
Some((&self.current_line[..], current_line_width))
None
}
}
}
/// A state machine that truncates overhanging lines.
pub struct LineTruncator<'a, 'b> {
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
pub struct LineTruncator<'a, O, I>
where
// Outer iterator providing the individual lines
O: Iterator<Item = (I, Alignment)>,
// Inner iterator providing the styled symbols of a line Each line consists of an alignment and
// a series of symbols
I: Iterator<Item = StyledGrapheme<'a>>,
{
/// The given, unprocessed lines
input_lines: O,
max_line_width: u16,
current_line: Vec<StyledGrapheme<'a>>,
/// Record the offet to skip render
/// Record the offset to skip render
horizontal_offset: u16,
}
impl<'a, 'b> LineTruncator<'a, 'b> {
pub fn new(
symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>,
max_line_width: u16,
) -> LineTruncator<'a, 'b> {
impl<'a, O, I> LineTruncator<'a, O, I>
where
O: Iterator<Item = (I, Alignment)>,
I: Iterator<Item = StyledGrapheme<'a>>,
{
pub fn new(lines: O, max_line_width: u16) -> LineTruncator<'a, O, I> {
LineTruncator {
symbols,
input_lines: lines,
max_line_width,
horizontal_offset: 0,
current_line: vec![],
@@ -149,8 +242,12 @@ impl<'a, 'b> LineTruncator<'a, 'b> {
}
}
impl<'a, 'b> LineComposer<'a> for LineTruncator<'a, 'b> {
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> {
impl<'a, O, I> LineComposer<'a> for LineTruncator<'a, O, I>
where
O: Iterator<Item = (I, Alignment)>,
I: Iterator<Item = StyledGrapheme<'a>>,
{
fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16, Alignment)> {
if self.max_line_width == 0 {
return None;
}
@@ -158,57 +255,50 @@ impl<'a, 'b> LineComposer<'a> for LineTruncator<'a, 'b> {
self.current_line.truncate(0);
let mut current_line_width = 0;
let mut skip_rest = false;
let mut symbols_exhausted = true;
let mut lines_exhausted = true;
let mut horizontal_offset = self.horizontal_offset as usize;
for StyledGrapheme { symbol, style } in &mut self.symbols {
symbols_exhausted = false;
let mut current_alignment = Alignment::Left;
if let Some((current_line, alignment)) = &mut self.input_lines.next() {
lines_exhausted = false;
current_alignment = *alignment;
// Ignore characters wider that the total max width.
if symbol.width() as u16 > self.max_line_width {
continue;
}
// Break on newline and discard it.
if symbol == "\n" {
break;
}
if current_line_width + symbol.width() as u16 > self.max_line_width {
// Exhaust the remainder of the line.
skip_rest = true;
break;
}
let symbol = if horizontal_offset == 0 {
symbol
} else {
let w = symbol.width();
if w > horizontal_offset {
let t = trim_offset(symbol, horizontal_offset);
horizontal_offset = 0;
t
} else {
horizontal_offset -= w;
""
for StyledGrapheme { symbol, style } in current_line {
// Ignore characters wider that the total max width.
if symbol.width() as u16 > self.max_line_width {
continue;
}
};
current_line_width += symbol.width() as u16;
self.current_line.push(StyledGrapheme { symbol, style });
}
if skip_rest {
for StyledGrapheme { symbol, .. } in &mut self.symbols {
if symbol == "\n" {
if current_line_width + symbol.width() as u16 > self.max_line_width {
// Truncate line
break;
}
let symbol = if horizontal_offset == 0 || Alignment::Left != *alignment {
symbol
} else {
let w = symbol.width();
if w > horizontal_offset {
let t = trim_offset(symbol, horizontal_offset);
horizontal_offset = 0;
t
} else {
horizontal_offset -= w;
""
}
};
current_line_width += symbol.width() as u16;
self.current_line.push(StyledGrapheme { symbol, style });
}
}
if symbols_exhausted && self.current_line.is_empty() {
if lines_exhausted {
None
} else {
Some((&self.current_line[..], current_line_width))
Some((
&self.current_line[..],
current_line_width,
current_alignment,
))
}
}
}
@@ -231,27 +321,44 @@ fn trim_offset(src: &str, mut offset: usize) -> &str {
#[cfg(test)]
mod test {
use super::*;
use unicode_segmentation::UnicodeSegmentation;
use super::*;
use crate::{
style::Style,
text::{Line, Text},
};
enum Composer {
WordWrapper { trim: bool },
LineTruncator,
}
fn run_composer(which: Composer, text: &str, text_area_width: u16) -> (Vec<String>, Vec<u16>) {
let style = Default::default();
let mut styled =
UnicodeSegmentation::graphemes(text, true).map(|g| StyledGrapheme { symbol: g, style });
fn run_composer<'a>(
which: Composer,
text: impl Into<Text<'a>>,
text_area_width: u16,
) -> (Vec<String>, Vec<u16>, Vec<Alignment>) {
let text = text.into();
let styled_lines = text.lines.iter().map(|line| {
(
line.spans
.iter()
.flat_map(|span| span.styled_graphemes(Style::default())),
line.alignment.unwrap_or(Alignment::Left),
)
});
let mut composer: Box<dyn LineComposer> = match which {
Composer::WordWrapper { trim } => {
Box::new(WordWrapper::new(&mut styled, text_area_width, trim))
Box::new(WordWrapper::new(styled_lines, text_area_width, trim))
}
Composer::LineTruncator => Box::new(LineTruncator::new(&mut styled, text_area_width)),
Composer::LineTruncator => Box::new(LineTruncator::new(styled_lines, text_area_width)),
};
let mut lines = vec![];
let mut widths = vec![];
while let Some((styled, width)) = composer.next_line() {
let mut alignments = vec![];
while let Some((styled, width, alignment)) = composer.next_line() {
let line = styled
.iter()
.map(|StyledGrapheme { symbol, .. }| *symbol)
@@ -259,8 +366,9 @@ mod test {
assert!(width <= text_area_width);
lines.push(line);
widths.push(width);
alignments.push(alignment);
}
(lines, widths)
(lines, widths, alignments)
}
#[test]
@@ -268,9 +376,13 @@ mod test {
let width = 40;
for i in 1..width {
let text = "a".repeat(i);
let (word_wrapper, _) =
run_composer(Composer::WordWrapper { trim: true }, &text, width as u16);
let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width as u16);
let (word_wrapper, _, _) = run_composer(
Composer::WordWrapper { trim: true },
&text[..],
width as u16,
);
let (line_truncator, _, _) =
run_composer(Composer::LineTruncator, &text[..], width as u16);
let expected = vec![text];
assert_eq!(word_wrapper, expected);
assert_eq!(line_truncator, expected);
@@ -282,8 +394,8 @@ mod test {
let width = 20;
let text =
"abcdefg\nhijklmno\npabcdefg\nhijklmn\nopabcdefghijk\nlmnopabcd\n\n\nefghijklmno";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _, _) = run_composer(Composer::LineTruncator, text, width);
let wrapped: Vec<&str> = text.split('\n').collect();
assert_eq!(word_wrapper, wrapped);
@@ -294,9 +406,9 @@ mod test {
fn line_composer_long_word() {
let width = 20;
let text = "abcdefghijklmnopabcdefghijklmnopabcdefghijklmnopabcdefghijklmno";
let (word_wrapper, _) =
let (word_wrapper, _, _) =
run_composer(Composer::WordWrapper { trim: true }, text, width as u16);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16);
let (line_truncator, _, _) = run_composer(Composer::LineTruncator, text, width as u16);
let wrapped = vec![
&text[..width],
@@ -320,14 +432,14 @@ mod test {
let text_multi_space =
"abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l \
m n o";
let (word_wrapper_single_space, _) =
let (word_wrapper_single_space, _, _) =
run_composer(Composer::WordWrapper { trim: true }, text, width as u16);
let (word_wrapper_multi_space, _) = run_composer(
let (word_wrapper_multi_space, _, _) = run_composer(
Composer::WordWrapper { trim: true },
text_multi_space,
width as u16,
);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16);
let (line_truncator, _, _) = run_composer(Composer::LineTruncator, text, width as u16);
let word_wrapped = vec![
"abcd efghij",
@@ -346,8 +458,8 @@ mod test {
fn line_composer_zero_width() {
let width = 0;
let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab ";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _, _) = run_composer(Composer::LineTruncator, text, width);
let expected: Vec<&str> = Vec::new();
assert_eq!(word_wrapper, expected);
@@ -358,8 +470,8 @@ mod test {
fn line_composer_max_line_width_of_1() {
let width = 1;
let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab ";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _, _) = run_composer(Composer::LineTruncator, text, width);
let expected: Vec<&str> = UnicodeSegmentation::graphemes(text, true)
.filter(|g| g.chars().any(|c| !c.is_whitespace()))
@@ -371,20 +483,21 @@ mod test {
#[test]
fn line_composer_max_line_width_of_1_double_width_characters() {
let width = 1;
let text = "コンピュータ上で文字を扱う場合、典型的には文字\naaaによる通信を行う場合にその\
let text =
"コンピュータ上で文字を扱う場合、典型的には文字\naaa\naによる通信を行う場合にその\
両端点では、";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
assert_eq!(word_wrapper, vec!["", "a", "a", "a"]);
assert_eq!(line_truncator, vec!["", "a"]);
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _, _) = run_composer(Composer::LineTruncator, text, width);
assert_eq!(word_wrapper, vec!["", "a", "a", "a", "a"]);
assert_eq!(line_truncator, vec!["", "a", "a"]);
}
/// Tests WordWrapper with words some of which exceed line length and some not.
/// Tests `WordWrapper` with words some of which exceed line length and some not.
#[test]
fn line_composer_word_wrapper_mixed_length() {
let width = 20;
let text = "abcd efghij klmnopabcdefghijklmnopabcdefghijkl mnopab cdefghi j klmno";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
assert_eq!(
word_wrapper,
vec![
@@ -394,7 +507,7 @@ mod test {
"mnopab cdefghi j",
"klmno",
]
)
);
}
#[test]
@@ -402,9 +515,9 @@ mod test {
let width = 20;
let text = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点\
では、";
let (word_wrapper, word_wrapper_width) =
let (word_wrapper, word_wrapper_width, _) =
run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
let (line_truncator, _, _) = run_composer(Composer::LineTruncator, text, width);
assert_eq!(line_truncator, vec!["コンピュータ上で文字"]);
let wrapped = vec![
"コンピュータ上で文字",
@@ -421,8 +534,8 @@ mod test {
fn line_composer_leading_whitespace_removal() {
let width = 20;
let text = "AAAAAAAAAAAAAAAAAAAA AAA";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _, _) = run_composer(Composer::LineTruncator, text, width);
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", "AAA",]);
assert_eq!(line_truncator, vec!["AAAAAAAAAAAAAAAAAAAA"]);
}
@@ -432,20 +545,20 @@ mod test {
fn line_composer_lots_of_spaces() {
let width = 20;
let text = " ";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _, _) = run_composer(Composer::LineTruncator, text, width);
assert_eq!(word_wrapper, vec![""]);
assert_eq!(line_truncator, vec![" "]);
}
/// Tests an input starting with a letter, folowed by spaces - some of the behaviour is
/// Tests an input starting with a letter, followed by spaces - some of the behaviour is
/// incidental.
#[test]
fn line_composer_char_plus_lots_of_spaces() {
let width = 20;
let text = "a ";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width);
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (line_truncator, _, _) = run_composer(Composer::LineTruncator, text, width);
// What's happening below is: the first line gets consumed, trailing spaces discarded,
// after 20 of which a word break occurs (probably shouldn't). The second line break
// discards all whitespace. The result should probably be vec!["a"] but it doesn't matter
@@ -461,9 +574,10 @@ mod test {
// to test double-width chars.
// You are more than welcome to add word boundary detection based of alterations of
// hiragana and katakana...
// This happens to also be a test case for mixed width because regular spaces are single width.
// This happens to also be a test case for mixed width because regular spaces are single
// width.
let text = "コンピュ ータ上で文字を扱う場合、 典型的には文 字による 通信を行 う場合にその両端点では、";
let (word_wrapper, word_wrapper_width) =
let (word_wrapper, word_wrapper_width, _) =
run_composer(Composer::WordWrapper { trim: true }, text, width);
assert_eq!(
word_wrapper,
@@ -485,21 +599,24 @@ mod test {
fn line_composer_word_wrapper_nbsp() {
let width = 20;
let text = "AAAAAAAAAAAAAAA AAAA\u{00a0}AAA";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width);
let (word_wrapper, word_wrapper_widths, _) =
run_composer(Composer::WordWrapper { trim: true }, text, width);
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAA", "AAAA\u{00a0}AAA",]);
assert_eq!(word_wrapper_widths, vec![15, 8]);
// Ensure that if the character was a regular space, it would be wrapped differently.
let text_space = text.replace('\u{00a0}', " ");
let (word_wrapper_space, _) =
run_composer(Composer::WordWrapper { trim: true }, &text_space, width);
let (word_wrapper_space, word_wrapper_widths, _) =
run_composer(Composer::WordWrapper { trim: true }, text_space, width);
assert_eq!(word_wrapper_space, vec!["AAAAAAAAAAAAAAA AAAA", "AAA",]);
assert_eq!(word_wrapper_widths, vec![20, 3])
}
#[test]
fn line_composer_word_wrapper_preserve_indentation() {
let width = 20;
let text = "AAAAAAAAAAAAAAAAAAAA AAA";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", " AAA",]);
}
@@ -507,7 +624,7 @@ mod test {
fn line_composer_word_wrapper_preserve_indentation_with_wrap() {
let width = 10;
let text = "AAA AAA AAAAA AA AAAAAA\n B\n C\n D";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
assert_eq!(
word_wrapper,
vec!["AAA AAA", "AAAAA AA", "AAAAAA", " B", " C", " D"]
@@ -518,7 +635,7 @@ mod test {
fn line_composer_word_wrapper_preserve_indentation_lots_of_whitespace() {
let width = 10;
let text = " 4 Indent\n must wrap!";
let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: false }, text, width);
assert_eq!(
word_wrapper,
vec![
@@ -531,4 +648,43 @@ mod test {
]
);
}
#[test]
fn line_composer_zero_width_at_end() {
let width = 3;
let line = "foo\0";
let (word_wrapper, _, _) = run_composer(Composer::WordWrapper { trim: true }, line, width);
let (line_truncator, _, _) = run_composer(Composer::LineTruncator, line, width);
assert_eq!(word_wrapper, vec!["foo\0"]);
assert_eq!(line_truncator, vec!["foo\0"]);
}
#[test]
fn line_composer_preserves_line_alignment() {
let width = 20;
let lines = vec![
Line::from("Something that is left aligned.").alignment(Alignment::Left),
Line::from("This is right aligned and half short.").alignment(Alignment::Right),
Line::from("This should sit in the center.").alignment(Alignment::Center),
];
let (_, _, wrapped_alignments) =
run_composer(Composer::WordWrapper { trim: true }, lines.clone(), width);
let (_, _, truncated_alignments) = run_composer(Composer::LineTruncator, lines, width);
assert_eq!(
wrapped_alignments,
vec![
Alignment::Left,
Alignment::Left,
Alignment::Right,
Alignment::Right,
Alignment::Right,
Alignment::Center,
Alignment::Center
]
);
assert_eq!(
truncated_alignments,
vec![Alignment::Left, Alignment::Right, Alignment::Center]
);
}
}

881
src/widgets/scrollbar.rs Normal file
View File

@@ -0,0 +1,881 @@
use super::StatefulWidget;
use crate::{
buffer::Buffer,
layout::Rect,
style::Style,
symbols::{block::FULL, line},
};
/// Scrollbar Set
/// ```text
/// <--▮------->
/// ^ ^ ^ ^
/// │ │ │ └ end
/// │ │ └──── track
/// │ └──────── thumb
/// └─────────── begin
/// ```
#[derive(Debug, Clone)]
pub struct Set {
pub track: &'static str,
pub thumb: &'static str,
pub begin: &'static str,
pub end: &'static str,
}
pub const DOUBLE_VERTICAL: Set = Set {
track: line::DOUBLE_VERTICAL,
thumb: FULL,
begin: "",
end: "",
};
pub const DOUBLE_HORIZONTAL: Set = Set {
track: line::DOUBLE_HORIZONTAL,
thumb: FULL,
begin: "",
end: "",
};
pub const VERTICAL: Set = Set {
track: line::VERTICAL,
thumb: FULL,
begin: "",
end: "",
};
pub const HORIZONTAL: Set = Set {
track: line::HORIZONTAL,
thumb: FULL,
begin: "",
end: "",
};
/// An enum representing the direction of scrolling in a Scrollbar widget.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ScrollDirection {
/// Forward scroll direction, usually corresponds to scrolling downwards or rightwards.
#[default]
Forward,
/// Backward scroll direction, usually corresponds to scrolling upwards or leftwards.
Backward,
}
/// A struct representing the state of a Scrollbar widget.
///
/// For example, in the following list, assume there are 4 bullet points:
///
/// - the `position` is 0
/// - the `content_length` is 4
/// - the `viewport_content_length` is 2
///
/// ```text
/// ┌───────────────┐
/// │1. this is a █
/// │ single item █
/// │2. this is a ║
/// │ second item ║
/// └───────────────┘
/// ```
///
/// If you don't have multi-line content, you can leave the `viewport_content_length` set to the
/// default of 0 and it'll use the track size as a `viewport_content_length`.
#[derive(Clone, Copy, Debug, Default)]
pub struct ScrollbarState {
// The current position within the scrollable content.
position: u16,
// The total length of the scrollable content.
content_length: u16,
// The length of content in current viewport.
viewport_content_length: u16,
}
impl ScrollbarState {
/// Sets the scroll position of the scrollbar and returns the modified ScrollbarState.
pub fn position(mut self, position: u16) -> Self {
self.position = position;
self
}
/// Sets the length of the scrollable content and returns the modified ScrollbarState.
pub fn content_length(mut self, content_length: u16) -> Self {
self.content_length = content_length;
self
}
/// Sets the length of the viewport content and returns the modified ScrollbarState.
pub fn viewport_content_length(mut self, viewport_content_length: u16) -> Self {
self.viewport_content_length = viewport_content_length;
self
}
/// Decrements the scroll position by one, ensuring it doesn't go below zero.
pub fn prev(&mut self) {
self.position = self.position.saturating_sub(1);
}
/// Increments the scroll position by one, ensuring it doesn't exceed the length of the content.
pub fn next(&mut self) {
self.position = self
.position
.saturating_add(1)
.clamp(0, self.content_length.saturating_sub(1))
}
/// Sets the scroll position to the start of the scrollable content.
pub fn first(&mut self) {
self.position = 0;
}
/// Sets the scroll position to the end of the scrollable content.
pub fn last(&mut self) {
self.position = self.content_length.saturating_sub(1)
}
/// Changes the scroll position based on the provided ScrollDirection.
pub fn scroll(&mut self, direction: ScrollDirection) {
match direction {
ScrollDirection::Forward => {
self.next();
}
ScrollDirection::Backward => {
self.prev();
}
}
}
}
/// Scrollbar Orientation
#[derive(Default, Debug, Clone)]
pub enum ScrollbarOrientation {
#[default]
VerticalRight,
VerticalLeft,
HorizontalBottom,
HorizontalTop,
}
/// Scrollbar widget for tui-rs library.
///
/// This widget can be used to display a scrollbar in a terminal user interface.
/// The following components of the scrollbar are customizable in symbol and style.
///
/// ```text
/// <--▮------->
/// ^ ^ ^ ^
/// │ │ │ └ end
/// │ │ └──── track
/// │ └──────── thumb
/// └─────────── begin
/// ```
#[derive(Debug, Clone)]
pub struct Scrollbar<'a> {
orientation: ScrollbarOrientation,
thumb_style: Style,
thumb_symbol: &'a str,
track_style: Style,
track_symbol: &'a str,
begin_symbol: Option<&'a str>,
begin_style: Style,
end_symbol: Option<&'a str>,
end_style: Style,
}
impl<'a> Default for Scrollbar<'a> {
fn default() -> Self {
Self {
orientation: ScrollbarOrientation::default(),
thumb_symbol: DOUBLE_VERTICAL.thumb,
thumb_style: Style::default(),
track_symbol: DOUBLE_VERTICAL.track,
track_style: Style::default(),
begin_symbol: Some(DOUBLE_VERTICAL.begin),
begin_style: Style::default(),
end_symbol: Some(DOUBLE_VERTICAL.end),
end_style: Style::default(),
}
}
}
impl<'a> Scrollbar<'a> {
pub fn new(orientation: ScrollbarOrientation) -> Self {
Self::default().orientation(orientation)
}
/// Sets the orientation of the scrollbar.
/// Resets the symbols to [`DOUBLE_VERTICAL`] or [`DOUBLE_HORIZONTAL`] based on orientation
pub fn orientation(mut self, orientation: ScrollbarOrientation) -> Self {
self.orientation = orientation;
let set = if self.is_vertical() {
DOUBLE_VERTICAL
} else {
DOUBLE_HORIZONTAL
};
self.symbols(set)
}
/// Sets the orientation and symbols for the scrollbar from a [`Set`].
pub fn orientation_and_symbol(mut self, orientation: ScrollbarOrientation, set: Set) -> Self {
self.orientation = orientation;
self.symbols(set)
}
/// Sets the symbol that represents the thumb of the scrollbar.
pub fn thumb_symbol(mut self, thumb_symbol: &'a str) -> Self {
self.thumb_symbol = thumb_symbol;
self
}
/// Sets the style that represents the thumb of the scrollbar.
pub fn thumb_style(mut self, thumb_style: Style) -> Self {
self.thumb_style = thumb_style;
self
}
/// Sets the symbol that represents the track of the scrollbar.
pub fn track_symbol(mut self, track_symbol: &'a str) -> Self {
self.track_symbol = track_symbol;
self
}
/// Sets the style that is used for the track of the scrollbar.
pub fn track_style(mut self, track_style: Style) -> Self {
self.track_style = track_style;
self
}
/// Sets the symbol that represents the beginning of the scrollbar.
pub fn begin_symbol(mut self, begin_symbol: Option<&'a str>) -> Self {
self.begin_symbol = begin_symbol;
self
}
/// Sets the style that is used for the beginning of the scrollbar.
pub fn begin_style(mut self, begin_style: Style) -> Self {
self.begin_style = begin_style;
self
}
/// Sets the symbol that represents the end of the scrollbar.
pub fn end_symbol(mut self, end_symbol: Option<&'a str>) -> Self {
self.end_symbol = end_symbol;
self
}
/// Sets the style that is used for the end of the scrollbar.
pub fn end_style(mut self, end_style: Style) -> Self {
self.end_style = end_style;
self
}
/// Sets the symbols used for the various parts of the scrollbar from a [`Set`].
///
/// ```text
/// <--▮------->
/// ^ ^ ^ ^
/// │ │ │ └ end
/// │ │ └──── track
/// │ └──────── thumb
/// └─────────── begin
/// ```
///
/// Only sets begin_symbol and end_symbol if they already contain a value.
/// If begin_symbol and/or end_symbol were set to `None` explicitly, this function will respect
/// that choice.
pub fn symbols(mut self, symbol: Set) -> Self {
self.track_symbol = symbol.track;
self.thumb_symbol = symbol.thumb;
if self.begin_symbol.is_some() {
self.begin_symbol = Some(symbol.begin);
}
if self.end_symbol.is_some() {
self.end_symbol = Some(symbol.end);
}
self
}
/// Sets the style used for the various parts of the scrollbar from a [`Style`].
/// ```text
/// <--▮------->
/// ^ ^ ^ ^
/// │ │ │ └ end
/// │ │ └──── track
/// │ └──────── thumb
/// └─────────── begin
/// ```
pub fn style(mut self, style: Style) -> Self {
self.track_style = style;
self.thumb_style = style;
self.begin_style = style;
self.end_style = style;
self
}
fn is_vertical(&self) -> bool {
match self.orientation {
ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => true,
ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => false,
}
}
fn get_track_area(&self, area: Rect) -> Rect {
// Decrease track area if a begin arrow is present
let area = if self.begin_symbol.is_some() {
if self.is_vertical() {
// For vertical scrollbar, reduce the height by one
Rect::new(
area.x,
area.y + 1,
area.width,
area.height.saturating_sub(1),
)
} else {
// For horizontal scrollbar, reduce the width by one
Rect::new(
area.x + 1,
area.y,
area.width.saturating_sub(1),
area.height,
)
}
} else {
area
};
// Further decrease scrollbar area if an end arrow is present
if self.end_symbol.is_some() {
if self.is_vertical() {
// For vertical scrollbar, reduce the height by one
Rect::new(area.x, area.y, area.width, area.height.saturating_sub(1))
} else {
// For horizontal scrollbar, reduce the width by one
Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height)
}
} else {
area
}
}
fn should_not_render(&self, track_start: u16, track_end: u16, content_length: u16) -> bool {
if track_end - track_start == 0 || content_length == 0 {
return true;
}
false
}
fn get_track_start_end(&self, area: Rect) -> (u16, u16, u16) {
match self.orientation {
ScrollbarOrientation::VerticalRight => {
(area.top(), area.bottom(), area.right().saturating_sub(1))
}
ScrollbarOrientation::VerticalLeft => (area.top(), area.bottom(), area.left()),
ScrollbarOrientation::HorizontalBottom => {
(area.left(), area.right(), area.bottom().saturating_sub(1))
}
ScrollbarOrientation::HorizontalTop => (area.left(), area.right(), area.top()),
}
}
/// Calculate the starting and ending position of a scrollbar thumb.
///
/// The scrollbar thumb's position and size are determined based on the current state of the
/// scrollbar, and the dimensions of the scrollbar track.
///
/// This function returns a tuple `(thumb_start, thumb_end)` where `thumb_start` is the position
/// at which the scrollbar thumb begins, and `thumb_end` is the position at which the
/// scrollbar thumb ends.
///
/// The size of the thumb (i.e., `thumb_end - thumb_start`) is proportional to the ratio of the
/// viewport content length to the total content length.
///
/// The position of the thumb (i.e., `thumb_start`) is proportional to the ratio of the current
/// scroll position to the total content length.
fn get_thumb_start_end(
&self,
state: &ScrollbarState,
track_start_end: (u16, u16),
) -> (u16, u16) {
// let (track_start, track_end) = track_start_end;
// let track_size = track_end - track_start;
// let thumb_size =
// ((state.viewport_content_length / state.content_length) * track_size).max(1);
// let thumb_start = (state.position / state.content_length) *
// state.viewport_content_length;
// let thumb_end = thumb_size + thumb_start;
// (thumb_start, thumb_end)
let (track_start, track_end) = track_start_end;
let viewport_content_length = if state.viewport_content_length == 0 {
track_end - track_start
} else {
state.viewport_content_length
};
let scroll_position_ratio = (state.position as f64 / state.content_length as f64).min(1.0);
let thumb_size = (((viewport_content_length as f64 / state.content_length as f64)
* (track_end - track_start) as f64)
.round() as u16)
.max(1);
let track_size = (track_end - track_start).saturating_sub(thumb_size);
let thumb_start = track_start + (scroll_position_ratio * track_size as f64).round() as u16;
let thumb_end = thumb_start + thumb_size;
(thumb_start, thumb_end)
}
}
impl<'a> StatefulWidget for Scrollbar<'a> {
type State = ScrollbarState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
//
// For ScrollbarOrientation::VerticalRight
//
// ┌───────── track_axis (x)
// v
// ┌───────────────┐
// │ ║<──────── track_start (y1)
// │ █
// │ █
// │ ║
// │ ║<──────── track_end (y2)
// └───────────────┘
//
// For ScrollbarOrientation::HorizontalBottom
//
// ┌───────────────┐
// │ │
// │ │
// │ │
// └═══███═════════┘<──────── track_axis (y)
// ^ ^
// │ └────────── track_end (x2)
// │
// └──────────────────────── track_start (x1)
//
// Find track_start, track_end, and track_axis
let area = self.get_track_area(area);
let (track_start, track_end, track_axis) = self.get_track_start_end(area);
if self.should_not_render(track_start, track_end, state.content_length) {
return;
}
let (thumb_start, thumb_end) = self.get_thumb_start_end(state, (track_start, track_end));
for i in track_start..track_end {
let (style, symbol) = if i >= thumb_start && i < thumb_end {
(self.thumb_style, self.thumb_symbol)
} else {
(self.track_style, self.track_symbol)
};
if self.is_vertical() {
buf.set_string(track_axis, i, symbol, style);
} else {
buf.set_string(i, track_axis, symbol, style);
}
}
if let Some(s) = self.begin_symbol {
if self.is_vertical() {
buf.set_string(track_axis, track_start - 1, s, self.begin_style);
} else {
buf.set_string(track_start - 1, track_axis, s, self.begin_style);
}
};
if let Some(s) = self.end_symbol {
if self.is_vertical() {
buf.set_string(track_axis, track_end, s, self.end_style);
} else {
buf.set_string(track_end, track_axis, s, self.end_style);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assert_buffer_eq;
#[test]
fn test_no_render_when_area_zero() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 0, 0));
let mut state = ScrollbarState::default().position(0).content_length(1);
Scrollbar::default().render(buffer.area, &mut buffer, &mut state);
assert_buffer_eq!(buffer, Buffer::empty(buffer.area));
}
#[test]
fn test_no_render_when_height_zero_with_without_arrows() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 3, 0));
let mut state = ScrollbarState::default().position(0).content_length(1);
Scrollbar::default().render(buffer.area, &mut buffer, &mut state);
assert_buffer_eq!(buffer, Buffer::empty(buffer.area));
let mut buffer = Buffer::empty(Rect::new(0, 0, 3, 0));
let mut state = ScrollbarState::default().position(0).content_length(1);
Scrollbar::default()
.begin_symbol(None)
.end_symbol(None)
.render(buffer.area, &mut buffer, &mut state);
assert_buffer_eq!(buffer, Buffer::empty(buffer.area));
}
#[test]
fn test_no_render_when_height_too_small_for_arrows() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2));
let mut state = ScrollbarState::default().position(0).content_length(1);
Scrollbar::default().render(buffer.area, &mut buffer, &mut state);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" ", " "]));
}
#[test]
fn test_renders_all_thumbs_at_minimum_height_without_arrows() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2));
let mut state = ScrollbarState::default().position(0).content_length(1);
Scrollbar::default()
.begin_symbol(None)
.end_symbol(None)
.render(buffer.area, &mut buffer, &mut state);
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["", ""]));
}
#[test]
fn test_renders_all_thumbs_at_minimum_height_and_minimum_width_without_arrows() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 2));
let mut state = ScrollbarState::default().position(0).content_length(1);
Scrollbar::default()
.begin_symbol(None)
.end_symbol(None)
.render(buffer.area, &mut buffer, &mut state);
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["", ""]));
}
#[test]
fn test_renders_two_arrows_one_thumb_at_minimum_height_with_arrows() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 3));
let mut state = ScrollbarState::default().position(0).content_length(1);
Scrollbar::default().render(buffer.area, &mut buffer, &mut state);
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["", "", ""]));
}
#[test]
fn test_no_render_when_content_length_zero() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 2));
let mut state = ScrollbarState::default().position(0).content_length(0);
Scrollbar::default().render(buffer.area, &mut buffer, &mut state);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" ", " "]));
}
#[test]
fn test_renders_all_thumbs_when_height_equals_content_length() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 2));
let mut state = ScrollbarState::default().position(0).content_length(2);
Scrollbar::default()
.begin_symbol(None)
.end_symbol(None)
.render(buffer.area, &mut buffer, &mut state);
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["", ""]));
let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 8));
let mut state = ScrollbarState::default().position(0).content_length(8);
Scrollbar::default()
.begin_symbol(None)
.end_symbol(None)
.render(buffer.area, &mut buffer, &mut state);
assert_buffer_eq!(
buffer,
Buffer::with_lines(vec!["", "", "", "", "", "", "", ""])
);
}
#[test]
fn test_renders_single_vertical_thumb_when_content_length_square_of_height() {
for i in 0..=17 {
let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 4));
let mut state = ScrollbarState::default().position(i).content_length(16);
Scrollbar::default()
.begin_symbol(None)
.end_symbol(None)
.render(buffer.area, &mut buffer, &mut state);
let expected = if i <= 2 {
vec!["", "", "", ""]
} else if i <= 7 {
vec!["", "", "", ""]
} else if i <= 13 {
vec!["", "", "", ""]
} else {
vec!["", "", "", ""]
};
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
}
#[test]
fn test_renders_single_horizontal_thumb_when_content_length_square_of_width() {
for i in 0..=17 {
let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2));
let mut state = ScrollbarState::default().position(i).content_length(16);
Scrollbar::default()
.begin_symbol(None)
.end_symbol(None)
.orientation(ScrollbarOrientation::HorizontalBottom)
.render(buffer.area, &mut buffer, &mut state);
let expected = if i <= 2 {
vec![" ", "█═══"]
} else if i <= 7 {
vec![" ", "═█══"]
} else if i <= 13 {
vec![" ", "══█═"]
} else {
vec![" ", "═══█"]
};
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
}
#[test]
fn test_renders_one_thumb_for_large_content_relative_to_height() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2));
let mut state = ScrollbarState::default().position(0).content_length(1600);
Scrollbar::default()
.begin_symbol(None)
.end_symbol(None)
.orientation(ScrollbarOrientation::HorizontalBottom)
.render(buffer.area, &mut buffer, &mut state);
let expected = vec![" ", "█═══"];
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2));
let mut state = ScrollbarState::default().position(800).content_length(1600);
Scrollbar::default()
.begin_symbol(None)
.end_symbol(None)
.orientation(ScrollbarOrientation::HorizontalBottom)
.render(buffer.area, &mut buffer, &mut state);
let expected = vec![" ", "══█═"];
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
#[test]
fn test_renders_two_thumb_default_symbols_for_content_double_height() {
for i in 0..=7 {
let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 4));
let mut state = ScrollbarState::default().position(i).content_length(8);
Scrollbar::default()
.begin_symbol(None)
.end_symbol(None)
.render(buffer.area, &mut buffer, &mut state);
let expected = if i <= 1 {
vec!["", "", "", ""]
} else if i <= 5 {
vec!["", "", "", ""]
} else {
vec!["", "", "", ""]
};
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
}
#[test]
fn test_renders_two_thumb_custom_symbols_for_content_double_height() {
for i in 0..=7 {
let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 4));
let mut state = ScrollbarState::default().position(i).content_length(8);
Scrollbar::default()
.symbols(VERTICAL)
.begin_symbol(None)
.end_symbol(None)
.render(buffer.area, &mut buffer, &mut state);
let expected = if i <= 1 {
vec!["", "", "", ""]
} else if i <= 5 {
vec!["", "", "", ""]
} else {
vec!["", "", "", ""]
};
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
}
#[test]
fn test_renders_two_thumb_default_symbols_for_content_double_width() {
for i in 0..=7 {
let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2));
let mut state = ScrollbarState::default().position(i).content_length(8);
Scrollbar::default()
.orientation(ScrollbarOrientation::HorizontalBottom)
.begin_symbol(None)
.end_symbol(None)
.render(buffer.area, &mut buffer, &mut state);
let expected = if i <= 1 {
vec![" ", "██══"]
} else if i <= 5 {
vec![" ", "═██═"]
} else {
vec![" ", "══██"]
};
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
}
#[test]
fn test_renders_two_thumb_custom_symbols_for_content_double_width() {
for i in 0..=7 {
let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2));
let mut state = ScrollbarState::default().position(i).content_length(8);
Scrollbar::default()
.orientation(ScrollbarOrientation::HorizontalBottom)
.symbols(HORIZONTAL)
.begin_symbol(None)
.end_symbol(None)
.render(buffer.area, &mut buffer, &mut state);
let expected = if i <= 1 {
vec![" ", "██──"]
} else if i <= 5 {
vec![" ", "─██─"]
} else {
vec![" ", "──██"]
};
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
}
#[test]
fn test_rendering_viewport_content_length() {
for i in 0..=16 {
let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2));
let mut state = ScrollbarState::default()
.position(i)
.content_length(16)
.viewport_content_length(4);
Scrollbar::default()
.orientation(ScrollbarOrientation::HorizontalBottom)
.begin_symbol(Some(DOUBLE_HORIZONTAL.begin))
.end_symbol(Some(DOUBLE_HORIZONTAL.end))
.render(buffer.area, &mut buffer, &mut state);
let expected = if i <= 1 {
vec![" ", "◄██════►"]
} else if i <= 5 {
vec![" ", "◄═██═══►"]
} else if i <= 9 {
vec![" ", "◄══██══►"]
} else if i <= 13 {
vec![" ", "◄═══██═►"]
} else {
vec![" ", "◄════██►"]
};
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
for i in 0..=16 {
let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2));
let mut state = ScrollbarState::default()
.position(i)
.content_length(16)
.viewport_content_length(1);
Scrollbar::default()
.orientation(ScrollbarOrientation::HorizontalBottom)
.begin_symbol(Some(DOUBLE_HORIZONTAL.begin))
.end_symbol(Some(DOUBLE_HORIZONTAL.end))
.render(buffer.area, &mut buffer, &mut state);
dbg!(i);
let expected = if i <= 1 {
vec![" ", "◄█═════►"]
} else if i <= 4 {
vec![" ", "◄═█════►"]
} else if i <= 7 {
vec![" ", "◄══█═══►"]
} else if i <= 11 {
vec![" ", "◄═══█══►"]
} else if i <= 14 {
vec![" ", "◄════█═►"]
} else {
vec![" ", "◄═════█►"]
};
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
}
#[test]
fn test_rendering_begin_end_arrows_horizontal_bottom() {
for i in 0..=16 {
let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2));
let mut state = ScrollbarState::default().position(i).content_length(16);
Scrollbar::default()
.orientation(ScrollbarOrientation::HorizontalBottom)
.begin_symbol(Some(DOUBLE_HORIZONTAL.begin))
.end_symbol(Some(DOUBLE_HORIZONTAL.end))
.render(buffer.area, &mut buffer, &mut state);
let expected = if i <= 1 {
vec![" ", "◄██════►"]
} else if i <= 5 {
vec![" ", "◄═██═══►"]
} else if i <= 9 {
vec![" ", "◄══██══►"]
} else if i <= 13 {
vec![" ", "◄═══██═►"]
} else {
vec![" ", "◄════██►"]
};
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
}
#[test]
fn test_rendering_begin_end_arrows_horizontal_top() {
for i in 0..=16 {
let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2));
let mut state = ScrollbarState::default().position(i).content_length(16);
Scrollbar::default()
.orientation(ScrollbarOrientation::HorizontalTop)
.begin_symbol(Some(DOUBLE_HORIZONTAL.begin))
.end_symbol(Some(DOUBLE_HORIZONTAL.end))
.render(buffer.area, &mut buffer, &mut state);
let expected = if i <= 1 {
vec!["◄██════►", " "]
} else if i <= 5 {
vec!["◄═██═══►", " "]
} else if i <= 9 {
vec!["◄══██══►", " "]
} else if i <= 13 {
vec!["◄═══██═►", " "]
} else {
vec!["◄════██►", " "]
};
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
}
#[test]
fn test_rendering_only_begin_arrow_horizontal_bottom() {
for i in 0..=16 {
let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2));
let mut state = ScrollbarState::default().position(i).content_length(16);
Scrollbar::default()
.orientation(ScrollbarOrientation::HorizontalBottom)
.begin_symbol(Some(DOUBLE_HORIZONTAL.begin))
.end_symbol(None)
.render(buffer.area, &mut buffer, &mut state);
let expected = if i <= 1 {
vec![" ", "◄███════"]
} else if i <= 5 {
vec![" ", "◄═███═══"]
} else if i <= 9 {
vec![" ", "◄══███══"]
} else if i <= 13 {
vec![" ", "◄═══███═"]
} else {
vec![" ", "◄════███"]
};
assert_buffer_eq!(buffer, Buffer::with_lines(expected.clone()));
}
}
}

View File

@@ -1,3 +1,5 @@
use std::cmp::min;
use crate::{
buffer::Buffer,
layout::Rect,
@@ -5,15 +7,14 @@ use crate::{
symbols,
widgets::{Block, Widget},
};
use std::cmp::min;
/// Widget to render a sparkline over one or more lines.
///
/// # Examples
///
/// ```
/// # use tui::widgets::{Block, Borders, Sparkline};
/// # use tui::style::{Style, Color};
/// # use ratatui::widgets::{Block, Borders, Sparkline};
/// # use ratatui::style::{Style, Color};
/// Sparkline::default()
/// .block(Block::default().title("Sparkline").borders(Borders::ALL))
/// .data(&[0, 2, 3, 4, 1, 4, 10])
@@ -33,16 +34,25 @@ pub struct Sparkline<'a> {
max: Option<u64>,
/// A set of bar symbols used to represent the give data
bar_set: symbols::bar::Set,
// The direction to render the sparkine, either from left to right, or from right to left
direction: RenderDirection,
}
#[derive(Debug, Clone, Copy)]
pub enum RenderDirection {
LeftToRight,
RightToLeft,
}
impl<'a> Default for Sparkline<'a> {
fn default() -> Sparkline<'a> {
Sparkline {
block: None,
style: Default::default(),
style: Style::default(),
data: &[],
max: None,
bar_set: symbols::bar::NINE_LEVELS,
direction: RenderDirection::LeftToRight,
}
}
}
@@ -72,6 +82,11 @@ impl<'a> Sparkline<'a> {
self.bar_set = bar_set;
self
}
pub fn direction(mut self, direction: RenderDirection) -> Sparkline<'a> {
self.direction = direction;
self
}
}
impl<'a> Widget for Sparkline<'a> {
@@ -99,10 +114,10 @@ impl<'a> Widget for Sparkline<'a> {
.iter()
.take(max_index)
.map(|e| {
if max != 0 {
e * u64::from(spark_area.height) * 8 / max
} else {
if max == 0 {
0
} else {
e * u64::from(spark_area.height) * 8 / max
}
})
.collect::<Vec<u64>>();
@@ -119,7 +134,11 @@ 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)
let x = match self.direction {
RenderDirection::LeftToRight => spark_area.left() + i as u16,
RenderDirection::RightToLeft => spark_area.right() - i as u16 - 1,
};
buf.get_mut(x, spark_area.top() + j)
.set_symbol(symbol)
.set_style(self.style);
@@ -136,20 +155,55 @@ impl<'a> Widget for Sparkline<'a> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{assert_buffer_eq, buffer::Cell};
// Helper function to render a sparkline to a buffer with a given width
// filled with x symbols to make it easier to assert on the result
fn render(widget: Sparkline, width: u16) -> Buffer {
let area = Rect::new(0, 0, width, 1);
let mut cell = Cell::default();
cell.set_symbol("x");
let mut buffer = Buffer::filled(area, &cell);
widget.render(area, &mut buffer);
buffer
}
#[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 buffer = render(widget, 6);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" xxx"]));
}
#[test]
fn it_does_not_panic_if_max_is_set_to_zero() {
let widget = Sparkline::default().data(&[0, 1, 2]).max(0);
let area = Rect::new(0, 0, 3, 1);
let mut buffer = Buffer::empty(area);
widget.render(area, &mut buffer);
let buffer = render(widget, 6);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" xxx"]));
}
#[test]
fn it_draws() {
let widget = Sparkline::default().data(&[0, 1, 2, 3, 4, 5, 6, 7, 8]);
let buffer = render(widget, 12);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" ▁▂▃▄▅▆▇█xxx"]));
}
#[test]
fn it_renders_left_to_right() {
let widget = Sparkline::default()
.data(&[0, 1, 2, 3, 4, 5, 6, 7, 8])
.direction(RenderDirection::LeftToRight);
let buffer = render(widget, 12);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" ▁▂▃▄▅▆▇█xxx"]));
}
#[test]
fn it_renders_right_to_left() {
let widget = Sparkline::default()
.data(&[0, 1, 2, 3, 4, 5, 6, 7, 8])
.direction(RenderDirection::RightToLeft);
let buffer = render(widget, 12);
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["xxx█▇▆▅▄▃▂▁ "]));
}
}

View File

@@ -1,3 +1,5 @@
use unicode_width::UnicodeWidthStr;
use crate::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
@@ -5,21 +7,20 @@ use crate::{
text::Text,
widgets::{Block, StatefulWidget, Widget},
};
use unicode_width::UnicodeWidthStr;
/// A [`Cell`] contains the [`Text`] to be displayed in a [`Row`] of a [`Table`].
///
/// It can be created from anything that can be converted to a [`Text`].
/// ```rust
/// # use tui::widgets::Cell;
/// # use tui::style::{Style, Modifier};
/// # use tui::text::{Span, Spans, Text};
/// # use ratatui::widgets::Cell;
/// # use ratatui::style::{Style, Modifier};
/// # use ratatui::text::{Span, Line, Text};
/// # use std::borrow::Cow;
/// Cell::from("simple string");
///
/// Cell::from(Span::from("span"));
///
/// Cell::from(Spans::from(vec![
/// Cell::from(Line::from(vec![
/// Span::raw("a vec of "),
/// Span::styled("spans", Style::default().add_modifier(Modifier::BOLD))
/// ]));
@@ -31,7 +32,7 @@ use unicode_width::UnicodeWidthStr;
///
/// 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)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Cell<'a> {
content: Text<'a>,
style: Style,
@@ -61,14 +62,14 @@ where
///
/// A [`Row`] is a collection of cells. It can be created from simple strings:
/// ```rust
/// # use tui::widgets::Row;
/// # use ratatui::widgets::Row;
/// Row::new(vec!["Cell1", "Cell2", "Cell3"]);
/// ```
///
/// But if you need a bit more control over individual cells, you can explicity create [`Cell`]s:
/// But if you need a bit more control over individual cells, you can explicitly create [`Cell`]s:
/// ```rust
/// # use tui::widgets::{Row, Cell};
/// # use tui::style::{Style, Color};
/// # use ratatui::widgets::{Row, Cell};
/// # use ratatui::style::{Style, Color};
/// Row::new(vec![
/// Cell::from("Cell1"),
/// Cell::from("Cell2").style(Style::default().fg(Color::Yellow)),
@@ -78,7 +79,7 @@ where
/// You can also construct a row from any type that can be converted into [`Text`]:
/// ```rust
/// # use std::borrow::Cow;
/// # use tui::widgets::Row;
/// # use ratatui::widgets::Row;
/// Row::new(vec![
/// Cow::Borrowed("hello"),
/// Cow::Owned("world".to_uppercase()),
@@ -86,7 +87,7 @@ where
/// ```
///
/// By default, a row has a height of 1 but you can change this using [`Row::height`].
#[derive(Debug, Clone, PartialEq, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Row<'a> {
cells: Vec<Cell<'a>>,
height: u16,
@@ -103,7 +104,7 @@ impl<'a> Row<'a> {
{
Self {
height: 1,
cells: cells.into_iter().map(|c| c.into()).collect(),
cells: cells.into_iter().map(Into::into).collect(),
style: Style::default(),
bottom_margin: 0,
}
@@ -116,7 +117,7 @@ impl<'a> Row<'a> {
self
}
/// Set the [`Style`] of the entire row. This [`Style`] can be overriden by the [`Style`] of a
/// Set the [`Style`] of the entire row. This [`Style`] can be overridden by the [`Style`] of a
/// any individual [`Cell`] or event by their [`Text`] content.
pub fn style(mut self, style: Style) -> Self {
self.style = style;
@@ -139,10 +140,10 @@ impl<'a> Row<'a> {
///
/// 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};
/// # use ratatui::widgets::{Block, Borders, Table, Row, Cell};
/// # use ratatui::layout::Constraint;
/// # use ratatui::style::{Style, Color, Modifier};
/// # use ratatui::text::{Text, Line, Span};
/// Table::new(vec![
/// // Row can be created from simple strings.
/// Row::new(vec!["Row11", "Row12", "Row13"]),
@@ -152,7 +153,7 @@ impl<'a> Row<'a> {
/// Row::new(vec![
/// Cell::from("Row31"),
/// Cell::from("Row32").style(Style::default().fg(Color::Yellow)),
/// Cell::from(Spans::from(vec![
/// Cell::from(Line::from(vec![
/// Span::raw("Row"),
/// Span::styled("33", Style::default().fg(Color::Green))
/// ])),
@@ -186,7 +187,7 @@ impl<'a> Row<'a> {
/// // ...and potentially show a symbol in front of the selection.
/// .highlight_symbol(">>");
/// ```
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Table<'a> {
/// A block to wrap the widget in
block: Option<Block<'a>>,
@@ -269,8 +270,7 @@ impl<'a> Table<'a> {
fn get_columns_widths(&self, max_width: u16, has_selection: bool) -> Vec<u16> {
let mut constraints = Vec::with_capacity(self.widths.len() * 2 + 1);
if has_selection {
let highlight_symbol_width =
self.highlight_symbol.map(|s| s.width() as u16).unwrap_or(0);
let highlight_symbol_width = self.highlight_symbol.map_or(0, |s| s.width() as u16);
constraints.push(Constraint::Length(highlight_symbol_width));
}
for constraint in self.widths {
@@ -280,7 +280,7 @@ impl<'a> Table<'a> {
if !self.widths.is_empty() {
constraints.pop();
}
let mut chunks = Layout::default()
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.expand_to_fill(false)
@@ -290,8 +290,9 @@ impl<'a> Table<'a> {
width: max_width,
height: 1,
});
let mut chunks = &chunks[..];
if has_selection {
chunks.remove(0);
chunks = &chunks[1..];
}
chunks.iter().step_by(2).map(|c| c.width).collect()
}
@@ -342,6 +343,24 @@ pub struct TableState {
}
impl TableState {
pub fn offset(&self) -> usize {
self.offset
}
pub fn offset_mut(&mut self) -> &mut usize {
&mut self.offset
}
pub fn with_selected(mut self, selected: Option<usize>) -> Self {
self.selected = selected;
self
}
pub fn with_offset(mut self, offset: usize) -> Self {
self.offset = offset;
self
}
pub fn selected(&self) -> Option<usize> {
self.selected
}
@@ -433,7 +452,7 @@ impl<'a> StatefulWidget for Table<'a> {
height: table_row.height,
};
buf.set_style(table_row_area, table_row.style);
let is_selected = state.selected.map(|s| s == i).unwrap_or(false);
let is_selected = state.selected.map_or(false, |s| s == i);
let table_row_start_col = if has_selection {
let symbol = if is_selected {
highlight_symbol
@@ -469,11 +488,11 @@ impl<'a> StatefulWidget for Table<'a> {
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() {
for (i, line) 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);
buf.set_line(area.x, area.y + i as u16, line, area.width);
}
}

View File

@@ -3,7 +3,7 @@ use crate::{
layout::Rect,
style::Style,
symbols,
text::{Span, Spans},
text::{Line, Span},
widgets::{Block, Widget},
};
@@ -12,11 +12,11 @@ use crate::{
/// # Examples
///
/// ```
/// # use tui::widgets::{Block, Borders, Tabs};
/// # use tui::style::{Style, Color};
/// # use tui::text::{Spans};
/// # use tui::symbols::{DOT};
/// let titles = ["Tab1", "Tab2", "Tab3", "Tab4"].iter().cloned().map(Spans::from).collect();
/// # use ratatui::widgets::{Block, Borders, Tabs};
/// # use ratatui::style::{Style, Color};
/// # use ratatui::text::{Line};
/// # use ratatui::symbols::{DOT};
/// let titles = ["Tab1", "Tab2", "Tab3", "Tab4"].iter().cloned().map(Line::from).collect();
/// Tabs::new(titles)
/// .block(Block::default().title("Tabs").borders(Borders::ALL))
/// .style(Style::default().fg(Color::White))
@@ -28,7 +28,7 @@ pub struct Tabs<'a> {
/// A block to wrap this widget in if necessary
block: Option<Block<'a>>,
/// One title for each tab
titles: Vec<Spans<'a>>,
titles: Vec<Line<'a>>,
/// The index of the selected tabs
selected: usize,
/// The style used to draw the text
@@ -40,13 +40,16 @@ pub struct Tabs<'a> {
}
impl<'a> Tabs<'a> {
pub fn new(titles: Vec<Spans<'a>>) -> Tabs<'a> {
pub fn new<T>(titles: Vec<T>) -> Tabs<'a>
where
T: Into<Line<'a>>,
{
Tabs {
block: None,
titles,
titles: titles.into_iter().map(Into::into).collect(),
selected: 0,
style: Default::default(),
highlight_style: Default::default(),
style: Style::default(),
highlight_style: Style::default(),
divider: Span::raw(symbols::line::VERTICAL),
}
}
@@ -105,7 +108,7 @@ 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 = buf.set_line(x, tabs_area.top(), &title, remaining_width);
if i == self.selected {
buf.set_style(
Rect {

View File

@@ -6,7 +6,7 @@ fn backend_termion_should_only_write_diffs() -> Result<(), Box<dyn std::error::E
let mut bytes = Vec::new();
let mut stdout = Cursor::new(&mut bytes);
{
use tui::{
use ratatui::{
backend::TermionBackend, layout::Rect, widgets::Paragraph, Terminal, TerminalOptions,
Viewport,
};
@@ -15,7 +15,7 @@ fn backend_termion_should_only_write_diffs() -> Result<(), Box<dyn std::error::E
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::fixed(area),
viewport: Viewport::Fixed(area),
},
)?;
terminal.draw(|f| {

19
tests/border_macro.rs Normal file
View File

@@ -0,0 +1,19 @@
#![cfg(feature = "macros")]
use ratatui::{border, widgets::Borders};
#[test]
fn border_empty_test() {
let empty = Borders::NONE;
assert_eq!(empty, border!());
}
#[test]
fn border_all_test() {
let all = Borders::ALL;
assert_eq!(all, border!(ALL));
assert_eq!(all, border!(TOP, BOTTOM, LEFT, RIGHT));
}
#[test]
fn border_left_right_test() {
let left_right = Borders::from_bits(Borders::LEFT.bits() | Borders::RIGHT.bits());
assert_eq!(left_right, Some(border!(RIGHT, LEFT)));
}

88
tests/stylize.rs Normal file
View File

@@ -0,0 +1,88 @@
use ratatui::{
backend::TestBackend,
buffer::Buffer,
layout::Rect,
style::{Color, Stylize},
widgets::{Block, Borders, Paragraph},
Terminal,
};
#[test]
fn paragraph_block_styles() {
let backend = TestBackend::new(10, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let paragraph = Paragraph::new("Text".cyan());
f.render_widget(
paragraph,
Rect {
x: 0,
y: 0,
width: 10,
height: 1,
},
);
})
.unwrap();
let mut expected = Buffer::with_lines(vec!["Text "]);
for x in 0..4 {
expected.get_mut(x, 0).set_fg(Color::Cyan);
}
terminal.backend().assert_buffer(&expected);
}
#[test]
fn block_styles() {
let backend = TestBackend::new(10, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let block = Block::default()
.title("Title".light_blue())
.on_cyan()
.cyan()
.borders(Borders::ALL);
f.render_widget(
block,
Rect {
x: 0,
y: 0,
width: 8,
height: 8,
},
);
})
.unwrap();
let mut expected = Buffer::with_lines(vec![
"┌Title─┐ ",
"│ │ ",
"│ │ ",
"│ │ ",
"│ │ ",
"│ │ ",
"│ │ ",
"└──────┘ ",
" ",
" ",
]);
for x in 0..8 {
for y in 0..8 {
expected
.get_mut(x, y)
.set_fg(Color::Cyan)
.set_bg(Color::Cyan);
}
}
for x in 1..=5 {
expected.get_mut(x, 0).set_fg(Color::LightBlue);
}
terminal.backend().assert_buffer(&expected);
}

View File

@@ -1,5 +1,6 @@
use std::error::Error;
use tui::{
use ratatui::{
backend::{Backend, TestBackend},
layout::Rect,
widgets::Paragraph,
@@ -15,20 +16,32 @@ fn terminal_buffer_size_should_be_limited() {
assert_eq!(size.height, 255);
}
#[test]
fn swap_buffer_clears_prev_buffer() {
let backend = TestBackend::new(100, 50);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.current_buffer_mut()
.set_string(0, 0, "Hello", ratatui::style::Style::reset());
assert_eq!(terminal.current_buffer_mut().content()[0].symbol, "H");
terminal.swap_buffers();
assert_eq!(terminal.current_buffer_mut().content()[0].symbol, " ");
}
#[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());
let paragraph = Paragraph::new("Test");
f.render_widget(paragraph, 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());
let paragraph = Paragraph::new("test");
f.render_widget(paragraph, f.size());
})?;
assert_eq!(frame.buffer.get(0, 0).symbol, "t");
assert_eq!(frame.area, Rect::new(0, 0, 8, 8));

View File

@@ -1,7 +1,9 @@
use tui::backend::TestBackend;
use tui::buffer::Buffer;
use tui::widgets::{BarChart, Block, Borders};
use tui::Terminal;
use ratatui::{
backend::TestBackend,
buffer::Buffer,
widgets::{BarChart, Block, Borders},
Terminal,
};
#[test]
fn widgets_barchart_not_full_below_max_value() {

View File

@@ -1,10 +1,13 @@
use tui::{
use ratatui::{
backend::TestBackend,
buffer::Buffer,
layout::{Alignment, Rect},
style::{Color, Style},
text::Span,
widgets::{Block, Borders},
widgets::{
block::title::{Position, Title},
Block, Borders,
},
Terminal,
};
@@ -46,6 +49,80 @@ fn widgets_block_renders() {
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widgets_block_titles_overlap() {
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);
};
// Left overrides the center
test_case(
Block::default()
.title(Title::from("aaaaa").alignment(Alignment::Left))
.title(Title::from("bbb").alignment(Alignment::Center))
.title(Title::from("ccc").alignment(Alignment::Right)),
Rect {
x: 0,
y: 0,
width: 10,
height: 1,
},
Buffer::with_lines(vec!["aaaaab ccc"]),
);
// Left alignment overrides the center alignment which overrides the right alignment
test_case(
Block::default()
.title(Title::from("aaaaa").alignment(Alignment::Left))
.title(Title::from("bbbbb").alignment(Alignment::Center))
.title(Title::from("ccccc").alignment(Alignment::Right)),
Rect {
x: 0,
y: 0,
width: 11,
height: 1,
},
Buffer::with_lines(vec!["aaaaabbbccc"]),
);
// Multiple left alignment overrides the center alignment and the right alignment
test_case(
Block::default()
.title(Title::from("aaaaa").alignment(Alignment::Left))
.title(Title::from("aaaaa").alignment(Alignment::Left))
.title(Title::from("bbbbb").alignment(Alignment::Center))
.title(Title::from("ccccc").alignment(Alignment::Right)),
Rect {
x: 0,
y: 0,
width: 11,
height: 1,
},
Buffer::with_lines(vec!["aaaaabaaaaa"]),
);
// The right alignment doesn't override the center alignment, but pierces through it
test_case(
Block::default()
.title(Title::from("bbbbb").alignment(Alignment::Center))
.title(Title::from("ccccccccccc").alignment(Alignment::Right)),
Rect {
x: 0,
y: 0,
width: 11,
height: 1,
},
Buffer::with_lines(vec!["cccbbbbbccc"]),
);
}
#[test]
fn widgets_block_renders_on_small_areas() {
let test_case = |block, area: Rect, expected| {
@@ -219,8 +296,7 @@ fn widgets_block_title_alignment() {
let mut terminal = Terminal::new(backend).unwrap();
let block = Block::default()
.title(Span::styled("Title", Style::default()))
.title_alignment(alignment)
.title(Title::from(Span::styled("Title", Style::default())).alignment(alignment))
.borders(borders);
let area = Rect {
@@ -344,3 +420,287 @@ fn widgets_block_title_alignment() {
Buffer::with_lines(vec![" Title ", " "]),
);
}
#[test]
fn widgets_block_title_alignment_bottom() {
let test_case = |alignment, borders, expected| {
let backend = TestBackend::new(15, 2);
let mut terminal = Terminal::new(backend).unwrap();
let block = Block::default()
.title(
Title::from(Span::styled("Title", Style::default()))
.alignment(alignment)
.position(Position::Bottom),
)
.borders(borders);
let area = Rect {
x: 1,
y: 0,
width: 13,
height: 2,
};
terminal
.draw(|f| {
f.render_widget(block, area);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
};
// title bottom-left with all borders
test_case(
Alignment::Left,
Borders::ALL,
Buffer::with_lines(vec![" ┌───────────┐ ", " └Title──────┘ "]),
);
// title bottom-left without bottom border
test_case(
Alignment::Left,
Borders::LEFT | Borders::TOP | Borders::RIGHT,
Buffer::with_lines(vec![" ┌───────────┐ ", " │Title │ "]),
);
// title bottom-left with no left border
test_case(
Alignment::Left,
Borders::TOP | Borders::RIGHT | Borders::BOTTOM,
Buffer::with_lines(vec![" ────────────┐ ", " Title───────┘ "]),
);
// title bottom-left without right border
test_case(
Alignment::Left,
Borders::LEFT | Borders::TOP | Borders::BOTTOM,
Buffer::with_lines(vec![" ┌──────────── ", " └Title─────── "]),
);
// title bottom-left without borders
test_case(
Alignment::Left,
Borders::NONE,
Buffer::with_lines(vec![" ", " Title "]),
);
// title center with all borders
test_case(
Alignment::Center,
Borders::ALL,
Buffer::with_lines(vec![" ┌───────────┐ ", " └───Title───┘ "]),
);
// title center without bottom border
test_case(
Alignment::Center,
Borders::LEFT | Borders::TOP | Borders::RIGHT,
Buffer::with_lines(vec![" ┌───────────┐ ", " │ Title │ "]),
);
// title center with no left border
test_case(
Alignment::Center,
Borders::TOP | Borders::RIGHT | Borders::BOTTOM,
Buffer::with_lines(vec![" ────────────┐ ", " ────Title───┘ "]),
);
// title center without right border
test_case(
Alignment::Center,
Borders::LEFT | Borders::TOP | Borders::BOTTOM,
Buffer::with_lines(vec![" ┌──────────── ", " └───Title──── "]),
);
// title center without borders
test_case(
Alignment::Center,
Borders::NONE,
Buffer::with_lines(vec![" ", " Title "]),
);
// title bottom-right with all borders
test_case(
Alignment::Right,
Borders::ALL,
Buffer::with_lines(vec![" ┌───────────┐ ", " └──────Title┘ "]),
);
// title bottom-right without bottom border
test_case(
Alignment::Right,
Borders::LEFT | Borders::TOP | Borders::RIGHT,
Buffer::with_lines(vec![" ┌───────────┐ ", " │ Title│ "]),
);
// title bottom-right with no left border
test_case(
Alignment::Right,
Borders::TOP | Borders::RIGHT | Borders::BOTTOM,
Buffer::with_lines(vec![" ────────────┐ ", " ───────Title┘ "]),
);
// title bottom-right without right border
test_case(
Alignment::Right,
Borders::LEFT | Borders::TOP | Borders::BOTTOM,
Buffer::with_lines(vec![" ┌──────────── ", " └───────Title "]),
);
// title bottom-right without borders
test_case(
Alignment::Right,
Borders::NONE,
Buffer::with_lines(vec![" ", " Title "]),
);
}
#[test]
fn widgets_block_multiple_titles() {
let test_case = |title_a, title_b, borders, expected| {
let backend = TestBackend::new(15, 2);
let mut terminal = Terminal::new(backend).unwrap();
let block = Block::default()
.title(title_a)
.title(title_b)
.borders(borders);
let area = Rect {
x: 1,
y: 0,
width: 13,
height: 2,
};
terminal
.draw(|f| {
f.render_widget(block, area);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
};
// title bottom-left with all borders
test_case(
Title::from("foo"),
Title::from("bar"),
Borders::ALL,
Buffer::with_lines(vec![" ┌foo─bar────┐ ", " └───────────┘ "]),
);
// title top-left without top border
test_case(
Title::from("foo"),
Title::from("bar"),
Borders::LEFT | Borders::BOTTOM | Borders::RIGHT,
Buffer::with_lines(vec![" │foo bar │ ", " └───────────┘ "]),
);
// title top-left with no left border
test_case(
Title::from("foo"),
Title::from("bar"),
Borders::TOP | Borders::RIGHT | Borders::BOTTOM,
Buffer::with_lines(vec![" foo─bar─────┐ ", " ────────────┘ "]),
);
// title top-left without right border
test_case(
Title::from("foo"),
Title::from("bar"),
Borders::LEFT | Borders::TOP | Borders::BOTTOM,
Buffer::with_lines(vec![" ┌foo─bar───── ", " └──────────── "]),
);
// title top-left without borders
test_case(
Title::from("foo"),
Title::from("bar"),
Borders::NONE,
Buffer::with_lines(vec![" foo bar ", " "]),
);
// title center with all borders
test_case(
Title::from("foo").alignment(Alignment::Center),
Title::from("bar").alignment(Alignment::Center),
Borders::ALL,
Buffer::with_lines(vec![" ┌──foo─bar──┐ ", " └───────────┘ "]),
);
// title center without top border
test_case(
Title::from("foo").alignment(Alignment::Center),
Title::from("bar").alignment(Alignment::Center),
Borders::LEFT | Borders::BOTTOM | Borders::RIGHT,
Buffer::with_lines(vec![" │ foo bar │ ", " └───────────┘ "]),
);
// title center with no left border
test_case(
Title::from("foo").alignment(Alignment::Center),
Title::from("bar").alignment(Alignment::Center),
Borders::TOP | Borders::RIGHT | Borders::BOTTOM,
Buffer::with_lines(vec![" ───foo─bar──┐ ", " ────────────┘ "]),
);
// title center without right border
test_case(
Title::from("foo").alignment(Alignment::Center),
Title::from("bar").alignment(Alignment::Center),
Borders::LEFT | Borders::TOP | Borders::BOTTOM,
Buffer::with_lines(vec![" ┌──foo─bar─── ", " └──────────── "]),
);
// title center without borders
test_case(
Title::from("foo").alignment(Alignment::Center),
Title::from("bar").alignment(Alignment::Center),
Borders::NONE,
Buffer::with_lines(vec![" foo bar ", " "]),
);
// title top-right with all borders
test_case(
Title::from("foo").alignment(Alignment::Right),
Title::from("bar").alignment(Alignment::Right),
Borders::ALL,
Buffer::with_lines(vec![" ┌────foo─bar┐ ", " └───────────┘ "]),
);
// title top-right without top border
test_case(
Title::from("foo").alignment(Alignment::Right),
Title::from("bar").alignment(Alignment::Right),
Borders::LEFT | Borders::BOTTOM | Borders::RIGHT,
Buffer::with_lines(vec![" │ foo bar│ ", " └───────────┘ "]),
);
// title top-right with no left border
test_case(
Title::from("foo").alignment(Alignment::Right),
Title::from("bar").alignment(Alignment::Right),
Borders::TOP | Borders::RIGHT | Borders::BOTTOM,
Buffer::with_lines(vec![" ─────foo─bar┐ ", " ────────────┘ "]),
);
// title top-right without right border
test_case(
Title::from("foo").alignment(Alignment::Right),
Title::from("bar").alignment(Alignment::Right),
Borders::LEFT | Borders::TOP | Borders::BOTTOM,
Buffer::with_lines(vec![" ┌─────foo─bar ", " └──────────── "]),
);
// title top-right without borders
test_case(
Title::from("foo").alignment(Alignment::Right),
Title::from("bar").alignment(Alignment::Right),
Borders::NONE,
Buffer::with_lines(vec![" foo bar ", " "]),
);
}

113
tests/widgets_calendar.rs Normal file
View File

@@ -0,0 +1,113 @@
#![cfg(feature = "widget-calendar")]
use ratatui::{
backend::TestBackend,
buffer::Buffer,
style::Style,
widgets::{
calendar::{CalendarEventStore, Monthly},
Widget,
},
Terminal,
};
use time::{Date, Month};
fn test_render<W: Widget>(widget: W, expected: Buffer, size: (u16, u16)) {
let backend = TestBackend::new(size.0, size.1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| f.render_widget(widget, f.size()))
.unwrap();
terminal.backend().assert_buffer(&expected);
}
#[test]
fn days_layout() {
let c = Monthly::new(
Date::from_calendar_date(2023, Month::January, 1).unwrap(),
CalendarEventStore::default(),
);
let expected = Buffer::with_lines(vec![
" 1 2 3 4 5 6 7",
" 8 9 10 11 12 13 14",
" 15 16 17 18 19 20 21",
" 22 23 24 25 26 27 28",
" 29 30 31",
]);
test_render(c, expected, (21, 5));
}
#[test]
fn days_layout_show_surrounding() {
let c = Monthly::new(
Date::from_calendar_date(2023, Month::December, 1).unwrap(),
CalendarEventStore::default(),
)
.show_surrounding(Style::default());
let expected = Buffer::with_lines(vec![
" 26 27 28 29 30 1 2",
" 3 4 5 6 7 8 9",
" 10 11 12 13 14 15 16",
" 17 18 19 20 21 22 23",
" 24 25 26 27 28 29 30",
" 31 1 2 3 4 5 6",
]);
test_render(c, expected, (21, 6));
}
#[test]
fn show_month_header() {
let c = Monthly::new(
Date::from_calendar_date(2023, Month::January, 1).unwrap(),
CalendarEventStore::default(),
)
.show_month_header(Style::default());
let expected = Buffer::with_lines(vec![
" January 2023 ",
" 1 2 3 4 5 6 7",
" 8 9 10 11 12 13 14",
" 15 16 17 18 19 20 21",
" 22 23 24 25 26 27 28",
" 29 30 31",
]);
test_render(c, expected, (21, 6));
}
#[test]
fn show_weekdays_header() {
let c = Monthly::new(
Date::from_calendar_date(2023, Month::January, 1).unwrap(),
CalendarEventStore::default(),
)
.show_weekdays_header(Style::default());
let expected = Buffer::with_lines(vec![
" Su Mo Tu We Th Fr Sa",
" 1 2 3 4 5 6 7",
" 8 9 10 11 12 13 14",
" 15 16 17 18 19 20 21",
" 22 23 24 25 26 27 28",
" 29 30 31",
]);
test_render(c, expected, (21, 6));
}
#[test]
fn show_combo() {
let c = Monthly::new(
Date::from_calendar_date(2023, Month::January, 1).unwrap(),
CalendarEventStore::default(),
)
.show_weekdays_header(Style::default())
.show_month_header(Style::default())
.show_surrounding(Style::default());
let expected = Buffer::with_lines(vec![
" January 2023 ",
" Su Mo Tu We Th Fr Sa",
" 1 2 3 4 5 6 7",
" 8 9 10 11 12 13 14",
" 15 16 17 18 19 20 21",
" 22 23 24 25 26 27 28",
" 29 30 31 1 2 3 4",
]);
test_render(c, expected, (21, 7));
}

View File

@@ -1,4 +1,4 @@
use tui::{
use ratatui::{
backend::TestBackend,
buffer::Buffer,
style::{Color, Style},
@@ -38,5 +38,5 @@ fn widgets_canvas_draw_labels() {
for col in 0..4 {
expected.get_mut(col, 4).set_fg(Color::Blue);
}
terminal.backend().assert_buffer(&expected)
terminal.backend().assert_buffer(&expected);
}

View File

@@ -1,8 +1,7 @@
use tui::layout::Alignment;
use tui::{
use ratatui::{
backend::TestBackend,
buffer::Buffer,
layout::Rect,
layout::{Alignment, Rect},
style::{Color, Style},
symbols,
text::Span,
@@ -469,7 +468,7 @@ fn widgets_chart_can_have_a_legend() {
"└──────────────────────────────────────────────────────────┘",
]);
// Set expected backgound color
// Set expected background color
for row in 0..30 {
for col in 0..60 {
expected.get_mut(col, row).set_bg(Color::White);

View File

@@ -1,4 +1,4 @@
use tui::{
use ratatui::{
backend::TestBackend,
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
@@ -39,10 +39,10 @@ fn widgets_gauge_renders() {
" ",
" ",
" ┌Percentage────────────────────────┐ ",
" ▋43% │ ",
"██████████████▋43% │ ",
" └──────────────────────────────────┘ ",
" ┌Ratio─────────────────────────────┐ ",
" 51% │ ",
"███████████████51% │ ",
" └──────────────────────────────────┘ ",
" ",
" ",
@@ -51,8 +51,8 @@ fn widgets_gauge_renders() {
for i in 3..17 {
expected
.get_mut(i, 3)
.set_bg(Color::Red)
.set_fg(Color::Blue);
.set_bg(Color::Blue)
.set_fg(Color::Red);
}
for i in 17..37 {
expected
@@ -64,8 +64,8 @@ fn widgets_gauge_renders() {
for i in 3..20 {
expected
.get_mut(i, 6)
.set_bg(Color::Red)
.set_fg(Color::Blue);
.set_bg(Color::Blue)
.set_fg(Color::Red);
}
for i in 20..37 {
expected

View File

@@ -1,14 +1,32 @@
use tui::{
#![allow(deprecated)]
use ratatui::{
backend::TestBackend,
buffer::Buffer,
layout::Rect,
style::{Color, Style},
symbols,
text::Spans,
text::Line,
widgets::{Block, Borders, List, ListItem, ListState},
Terminal,
};
#[test]
fn list_should_shows_the_length() {
let items = vec![
ListItem::new("Item 1"),
ListItem::new("Item 2"),
ListItem::new("Item 3"),
];
let list = List::new(items);
assert_eq!(list.len(), 3);
assert!(!list.is_empty());
let empty_list = List::new(vec![]);
assert_eq!(empty_list.len(), 0);
assert!(empty_list.is_empty());
}
#[test]
fn widgets_list_should_highlight_the_selected_item() {
let backend = TestBackend::new(10, 3);
@@ -138,9 +156,9 @@ fn widgets_list_should_display_multiline_items() {
.draw(|f| {
let size = f.size();
let items = vec![
ListItem::new(vec![Spans::from("Item 1"), Spans::from("Item 1a")]),
ListItem::new(vec![Spans::from("Item 2"), Spans::from("Item 2b")]),
ListItem::new(vec![Spans::from("Item 3"), Spans::from("Item 3c")]),
ListItem::new(vec![Line::from("Item 1"), Line::from("Item 1a")]),
ListItem::new(vec![Line::from("Item 2"), Line::from("Item 2b")]),
ListItem::new(vec![Line::from("Item 3"), Line::from("Item 3c")]),
];
let list = List::new(items)
.highlight_style(Style::default().bg(Color::Yellow))
@@ -173,9 +191,9 @@ fn widgets_list_should_repeat_highlight_symbol() {
.draw(|f| {
let size = f.size();
let items = vec![
ListItem::new(vec![Spans::from("Item 1"), Spans::from("Item 1a")]),
ListItem::new(vec![Spans::from("Item 2"), Spans::from("Item 2b")]),
ListItem::new(vec![Spans::from("Item 3"), Spans::from("Item 3c")]),
ListItem::new(vec![Line::from("Item 1"), Line::from("Item 1a")]),
ListItem::new(vec![Line::from("Item 2"), Line::from("Item 2b")]),
ListItem::new(vec![Line::from("Item 3"), Line::from("Item 3c")]),
];
let list = List::new(items)
.highlight_style(Style::default().bg(Color::Yellow))
@@ -198,3 +216,30 @@ fn widgets_list_should_repeat_highlight_symbol() {
}
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widget_list_should_not_ignore_empty_string_items() {
let backend = TestBackend::new(6, 4);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let items = vec![
ListItem::new("Item 1"),
ListItem::new(""),
ListItem::new(""),
ListItem::new("Item 4"),
];
let list = List::new(items)
.style(Style::default())
.highlight_style(Style::default());
f.render_widget(list, f.size());
})
.unwrap();
let expected = Buffer::with_lines(vec!["Item 1", "", "", "Item 4"]);
terminal.backend().assert_buffer(&expected);
}

View File

@@ -1,116 +1,56 @@
use tui::{
#![allow(deprecated)]
use ratatui::{
backend::TestBackend,
buffer::Buffer,
layout::Alignment,
text::{Span, Spans, Text},
widgets::{Block, Borders, Paragraph, Wrap},
text::{Line, Span, Text},
widgets::{Block, Borders, Padding, Paragraph, Wrap},
Terminal,
};
const SAMPLE_STRING: &str = "The library is based on the principle of immediate rendering with \
intermediate buffers. This means that at each new frame you should build all widgets that are \
supposed to be part of the UI. While providing a great flexibility for rich and \
interactive UI, this may introduce overhead for highly dynamic content.";
#[test]
fn widgets_paragraph_can_wrap_its_content() {
let test_case = |alignment, expected| {
let backend = TestBackend::new(20, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let size = f.size();
let text = vec![Spans::from(SAMPLE_STRING)];
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL))
.alignment(alignment)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, size);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
};
test_case(
Alignment::Left,
Buffer::with_lines(vec![
"┌──────────────────┐",
"│The library is │",
"│based on the │",
"│principle of │",
"│immediate │",
"│rendering with │",
"│intermediate │",
"│buffers. This │",
"│means that at each│",
"└──────────────────┘",
]),
);
test_case(
Alignment::Right,
Buffer::with_lines(vec![
"┌──────────────────┐",
"│ The library is│",
"│ based on the│",
"│ principle of│",
"│ immediate│",
"│ rendering with│",
"│ intermediate│",
"│ buffers. This│",
"│means that at each│",
"└──────────────────┘",
]),
);
test_case(
Alignment::Center,
Buffer::with_lines(vec![
"┌──────────────────┐",
"│ The library is │",
"│ based on the │",
"│ principle of │",
"│ immediate │",
"│ rendering with │",
"│ intermediate │",
"│ buffers. This │",
"│means that at each│",
"└──────────────────┘",
]),
);
}
#[test]
fn widgets_paragraph_renders_double_width_graphemes() {
let backend = TestBackend::new(10, 10);
/// Tests the [`Paragraph`] widget against the expected [`Buffer`] by rendering it onto an equal
/// area and comparing the rendered and expected content.
fn test_case(paragraph: Paragraph, expected: Buffer) {
let backend = TestBackend::new(expected.area.width, expected.area.height);
let mut terminal = Terminal::new(backend).unwrap();
let s = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点では、";
terminal
.draw(|f| {
let size = f.size();
let text = vec![Spans::from(s)];
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(paragraph, size);
})
.unwrap();
let expected = Buffer::with_lines(vec![
"┌────────┐",
"│コンピュ│",
"│ータ上で│",
"│文字を扱│",
"│う場合、│",
"│典型的に│",
"│は文字に│",
"│よる通信│",
"│を行う場│",
"└────────┘",
]);
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widgets_paragraph_renders_double_width_graphemes() {
let s = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点では、";
let text = vec![Line::from(s)];
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true });
test_case(
paragraph,
Buffer::with_lines(vec![
"┌────────┐",
"│コンピュ│",
"│ータ上で│",
"│文字を扱│",
"│う場合、│",
"│典型的に│",
"│は文字に│",
"│よる通信│",
"│を行う場│",
"└────────┘",
]),
);
}
#[test]
fn widgets_paragraph_renders_mixed_width_graphemes() {
let backend = TestBackend::new(10, 7);
@@ -120,7 +60,7 @@ fn widgets_paragraph_renders_mixed_width_graphemes() {
terminal
.draw(|f| {
let size = f.size();
let text = vec![Spans::from(s)];
let text = vec![Line::from(s)];
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true });
@@ -144,49 +84,27 @@ fn widgets_paragraph_renders_mixed_width_graphemes() {
#[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| {
let backend = TestBackend::new(20, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let size = f.size();
let text = Text::from(
"段落现在可以水平滚动了!\nParagraph can scroll horizontally!\nShort line",
);
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL))
.alignment(alignment)
.scroll(scroll);
f.render_widget(paragraph, size);
})
.unwrap();
terminal.backend().assert_buffer(&expected);
};
let line = Line::from(vec![Span::raw("NBSP"), Span::raw(nbsp)]);
let paragraph = Paragraph::new(line).block(Block::default().borders(Borders::ALL));
test_case(
Alignment::Left,
(0, 7),
paragraph,
Buffer::with_lines(vec![
"┌──────────────────┐",
"│NBSP\u{00a0}",
"└──────────────────┘",
]),
);
}
#[test]
fn widgets_paragraph_can_scroll_horizontally() {
let text =
Text::from("段落现在可以水平滚动了!\nParagraph can scroll horizontally!\nShort line");
let paragraph = Paragraph::new(text).block(Block::default().borders(Borders::ALL));
test_case(
paragraph.clone().alignment(Alignment::Left).scroll((0, 7)),
Buffer::with_lines(vec![
"┌──────────────────┐",
"│在可以水平滚动了!│",
@@ -202,8 +120,7 @@ fn widgets_paragraph_can_scroll_horizontally() {
);
// only support Alignment::Left
test_case(
Alignment::Right,
(0, 7),
paragraph.clone().alignment(Alignment::Right).scroll((0, 7)),
Buffer::with_lines(vec![
"┌──────────────────┐",
"│段落现在可以水平滚│",
@@ -218,3 +135,234 @@ fn widgets_paragraph_can_scroll_horizontally() {
]),
);
}
const SAMPLE_STRING: &str = "The library is based on the principle of immediate rendering with \
intermediate buffers. This means that at each new frame you should build all widgets that are \
supposed to be part of the UI. While providing a great flexibility for rich and \
interactive UI, this may introduce overhead for highly dynamic content.";
#[test]
fn widgets_paragraph_can_wrap_its_content() {
let text = vec![Line::from(SAMPLE_STRING)];
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true });
test_case(
paragraph.clone().alignment(Alignment::Left),
Buffer::with_lines(vec![
"┌──────────────────┐",
"│The library is │",
"│based on the │",
"│principle of │",
"│immediate │",
"│rendering with │",
"│intermediate │",
"│buffers. This │",
"│means that at each│",
"└──────────────────┘",
]),
);
test_case(
paragraph.clone().alignment(Alignment::Center),
Buffer::with_lines(vec![
"┌──────────────────┐",
"│ The library is │",
"│ based on the │",
"│ principle of │",
"│ immediate │",
"│ rendering with │",
"│ intermediate │",
"│ buffers. This │",
"│means that at each│",
"└──────────────────┘",
]),
);
test_case(
paragraph.clone().alignment(Alignment::Right),
Buffer::with_lines(vec![
"┌──────────────────┐",
"│ The library is│",
"│ based on the│",
"│ principle of│",
"│ immediate│",
"│ rendering with│",
"│ intermediate│",
"│ buffers. This│",
"│means that at each│",
"└──────────────────┘",
]),
);
}
#[test]
fn widgets_paragraph_works_with_padding() {
let text = vec![Line::from(SAMPLE_STRING)];
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).padding(Padding {
left: 2,
right: 2,
top: 1,
bottom: 1,
}))
.wrap(Wrap { trim: true });
test_case(
paragraph.clone().alignment(Alignment::Left),
Buffer::with_lines(vec![
"┌────────────────────┐",
"│ │",
"│ The library is │",
"│ based on the │",
"│ principle of │",
"│ immediate │",
"│ rendering with │",
"│ intermediate │",
"│ buffers. This │",
"│ means that at │",
"│ │",
"└────────────────────┘",
]),
);
test_case(
paragraph.clone().alignment(Alignment::Right),
Buffer::with_lines(vec![
"┌────────────────────┐",
"│ │",
"│ The library is │",
"│ based on the │",
"│ principle of │",
"│ immediate │",
"│ rendering with │",
"│ intermediate │",
"│ buffers. This │",
"│ means that at │",
"│ │",
"└────────────────────┘",
]),
);
let mut text = vec![Line::from("This is always centered.").alignment(Alignment::Center)];
text.push(Line::from(SAMPLE_STRING));
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).padding(Padding {
left: 2,
right: 2,
top: 1,
bottom: 1,
}))
.wrap(Wrap { trim: true });
test_case(
paragraph.alignment(Alignment::Right),
Buffer::with_lines(vec![
"┌────────────────────┐",
"│ │",
"│ This is always │",
"│ centered. │",
"│ The library is │",
"│ based on the │",
"│ principle of │",
"│ immediate │",
"│ rendering with │",
"│ intermediate │",
"│ buffers. This │",
"│ means that at │",
"│ │",
"└────────────────────┘",
]),
);
}
#[test]
fn widgets_paragraph_can_align_spans() {
let right_s = "This string will override the paragraph alignment to be right aligned.";
let default_s = "This string will be aligned based on the alignment of the paragraph.";
let text = vec![
Line::from(right_s).alignment(Alignment::Right),
Line::from(default_s),
];
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true });
test_case(
paragraph.clone().alignment(Alignment::Left),
Buffer::with_lines(vec![
"┌──────────────────┐",
"│ This string will│",
"│ override the│",
"│ paragraph│",
"│ alignment to be│",
"│ right aligned.│",
"│This string will │",
"│be aligned based │",
"│on the alignment │",
"└──────────────────┘",
]),
);
test_case(
paragraph.alignment(Alignment::Center),
Buffer::with_lines(vec![
"┌──────────────────┐",
"│ This string will│",
"│ override the│",
"│ paragraph│",
"│ alignment to be│",
"│ right aligned.│",
"│ This string will │",
"│ be aligned based │",
"│ on the alignment │",
"└──────────────────┘",
]),
);
let left_lines = vec!["This string", "will override the paragraph alignment"]
.into_iter()
.map(|s| Line::from(s).alignment(Alignment::Left))
.collect::<Vec<_>>();
let mut lines = vec![
"This",
"must be pretty long",
"in order to effectively show",
"truncation.",
]
.into_iter()
.map(Line::from)
.collect::<Vec<_>>();
let mut text = left_lines.clone();
text.append(&mut lines);
let paragraph = Paragraph::new(text).block(Block::default().borders(Borders::ALL));
test_case(
paragraph.clone().alignment(Alignment::Right),
Buffer::with_lines(vec![
"┌──────────────────┐",
"│This string │",
"│will override the │",
"│ This│",
"│must be pretty lon│",
"│in order to effect│",
"│ truncation.│",
"│ │",
"│ │",
"└──────────────────┘",
]),
);
test_case(
paragraph.alignment(Alignment::Left),
Buffer::with_lines(vec![
"┌──────────────────┐",
"│This string │",
"│will override the │",
"│This │",
"│must be pretty lon│",
"│in order to effect│",
"│truncation. │",
"│ │",
"└──────────────────┘",
]),
);
}

View File

@@ -1,9 +1,11 @@
use tui::{
#![allow(deprecated)]
use ratatui::{
backend::TestBackend,
buffer::Buffer,
layout::Constraint,
style::{Color, Modifier, Style},
text::{Span, Spans},
text::{Line, Span},
widgets::{Block, Borders, Cell, Row, Table, TableState},
Terminal,
};
@@ -623,7 +625,7 @@ fn widgets_table_can_have_elements_styled_individually() {
Row::new(vec![
Cell::from("Row21"),
Cell::from("Row22").style(Style::default().fg(Color::Yellow)),
Cell::from(Spans::from(vec![
Cell::from(Line::from(vec![
Span::raw("Row"),
Span::styled("23", Style::default().fg(Color::Blue)),
]))

View File

@@ -1,5 +1,7 @@
use tui::{
backend::TestBackend, buffer::Buffer, layout::Rect, symbols, text::Spans, widgets::Tabs,
#![allow(deprecated)]
use ratatui::{
backend::TestBackend, buffer::Buffer, layout::Rect, symbols, text::Line, widgets::Tabs,
Terminal,
};
@@ -9,7 +11,7 @@ fn widgets_tabs_should_not_panic_on_narrow_areas() {
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let tabs = Tabs::new(["Tab1", "Tab2"].iter().cloned().map(Spans::from).collect());
let tabs = Tabs::new(["Tab1", "Tab2"].iter().cloned().map(Line::from).collect());
f.render_widget(
tabs,
Rect {
@@ -31,7 +33,7 @@ fn widgets_tabs_should_truncate_the_last_item() {
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let tabs = Tabs::new(["Tab1", "Tab2"].iter().cloned().map(Spans::from).collect());
let tabs = Tabs::new(["Tab1", "Tab2"].iter().cloned().map(Line::from).collect());
f.render_widget(
tabs,
Rect {

4
typos.toml Normal file
View File

@@ -0,0 +1,4 @@
# configuration for https://github.com/crate-ci/typos
[default.extend-words]
ratatui = "ratatui"