Skip to content

Tui.rs

ターミナル

チュートリアルのこのセクションでは、 Tui structの基本コンポーネントについて説明します。

ほとんどの人は、 crossterm を使用してターミナルアプリケーションのセットアップとティアダウンを見つけることができます。

fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
let mut stdout = io::stdout();
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture, HideCursor)?;
Terminal::new(CrosstermBackend::new(stdout))
}
fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
let mut stdout = io::stdout();
crossterm::terminal::disable_raw_mode()?;
crossterm::execute!(stdout, LeaveAlternateScreen, DisableMouseCapture, ShowCursor)?;
Ok(())
}
fn main() -> Result<()> {
let mut terminal = setup_terminal()?;
run_app(&mut terminal)?;
teardown_terminal(&mut terminal)?;
Ok(())
}

termion または termwiz をここで使用できます。 setup_terminal および teardown_terminal の実装を変更する必要があります。

私は個人的に crossterm を使用して、WindowsでTUIも実行できるようにするのが好きです。

Tui structで、セットアップと隔離関数を enter() および exit() メソッドに再編成できます。

use color_eyre::eyre::Result;
use ratatui::crossterm::{
cursor,
event::{DisableMouseCapture, EnableMouseCapture},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::backend::CrosstermBackend as Backend;
use tokio::{
sync::{mpsc, Mutex},
task::JoinHandle,
};
pub type Frame<'a> = ratatui::Frame<'a, Backend<std::io::Stderr>>;
pub struct Tui {
pub terminal: ratatui::Terminal<Backend<std::io::Stderr>>,
}
impl Tui {
pub fn new() -> Result<Self> {
let terminal = ratatui::Terminal::new(Backend::new(std::io::stderr()))?;
Ok(Self { terminal })
}
pub fn enter(&self) -> Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), EnterAlternateScreen, EnableMouseCapture, cursor::Hide)?;
Ok(())
}
pub fn exit(&self) -> Result<()> {
crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, DisableMouseCapture, cursor::Show)?;
crossterm::terminal::disable_raw_mode()?;
Ok(())
}
pub fn suspend(&self) -> Result<()> {
self.exit()?;
#[cfg(not(windows))]
signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?;
Ok(())
}
pub fn resume(&self) -> Result<()> {
self.enter()?;
Ok(())
}
}

termion または wezterm で使用する必要があるため、これを変更してください。

Frame のタイプエイリアスは、 components フォルダーをより簡単に操作できるようにするためだけであり、厳密に必要ではありません。

イベント

最も単純な形式では、ほとんどのアプリケーションには main ループがあります。

fn main() -> Result<()> {
let mut app = App::new();
let mut t = Tui::new()?;
t.enter()?; // raw mode enabled
loop {
// get key event and update state
// ... Special handling to read key or mouse events required here
t.terminal.draw(|f| { // <- `terminal.draw` is the only ratatui function here
ui(app, f) // render state to terminal
})?;
}
t.exit()?; // raw mode disabled
Ok(())
}

「raw モード」、つまり t.enter() を呼び出した後では、そのターミナル ウィンドウ内でのキー押下はすべて stdin に送信されます。これらのキー押下に対してアクションを起こすには、stdin からキー押下を読み取る必要があります。

これを行うにはさまざまな方法があります。crossterm には、これらのキー押下を読み取る機能を実装する event モジュールがあります。

j を押すとカウンターが増分され、k を押すとカウンターが減分される、単純な「カウンター」アプリケーションを構築していると仮定します。

fn main() -> Result {
let mut app = App::new();
let mut t = Tui::new()?;
t.enter()?;
loop {
if crossterm::event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = crossterm::event::read()? {
match key.code {
KeyCode::Char('j') => app.increment(),
KeyCode::Char('k') => app.decrement(),
KeyCode::Char('q') => break,
_ => (),
}
}
};
t.terminal.draw(|f| {
ui(app, f)
})?;
}
t.exit()?;
Ok(())
}

これは完全に正常に機能し、多くの小規模から中型のプログラムがそれを行うことで逃げることができます。

ただし、このアプローチは、App Stateの更新との重要な入力処理を組み込み、「描画」ループでそれを行います。このアプローチの実際的な問題は、キープレスを待つ250ミリ秒のドローループをブロックすることです。これには奇妙な副作用が発生する可能性があります。たとえば、キーを保持することで、ターミナルへの描画が速くなります。

アーキテクチャの観点から、コードは推論するほど複雑になる可能性があります。たとえば、キープレスがアプリの状態に応じて_different_のものを意味することを望むかもしれません (入力フィールドに焦点を合わせている場合、 "j" をテキスト入力フィールドに入力することもできますが、リストに焦点を合わせた場合アイテムの場合、リストを下にスクロールしてください。)

Pressing j 3 times to increment counter and 3 times in the text field

いくつか異なる設定を行う必要があるので、1 つずつ進めていきましょう。

まず、ポーリングの代わりに、キーの押下を非同期で取得し、チャネル経由で送信するチャネルを導入します。次に、main ループでチャネルで受信します。

これを行うには 2 つの方法があります。OS スレッドを使用するか、“グリーン” スレッド、つまりタスク、つまり rust の async-await future + future executor を使用できます。

std::threadtokio::task を使用してキーの押下を非同期で読み取るサンプル コードを以下に示します。

std::thread

enum Event {
Key(crossterm::event::KeyEvent)
}
struct EventHandler {
rx: std::sync::mpsc::Receiver<Event>,
}
impl EventHandler {
fn new() -> Self {
let tick_rate = std::time::Duration::from_millis(250);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
loop {
if crossterm::event::poll(tick_rate)? {
match crossterm::event::read()? {
CrosstermEvent::Key(e) => tx.send(Event::Key(e)),
_ => unimplemented!(),
}?
}
}
})
EventHandler { rx }
}
fn next(&self) -> Result<Event> {
Ok(self.rx.recv()?)
}
}

tokio::task

enum Event {
Key(crossterm::event::KeyEvent)
}
struct EventHandler {
rx: tokio::sync::mpsc::UnboundedReceiver<Event>,
}
impl EventHandler {
fn new() -> Self {
let tick_rate = std::time::Duration::from_millis(250);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
loop {
if crossterm::event::poll(tick_rate)? {
match crossterm::event::read()? {
CrosstermEvent::Key(e) => tx.send(Event::Key(e)),
_ => unimplemented!(),
}?
}
}
})
EventHandler { rx }
}
async fn next(&self) -> Result<Event> {
Ok(self.rx.recv().await.ok()?)
}
}

diff

enum Event {
Key(crossterm::event::KeyEvent)
}
struct EventHandler {
rx: std::sync::mpsc::Receiver<Event>,
rx: tokio::sync::mpsc::UnboundedReceiver<Event>,
}
impl EventHandler {
fn new() -> Self {
let tick_rate = std::time::Duration::from_millis(250);
let (tx, rx) = std::sync::mpsc::channel();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
std::thread::spawn(move || {
tokio::spawn(async move {
loop {
if crossterm::event::poll(tick_rate)? {
match crossterm::event::read()? {
CrosstermEvent::Key(e) => tx.send(Event::Key(e)),
_ => unimplemented!(),
}?
}
}
})
EventHandler { rx }
}
fn next(&self) -> Result<Event> {
async fn next(&self) -> Result<Event> {
Ok(self.rx.recv()?)
Ok(self.rx.recv().await.ok()?)
}
}

Tokio は Rust プログラミング言語の非同期ランタイムです。Rust の非同期プログラミング用の人気のランタイムの 1 つです。詳細については、https://tokio.rs/tokio/tutorial を参照してください。このチュートリアルの残りの部分では、tokio を使用することを前提とします。公式の tokio ドキュメントを読むことを強くお勧めします。

tokio を使用する場合、イベントを受信するには .await が必要です。したがって、main ループは次のようになります。

#[tokio::main]
async fn main() -> {
let mut app = App::new();
let events = EventHandler::new();
let mut t = Tui::new()?;
t.enter()?;
loop {
if let Event::Key(key) = events.next().await? {
match key.code {
KeyCode::Char('j') => app.increment(),
KeyCode::Char('k') => app.decrement(),
KeyCode::Char('q') => break,
_ => (),
}
}
t.terminal.draw(|f| {
ui(app, f)
})?;
}
t.exit()?;
Ok(())
}

追加の改善

EventHandler を変更して、 AppTick イベントを処理します。 Event::AppTick を定期的に送信する必要があります。また、 CancellationToken を使用して、リクエストに応じてtokioタスクを停止したいと考えています。

tokio ‘s select! macro では、複数の async 計算を待つことができ、単一の計算が完了したときに戻ることができます。

完成した EventHandler コードは次のようになります。

use color_eyre::eyre::Result;
use ratatui::crossterm::{
cursor,
event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent},
};
use futures::{FutureExt, StreamExt};
use tokio::{
sync::{mpsc, oneshot},
task::JoinHandle,
};
#[derive(Clone, Copy, Debug)]
pub enum Event {
Error,
AppTick,
Key(KeyEvent),
}
#[derive(Debug)]
pub struct EventHandler {
_tx: mpsc::UnboundedSender<Event>,
rx: mpsc::UnboundedReceiver<Event>,
task: Option<JoinHandle<()>>,
stop_cancellation_token: CancellationToken,
}
impl EventHandler {
pub fn new(tick_rate: u64) -> Self {
let tick_rate = std::time::Duration::from_millis(tick_rate);
let (tx, rx) = mpsc::unbounded_channel();
let _tx = tx.clone();
let stop_cancellation_token = CancellationToken::new();
let _stop_cancellation_token = stop_cancellation_token.clone();
let task = tokio::spawn(async move {
let mut reader = crossterm::event::EventStream::new();
let mut interval = tokio::time::interval(tick_rate);
loop {
let delay = interval.tick();
let crossterm_event = reader.next().fuse();
tokio::select! {
_ = _stop_cancellation_token.cancelled() => {
break;
}
maybe_event = crossterm_event => {
match maybe_event {
Some(Ok(evt)) => {
match evt {
CrosstermEvent::Key(key) => {
if key.kind == KeyEventKind::Press {
tx.send(Event::Key(key)).unwrap();
}
},
_ => {},
}
}
Some(Err(_)) => {
tx.send(Event::Error).unwrap();
}
None => {},
}
},
_ = delay => {
tx.send(Event::AppTick).unwrap();
},
}
}
});
Self { _tx, rx, task: Some(task), stop_cancellation_token }
}
pub async fn next(&mut self) -> Option<Event> {
self.rx.recv().await
}
pub async fn stop(&mut self) -> Result<()> {
self.stop_cancellation_token.cancel();
if let Some(handle) = self.task.take() {
handle.await.unwrap();
}
Ok(())
}
}

この EventHandler を実装すると、tokio を使用して、main ループ内で任意のキーを非同期的に処理する別の「タスク」を作成できます。

私は個人的に、EventHandlerTui 構造体を 1 つの構造体に組み合わせるのが好きです。参考までに、その Tui 構造体の例を次に示します。

use std::{
io::{stderr, Stderr},
ops::{Deref, DerefMut},
time::Duration,
};
use color_eyre::eyre::Result;
use futures::{FutureExt, StreamExt};
use ratatui::{
backend::CrosstermBackend,
crossterm::{
cursor,
event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
},
};
use serde::{Deserialize, Serialize};
use tokio::{
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
task::JoinHandle,
};
use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event {
Init,
Quit,
Error,
Closed,
Tick,
Render,
FocusGained,
FocusLost,
Paste(String),
Key(KeyEvent),
Mouse(MouseEvent),
Resize(u16, u16),
}
pub struct Tui {
pub terminal: ratatui::Terminal<CrosstermBackend<Stderr>>,
pub task: JoinHandle<()>,
pub cancellation_token: CancellationToken,
pub event_rx: UnboundedReceiver<Event>,
pub event_tx: UnboundedSender<Event>,
pub frame_rate: f64,
pub tick_rate: f64,
}
impl Tui {
pub fn new() -> Result<Self> {
let tick_rate = 4.0;
let frame_rate = 60.0;
let terminal = ratatui::Terminal::new(CrosstermBackend::new(stderr()))?;
let (event_tx, event_rx) = mpsc::unbounded_channel();
let cancellation_token = CancellationToken::new();
let task = tokio::spawn(async {});
Ok(Self { terminal, task, cancellation_token, event_rx, event_tx, frame_rate, tick_rate })
}
pub fn tick_rate(&mut self, tick_rate: f64) {
self.tick_rate = tick_rate;
}
pub fn frame_rate(&mut self, frame_rate: f64) {
self.frame_rate = frame_rate;
}
pub fn start(&mut self) {
let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate);
self.cancel();
self.cancellation_token = CancellationToken::new();
let _cancellation_token = self.cancellation_token.clone();
let _event_tx = self.event_tx.clone();
self.task = tokio::spawn(async move {
let mut reader = crossterm::event::EventStream::new();
let mut tick_interval = tokio::time::interval(tick_delay);
let mut render_interval = tokio::time::interval(render_delay);
_event_tx.send(Event::Init).unwrap();
loop {
let tick_delay = tick_interval.tick();
let render_delay = render_interval.tick();
let crossterm_event = reader.next().fuse();
tokio::select! {
_ = _cancellation_token.cancelled() => {
break;
}
maybe_event = crossterm_event => {
match maybe_event {
Some(Ok(evt)) => {
match evt {
CrosstermEvent::Key(key) => {
if key.kind == KeyEventKind::Press {
_event_tx.send(Event::Key(key)).unwrap();
}
},
CrosstermEvent::Mouse(mouse) => {
_event_tx.send(Event::Mouse(mouse)).unwrap();
},
CrosstermEvent::Resize(x, y) => {
_event_tx.send(Event::Resize(x, y)).unwrap();
},
CrosstermEvent::FocusLost => {
_event_tx.send(Event::FocusLost).unwrap();
},
CrosstermEvent::FocusGained => {
_event_tx.send(Event::FocusGained).unwrap();
},
CrosstermEvent::Paste(s) => {
_event_tx.send(Event::Paste(s)).unwrap();
},
}
}
Some(Err(_)) => {
_event_tx.send(Event::Error).unwrap();
}
None => {},
}
},
_ = tick_delay => {
_event_tx.send(Event::Tick).unwrap();
},
_ = render_delay => {
_event_tx.send(Event::Render).unwrap();
},
}
}
});
}
pub fn stop(&self) -> Result<()> {
self.cancel();
let mut counter = 0;
while !self.task.is_finished() {
std::thread::sleep(Duration::from_millis(1));
counter += 1;
if counter > 50 {
self.task.abort();
}
if counter > 100 {
log::error!("Failed to abort task in 100 milliseconds for unknown reason");
break;
}
}
Ok(())
}
pub fn enter(&mut self) -> Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
self.start();
Ok(())
}
pub fn exit(&mut self) -> Result<()> {
self.stop()?;
if crossterm::terminal::is_raw_mode_enabled()? {
self.flush()?;
crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?;
crossterm::terminal::disable_raw_mode()?;
}
Ok(())
}
pub fn cancel(&self) {
self.cancellation_token.cancel();
}
pub fn suspend(&mut self) -> Result<()> {
self.exit()?;
#[cfg(not(windows))]
signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?;
Ok(())
}
pub fn resume(&mut self) -> Result<()> {
self.enter()?;
Ok(())
}
pub async fn next(&mut self) -> Option<Event> {
self.event_rx.recv().await
}
}
impl Deref for Tui {
type Target = ratatui::Terminal<CrosstermBackend<Stderr>>;
fn deref(&self) -> &Self::Target {
&self.terminal
}
}
impl DerefMut for Tui {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.terminal
}
}
impl Drop for Tui {
fn drop(&mut self) {
self.exit().unwrap();
}
}

次のセクションでは、イベントの効果を橋渡しするために Command パターンを紹介します。