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>
This commit is contained in:
Orhun Parmaksız 2023-05-12 17:58:01 +02:00 committed by GitHub
parent 1cc405d2dc
commit 4437835057
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 309 additions and 0 deletions

View File

@ -32,6 +32,7 @@ unicode-segmentation = "1.10"
unicode-width = "0.1" unicode-width = "0.1"
termion = { version = "2.0", optional = true } termion = { version = "2.0", optional = true }
crossterm = { version = "0.26", optional = true } crossterm = { version = "0.26", optional = true }
termwiz = { version = "0.20.0", optional = true }
serde = { version = "1", optional = true, features = ["derive"]} serde = { version = "1", optional = true, features = ["derive"]}
time = { version = "0.3.11", optional = true, features = ["local-offset"]} time = { version = "0.3.11", optional = true, features = ["local-offset"]}

View File

@ -13,10 +13,13 @@ dependencies = [
"fmt", "fmt",
"check-crossterm", "check-crossterm",
"check-termion", "check-termion",
"check-termwiz",
"test-crossterm", "test-crossterm",
"test-termion", "test-termion",
"test-termwiz",
"clippy-crossterm", "clippy-crossterm",
"clippy-termion", "clippy-termion",
"clippy-termwiz",
"test-doc", "test-doc",
] ]
@ -25,8 +28,11 @@ private = true
dependencies = [ dependencies = [
"fmt", "fmt",
"check-crossterm", "check-crossterm",
"check-termwiz",
"test-crossterm", "test-crossterm",
"test-termwiz",
"clippy-crossterm", "clippy-crossterm",
"clippy-termwiz",
"test-doc", "test-doc",
] ]
@ -47,6 +53,10 @@ run_task = "check"
env = { TUI_FEATURES = "serde,termion" } env = { TUI_FEATURES = "serde,termion" }
run_task = "check" run_task = "check"
[tasks.check-termwiz]
env = { TUI_FEATURES = "serde,termwiz" }
run_task = "check"
[tasks.check] [tasks.check]
command = "cargo" command = "cargo"
condition = { env_set = ["TUI_FEATURES"] } condition = { env_set = ["TUI_FEATURES"] }
@ -66,6 +76,10 @@ run_task = "build"
env = { TUI_FEATURES = "serde,termion" } env = { TUI_FEATURES = "serde,termion" }
run_task = "build" run_task = "build"
[tasks.build-termwiz]
env = { TUI_FEATURES = "serde,termwiz" }
run_task = "build"
[tasks.build] [tasks.build]
command = "cargo" command = "cargo"
condition = { env_set = ["TUI_FEATURES"] } condition = { env_set = ["TUI_FEATURES"] }
@ -85,6 +99,10 @@ run_task = "clippy"
env = { TUI_FEATURES = "serde,termion" } env = { TUI_FEATURES = "serde,termion" }
run_task = "clippy" run_task = "clippy"
[tasks.clippy-termwiz]
env = { TUI_FEATURES = "serde,termwiz" }
run_task = "clippy"
[tasks.clippy] [tasks.clippy]
command = "cargo" command = "cargo"
condition = { env_set = ["TUI_FEATURES"] } condition = { env_set = ["TUI_FEATURES"] }
@ -107,6 +125,10 @@ run_task = "test"
env = { TUI_FEATURES = "serde,termion,all-widgets" } env = { TUI_FEATURES = "serde,termion,all-widgets" }
run_task = "test" run_task = "test"
[tasks.test-termwiz]
env = { TUI_FEATURES = "serde,termwiz,all-widgets" }
run_task = "test"
[tasks.test] [tasks.test]
command = "cargo" command = "cargo"
condition = { env_set = ["TUI_FEATURES"] } condition = { env_set = ["TUI_FEATURES"] }

View File

@ -37,6 +37,7 @@ The library supports multiple backends:
- [crossterm](https://github.com/crossterm-rs/crossterm) [default] - [crossterm](https://github.com/crossterm-rs/crossterm) [default]
- [termion](https://github.com/ticki/termion) - [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 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 buffers. This means that at each new frame you should build all widgets that are

View File

@ -3,12 +3,18 @@ mod app;
mod crossterm; mod crossterm;
#[cfg(feature = "termion")] #[cfg(feature = "termion")]
mod termion; mod termion;
#[cfg(feature = "termwiz")]
mod termwiz;
mod ui; mod ui;
#[cfg(feature = "crossterm")] #[cfg(feature = "crossterm")]
use crate::crossterm::run; use crate::crossterm::run;
#[cfg(feature = "termion")] #[cfg(feature = "termion")]
use crate::termion::run; use crate::termion::run;
#[cfg(feature = "termwiz")]
use crate::termwiz::run;
use argh::FromArgs; use argh::FromArgs;
use std::{error::Error, time::Duration}; use std::{error::Error, time::Duration};

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

@ -0,0 +1,75 @@
use ratatui::{backend::TermwizBackend, Terminal};
use std::{
error::Error,
io,
time::{Duration, Instant},
};
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

@ -13,6 +13,11 @@ mod crossterm;
#[cfg(feature = "crossterm")] #[cfg(feature = "crossterm")]
pub use self::crossterm::CrosstermBackend; pub use self::crossterm::CrosstermBackend;
#[cfg(feature = "termwiz")]
mod termwiz;
#[cfg(feature = "termwiz")]
pub use self::termwiz::TermwizBackend;
mod test; mod test;
pub use self::test::TestBackend; pub use self::test::TestBackend;

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

@ -0,0 +1,199 @@
use crate::{
backend::Backend,
buffer::Cell,
layout::Rect,
style::{Color, Modifier},
};
use std::{error::Error, io};
use termwiz::{
caps::Capabilities,
cell::*,
color::*,
surface::*,
terminal::{buffered::BufferedTerminal, SystemTerminal, Terminal},
};
pub struct TermwizBackend {
buffered_terminal: BufferedTerminal<SystemTerminal>,
}
impl TermwizBackend {
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 })
}
pub fn with_buffered_terminal(instance: BufferedTerminal<SystemTerminal>) -> TermwizBackend {
TermwizBackend {
buffered_terminal: instance,
}
}
pub fn buffered_terminal(&self) -> &BufferedTerminal<SystemTerminal> {
&self.buffered_terminal
}
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)))
}
}
}
}