👉 crossterm version = “0.25.0” "0.28.0"に更新
👉 document
crosstermはマルチプラットフォームで入出力系を取得したり、色を付けたりできるターミナル操作ライブラリ
基本的な特徴
- クロスプラットフォーム
- ドキュメントが豊富
- 依存が少ない
- カーソル操作
- イベントハンドリング
- 色の指定
- ターミナルの操作
この記事で扱うのはターミナルとカーソル、イベントの中でキー操作の一部のみ
基本
- 即時実行できるマクロの
execute!()
, 関数のexecute()
- 遅延評価できるマクロの
queue!()
, 関数のqueue()
基本はこの4つのいずれかを使って実行する
本記事では基本的にはマクロの方を利用するが関数に変えても動作する
また既存のprint!(), println!()を使っても同様のことはできる
use std::io::stdout;
use std::io::Result;
use crossterm::execute;
use crossterm::style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor};
use crossterm::ExecutableCommand;
fn main() -> Result<()> {
let mut stdout = stdout();
stdout
.execute(SetForegroundColor(Color::Blue))?
.execute(SetBackgroundColor(Color::Red))?
.execute(Print("Styled text here."))?;
// print!でも同様のコードをかける
println!(
"{}{} Styple text here{}",
SetForegroundColor(Color::Red),
SetBackgroundColor(Color::Blue),
ResetColor
);
// ResetColorをしないと以降の場所でも色が適応される
execute!(stdout, SetBackgroundColor(Color::Blue), Print(" "))?;
println!("Hi{}There", SetForegroundColor(Color::Black));
println!();
Ok(())
}
print!()とexecute!()は基本的にはどちらでも良い
だが,version="0.26.0"からはマクロを使った方がすっきりと書ける
遅延評価したい場合はqueue!()
を使う
stdout.flush()?
で実行できる
use crossterm::style::{PrintStyledContent, Stylize};
use crossterm::{cursor, execute, queue, terminal};
fn main() -> Result<()> {
let mut stdout = stdout();
execute!(stdout, terminal::Clear(terminal::ClearType::All))?;
let block = PrintStyledContent("█".magenta());
for y in 0..20 {
for x in 0..150 {
if (y == 0 || y == 20 - 1) || (x == 0 || x == 150 - 1) {
queue!(stdout, cursor::MoveTo(x, y), block)?;
}
}
}
stdout.flush()?; // ここでqueueされた順で遅延評価される
Ok(())
}
たくさんの描画が必要な時には,描画するのを全てqueueしてから一気に描画した方がちらつきがなくてすっきりと見れる
ターミナル操作
個人的に重要かなと思うのが以下の6つ
- EnterAlternateScreen 代替画面(バッファー)へ入る
- LeaveAlternateScreen 代替画面から出る
- enable_raw_mode rawモードの有効化
- disable_raw_mode rawモードの無効化
- Clear, ClearType 描画したのを消せる
これらの概要を説明する
代替画面の入出
EnterAlternateScreen
は今開いてるターミナルとは別のバッファースクリーンを表示することができ、LeaveAlternateScreen
をすると実行前の画面に戻ることができる
tmuxとかのターミナルエミュレータを起動し、終わった後に元の画面に戻るイメージ
なので実行したコマンドとcrosstermで行われる処理や描画を分離して、バッファーが閉じたらコマンドを実行した画面に綺麗に戻れるようになる
[dependencies]
crossterm = "0.27.0"
+ rand = "0.8.5"
迷路を作成して元の画面に戻る
use std::io::{stdout, Result, Write};
use std::time::Duration;
use crossterm::style::{PrintStyledContent, Stylize};
use crossterm::terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{execute, queue};
use rand::seq::SliceRandom;
fn main() -> Result<()> {
let mut stdout = stdout();
execute!(stdout, Hide, EnterAlternateScreen, Clear(ClearType::All))?;
let mut maze = [[1usize; MAZE_X]; MAZE_Y];
dig_maze(11, 11, &mut maze)?;
execute!(stdout, Show, LeaveAlternateScreen,)?;
Ok(())
}
enum Direction {
Up,
Down,
Left,
Right,
}
impl Direction {
fn new_vec() -> Vec<Direction> {
vec![Self::Up, Self::Down, Self::Left, Self::Right]
}
}
// 穴掘り法
// ブロック(WALL)の大きさが2で掘る長さが4なのでそこだけ注意
const MAZE_Y: usize = 31;
const MAZE_X: usize = 51;
const WALL: &str = "██";
const TIME: Duration = Duration::from_millis(10); // ゆっくり見たいときにはここを大きい数字にする
fn dig_maze(mut x: usize, mut y: usize, maze: &mut [[usize; MAZE_X]; MAZE_Y]) -> Result<()> {
maze[y][x] = 0;
maze[y][x + 1] = 0;
let mut direction = Direction::new_vec();
let mut rng = rand::thread_rng();
direction.shuffle(&mut rng);
for d in direction.iter() {
match d {
Direction::Up => {
let dy = y.checked_sub(2);
if dy.is_none() || maze[dy.unwrap()][x] == 0 {
continue;
}
maze[y - 1][x] = 0;
maze[y - 1][x + 1] = 0;
maze[y - 2][x] = 0;
maze[y - 2][x + 1] = 0;
y -= 2;
}
Direction::Right => {
if (x + 4) >= MAZE_X || maze[y][x + 4] == 0 {
continue;
}
maze[y][x + 1] = 0;
maze[y][x + 2] = 0;
maze[y][x + 3] = 0;
maze[y][x + 4] = 0;
x += 4;
}
Direction::Down => {
if (y + 2) >= MAZE_Y || maze[y + 2][x] == 0 {
continue;
}
maze[y + 1][x] = 0;
maze[y + 1][x + 1] = 0;
maze[y + 2][x] = 0;
maze[y + 2][x + 1] = 0;
y += 2;
}
Direction::Left => {
let dx = x.checked_sub(4);
if dx.is_none() || maze[y][dx.unwrap()] == 0 {
continue;
}
maze[y][x - 1] = 0;
maze[y][x - 2] = 0;
maze[y][x - 3] = 0;
maze[y][x - 4] = 0;
x -= 4;
}
}
draw_maze(maze).unwrap();
std::thread::sleep(TIME);
dig_maze(x, y, maze)?;
}
Ok(())
}
fn draw_maze(maze: &[[usize; MAZE_X]; MAZE_Y]) -> Result<()> {
for y in 0..MAZE_Y {
for x in 0..MAZE_X {
if maze[y][x] == 0 {
queue!(
stdout(),
MoveTo(x as u16, y as u16),
PrintStyledContent(WALL.blue())
)?;
} else {
queue!(
stdout(),
MoveTo(x as u16, y as u16),
PrintStyledContent(WALL.black())
)?;
}
}
}
stdout().flush()?;
Ok(())
}
代替画面で迷路が表示され戻ってくるときにはなくなっているので画面が綺麗なままになる
このように別のバッファーを開いて何かやってコマンドを実行した後に戻すことができる
大抵はループ処理で表示し続けるようにする
ローモードの切り替え
raw mode
はターミナルに対する入力やそれによるカーソル移動などを自動でやらずに全て手動で行えるようにするモード
Ctrl+c
でターミナルのコマンドを強制終了したりするのをやめさせたりできる
先ほどの迷路を表示中の間にCtrl+c
を押すと強制終了させることができる
これを意図的に消したい場合はrawモードを設定する
ただ,入力によるカーソル移動や右端に来たら改行なども全て手動で制御する必要が出てくるので注意が必要
fn main() -> Result<()> {
enable_raw_mode()?;
for i in 0..5 {
print!("{i}");
sleep(Duration::from_secs(1));
}
disable_raw_mode()?;
Ok(())
}
画面のクリア
Clearの仕方はClearTypeを指定するだけ
- All 全部
- Purge 粛清する(履歴とセル全て)
- FromCursorDown カーソル位置から下方向のセル全て
- FromCursorUp カーソル位置から上方向のセル全て
- CurrentLine 現在の行のセル全て
- UntilNewLine 現在のカーソル位置から改行までのセルすべて
fn main() -> Result<()>{
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen)?;
// terminal::size() -> Result<(u16, u16)>を利用して全画面もできる
const WALL_Y: usize = 41;
const WALL_X: usize = 101;
let tiles = "██";
for y in 0..WALL_Y {
for x in 0..WALL_X {
execute!(
stdout,
MoveTo(x as u16, y as u16),
style::PrintStyledContent(tiles.white())
)
.unwrap();
}
println!();
}
for y in 0..WALL_Y {
for x in 0..WALL_X {
execute!(
stdout,
MoveTo(x as u16, y as u16),
Clear(ClearType::FromCursorUp),
)
.unwrap();
}
println!();
}
execute!(stdout, LeaveAlternateScreen)
}
白く塗られていき、徐々に消えていく
実行したときにClear(ClearType::All)
をやっておくと、実行するたびに綺麗になる
実行した場所のカーソル位置を保存しCler(ClearType::FromCursorDown)
をしたりすると代替画面に行かずに描画したのを消したりできる
カーソル操作
既に迷路作成で出てきているが、出力するときの位置先などを決めることができる
入力をするときとかにMoveRight(Left)とかBlinkを利用し、何かを特定の位置に表示させたいときにはMoveToを使ったりする
構造体の名前と機能が割と見てわかるので簡易的に説明をする
- DisableBlinking: カーソルの点滅を無効化
- EnableBlinking: カーソルの点滅を有効化
- SetCursorStyle: カーソルの形を変更(ブロック,ラインなどなど)
- Hide: カーソルを隠す
- Show: カーソルを表示
- Move(Up, Down, Left, Right)
- MoveTo: 指定した(col, row)に移動させる。左上が(0, 0)の基準になっている
- MoveTo(Column, NextLine, PreviousLine, Row, Up) 各列や行に移動
- SavePosition, RestorePosition
とりあえず指定した位置に文字を出力するのはこのようになる
use std::io::{stdout, Result};
use crossterm::cursor::MoveTo;
use crossterm::execute;
use crossterm::style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor};
fn main() -> Result<()> {
execute!(
stdout(),
MoveTo(10, 20),
SetForegroundColor(Color::Blue),
SetBackgroundColor(Color::Red),
Print("Styled text here."),
ResetColor
)?;
Ok(())
}
MoveTo(10, 20)からわかる通り、10列目の20行目からStyled text here
が表示される
今表示しているターミナルの画面を基準にして大きさが測られて上書きするように表示される
MoveDownの場合は以下の通り
use std::io::{stdout, Result};
use crossterm::cursor::MoveTo;
use crossterm::execute;
use crossterm::style::{Color, Print, SetBackgroundColor};
use crossterm::terminal::{Clear, ClearType};
fn main() -> Result<()> {
let mut stdout: std::io::Stdout = stdout();
let cursor_styles: [(u16, Color); 16] = [
(0, Color::Black),
(1, Color::DarkGrey),
(2, Color::Red),
(3, Color::DarkRed),
(4, Color::Green),
(5, Color::DarkGreen),
(6, Color::Yellow),
(7, Color::DarkYellow),
(8, Color::Blue),
(9, Color::DarkBlue),
(10, Color::Magenta),
(11, Color::DarkMagenta),
(12, Color::Cyan),
(13, Color::DarkCyan),
(14, Color::White),
(15, Color::Grey),
];
execute!(stdout, Clear(ClearType::All), MoveTo(0, 0))?;
for (i, color) in cursor_styles.into_iter() {
execute!(
stdout,
MoveTo(i, i),
SetBackgroundColor(color),
Print(cursor_styles[i as usize].0),
)?;
}
Ok(())
}
これも単純に最初に指定した位置から背景を付けて数字をプリントして下に移動するのが分かる
UpやRight、Leftも同様である
入力系
とりあえずはドキュメントにある入力系の大まかななのは以下の通り
- EnableFocusChange
- DisableMouseCapture
- KeyEvent
- KeyModifiers
- MouseEvent
それから読み込むためのread()と、そのread()をチェックするためのpoll()の2つの関数がある
crosstermもそうだが、割と多くのライブラリで独自のResultを実装していることが多い
そのためcrossterm::Result
とio::Result
, std::result::Result
など様々なResult
が混ざってタイプミスマッチのエラーが起きやすくなる
このPRで修正され、io::Result
で処理可能になった
とりあえず、read()を使ってマウスとキーボードそれからターミナルのリサイジングの入力を受け取るコードを以下に示す。
EscかCtrl+cを押すと元に戻れる。
fn main() -> Result<()> {
execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
print_events().unwrap();
execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
Ok(())
}
fn print_events() -> Result<()> {
loop {
// `read()` blocks until an `Event` is available
match read()? {
Event::Mouse(event) => println!("{:?}", event),
Event::Key(event) => match event {
KeyEvent {
code: KeyCode::Esc, ..
} => break,
_ => println!("{:?}", event),
},
Event::Resize(width, height) => println!("New size {}x{}", width, height),
_ => {}
}
}
Ok(())
}
ターミナルに対するすほぼ全ての入力が出力されるようになる
他にもtokioを使ったEventSteramだったり、クリップボードのサポートだったり機能が豊富にある
ここで注意が必要なのは入力が出力される先が決められていないので中途半端な位置から出力がされるという点である
マウス入力
マウスカーソルがターミナル上にあるときに自動で取得される
位置,移動中かどうか,ボタンを押しているかどうかなどが取得できる
とりあえずhere
を表示してその近くになるとhover
が表示され,クリックをし続けているとClicked
が表示されるようのは以下の通り (Result as crsResultするのを忘れずに)
esc
かCtrl+c
で終了できる
fn main() -> Result<()> {
execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
print_events().unwrap();
execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
Ok(())
}
struct ButtonDeath {
x: u16,
y: u16,
width: u16,
height: u16,
}
impl ButtonDeath {
fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
Self {
x,
y,
width,
height,
}
}
fn is_in_button(&self, x: u16, y: u16) -> bool {
match (
self.x <= x,
x <= self.x + self.width,
self.y <= y,
y <= self.y + self.height,
) {
(true, true, true, true) => true,
_ => false,
}
}
fn get_left_top(&self) -> (u16, u16) {
(self.x, self.y)
}
}
fn print_events() -> Result<()> {
let button = ButtonDeath::new(2, 2, 4, 0);
let (left_x, left_y) = button.get_left_top();
loop {
match event::read()? {
Event::Mouse(MouseEvent {
kind, column, row, ..
}) => {
if button.is_in_button(column, row) {
execute!(
stdout(),
MoveTo(left_x, left_y),
Clear(ClearType::CurrentLine),
Print("hover".with(Color::Black.into()).on(Color::Blue.into()))
)?;
match kind {
MouseEventKind::Down(MouseButton::Left) => {
execute!(
stdout(),
MoveTo(left_x, left_y + 1),
Print("Clicked".with(Color::Green.into()))
)?;
}
MouseEventKind::Up(_) => {
execute!(
stdout(),
MoveTo(left_x, left_y + 1),
Clear(ClearType::CurrentLine)
)?;
}
_ => {}
}
} else {
execute!(
stdout(),
MoveTo(left_x, left_y),
Clear(ClearType::CurrentLine),
Print("Here")
)?;
}
}
Event::Key(event) => match event {
KeyEvent {
code: KeyCode::Esc, ..
} => break,
_ => {
execute!(stdout(), MoveTo(0, 0))?;
println!("{:?}", event);
}
},
_ => {}
}
}
Ok(())
}
簡単なクリックとボタンもどきみたいなのができてしまう
専用のButton
構造体を作ってしまえばターミナル上に簡単にボタンを配置したりできるようになる
他にもドラック(クリックして移動)とかの入力を受け取れるので更に細かい操作とかも可能になっている
キーボード入力
以下のコードがとりあえずキーボード入力を取得する一番楽な方法である
Even::Key(key) = event::read()?
簡易的にはif let
で一文字入力、while let
で文字列の入力ができる
fn input_events() -> Result<()> {
loop {
if let Event::Key(KeyEvent {
code,
modifiers,
kind,
state,
}) = read()?
{
if code == KeyCode::Esc {
break;
}
println!("{:?}{:?}{:?}{:?}", code, modifiers, kind, state);
}
}
Ok(())
}
複数行にするときにはloop
とwhile let
を使うとできる
key
はKeyEvent { code, modifiers, kind, state }
の4つのフィールドを持つ構造体になっている
- code: 押されている特殊キーを含めたキー入力
- modifiers: 押されている修飾キー入力
- kind: キーが押される、押し続けられている、離されたを判断するバリアント
- state: 変更されたキー入力を正確に読み取れるようにするフラグ
今回はここのcode
とmodifiers
に限定して説明する
codeとmodfiersはそれぞれキーのバリアントを持っており、キー入力をそれらバリアントとマッチングさせることでキーを操作する
例えばCtrl+Alt+aを押した場合は以下のようになる
KeyEvent {
code: Char('A'),
modifiers: CNTROL | ALT,
kind: Pressed,
state: None,
}
なぜかShiftとCtrlは同時に押しても認識されない
codeには入力された文字が代入される
ユニークな文字の場合はそれのバリアント名となる
- Enter
- Backspace
- Tab
- Esc
それ以外はChar('a'), Char(' ')(空白), Char('#')などのcharで取得される
入力とカーソル移動を合わせたエディタもどきは以下のようになる
use std::{
fmt::Display,
io::{stdout, Result, Write},
};
use crossterm::{
cursor::{MoveTo, SetCursorStyle},
event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
execute, queue,
style::Print,
terminal::{
disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
LeaveAlternateScreen,
},
};
fn main() -> Result<()> {
let mut stdout = stdout();
let mut app = App::new();
execute!(
stdout,
EnterAlternateScreen,
SetCursorStyle::BlinkingBlock,
MoveTo(app.col as u16, app.col as u16)
)?;
enable_raw_mode()?;
app.normal_mode()?;
execute!(
stdout,
LeaveAlternateScreen,
SetCursorStyle::DefaultUserShape
)?;
disable_raw_mode()?;
Ok(())
}
struct App {
lines: Vec<String>,
line_length: usize,
row: usize,
col: usize,
_width: u16,
height: u16,
mode: Mode,
}
enum Mode {
Normal,
Insert,
Visual,
}
trait MoveTrait {
fn cursor_style(&self) -> SetCursorStyle;
fn set_mode(&mut self, mode: Mode);
}
impl MoveTrait for Mode {
fn cursor_style(&self) -> SetCursorStyle {
match self {
Mode::Normal => SetCursorStyle::BlinkingBlock,
Mode::Insert => SetCursorStyle::BlinkingBar,
Mode::Visual => SetCursorStyle::SteadyBlock,
}
}
fn set_mode(&mut self, mode: Mode) {
*self = mode;
}
}
impl Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Mode::Normal => write!(f, "Normal"),
Mode::Insert => write!(f, "Insert"),
Mode::Visual => write!(f, "Visual"),
}
}
}
struct VisualRange {
line_begin: usize,
line_end: usize,
col_begin: usize,
col_end: usize,
}
impl VisualRange {
fn new(lb: usize, le: usize, cb: usize, ce: usize) -> Self {
Self {
line_begin: lb,
line_end: le,
col_begin: cb,
col_end: ce,
}
}
}
impl App {
fn new() -> Self {
let (width, height) = crossterm::terminal::size().unwrap();
Self {
lines: vec![String::new()],
line_length: 0,
row: 0,
col: 0,
_width: width,
height,
mode: Mode::Normal,
}
}
fn draw_text(&self) -> Result<()> {
queue!(stdout(), Clear(ClearType::All))?;
for (i, line) in self.lines.iter().enumerate() {
queue!(stdout(), MoveTo(0, i as u16), Print(line))?;
}
Ok(())
}
fn draw_cursor(&self) -> Result<()> {
print!(
"{}({}:{}) {}, {}",
MoveTo(0, self.height - 1),
self.col,
self.row,
self.line_length,
self.mode
);
queue!(stdout(), MoveTo(self.col as u16, self.row as u16))?;
Ok(())
}
fn current_width(&mut self) {
if let Some(line) = self.lines.get(self.row) {
self.line_length = line.len();
if self.line_length < self.col {
self.col = self.line_length;
}
}
}
fn normal_mode(&mut self) -> Result<()> {
self.draw_text()?;
self.draw_cursor()?;
loop {
if let Event::Key(KeyEvent { code, kind, .. }) = event::read()? {
if kind == KeyEventKind::Release {
continue;
}
self.mode.set_mode(Mode::Normal);
match code {
KeyCode::Esc => break,
KeyCode::Char('i') => {
self.insert_mode()?;
self.mode.set_mode(Mode::Normal);
execute!(stdout(), self.mode.cursor_style())?;
}
KeyCode::Char('v') => {
self.visual_mode()?;
self.mode.set_mode(Mode::Normal);
execute!(stdout(), self.mode.cursor_style())?;
}
KeyCode::Char('h') => {
if self.col > 0 {
self.col -= 1;
}
}
KeyCode::Char('l') => {
if let Some(line) = self.lines.get(self.row) {
if self.col < line.len() {
self.col += 1;
}
}
}
KeyCode::Char('j') => {
if self.row < self.lines.len() - 1 {
self.row += 1;
self.current_width();
}
}
KeyCode::Char('k') => {
if self.row > 0 {
self.row -= 1;
self.current_width();
}
}
_ => {}
}
}
self.draw_text()?;
self.draw_cursor()?;
stdout().flush()?;
}
Ok(())
}
fn insert_mode(&mut self) -> Result<()> {
self.mode.set_mode(Mode::Insert);
self.draw_text()?;
self.draw_cursor()?;
execute!(stdout(), self.mode.cursor_style())?;
loop {
if let Event::Key(KeyEvent { code, kind, .. }) = event::read()? {
if kind == KeyEventKind::Release {
continue;
}
match code {
KeyCode::Enter => {
if self.col == self.line_length {
self.row += 1;
self.col = 0;
self.lines.push(String::new());
} else {
let line = self.lines.remove(self.row);
let (l, r) = line.split_at(self.col);
self.lines.insert(self.row, l.to_string());
self.lines.insert(self.row + 1, r.to_string());
self.row += 1;
self.col = 0;
}
}
KeyCode::Backspace => {
let row = self.lines.get_mut(self.row);
if row.is_none() {
continue;
}
if self.col == 0 {
if let Some(row) = self.row.checked_sub(1) {
self.row = row;
if let Some(width) = self.lines.get(self.row) {
self.col = width.len();
let text = self.lines.remove(self.row + 1);
if let Some(line) = self.lines.get_mut(self.row) {
line.push_str(&text);
}
}
}
self.current_width();
} else if self.col > 0 {
row.unwrap().remove(self.col - 1);
self.col -= 1;
self.current_width();
}
}
KeyCode::Esc => break,
KeyCode::Left => {
self.col = self.col.saturating_sub(1);
}
KeyCode::Right => {
if let Some(row) = self.lines.get(self.row) {
if self.col < row.len() {
self.col += 1;
}
}
}
KeyCode::Down => {
if self.row + 1 < self.lines.len() {
self.row += 1;
self.current_width();
}
}
KeyCode::Up => {
self.row = self.row.saturating_sub(1);
self.current_width();
}
KeyCode::Char(c) => {
self.lines[self.row as usize].insert(self.col as usize, c);
self.col += 1;
self.current_width();
}
_ => {}
}
self.draw_text()?;
self.draw_cursor()?;
stdout().flush()?;
}
}
Ok(())
}
fn visual_mode(&mut self) -> Result<()> {
self.mode.set_mode(Mode::Visual);
todo!()
}
}
visualモードも作ろうと思ったが面倒になったのであきらめた
文字列をVecで保持しているのでこれを単一のStringやそれこそPiece Table
やGup Buffer
にすると本格的なテキストエディタになる
また、毎度画面を消して再描画するのを繰り返しているので編集した箇所のみを描画とか無効な時にcontinue
するとか、いろいろ考慮できるところがある
もどきなので致し方ない
でも簡単な操作ならこんな感じでRustにしてはコードが少ない?と思われる量で書くことができる
まとめ
Crosstermの使い方を少しだが紹介することができたと思う
- 色の指定
- ターミナルの画面
- カーソル位置
- キー入力
それぞれの紹介しきれていない部分や自分の簡易コードだと表現しきれていない良さもあったりするので触ってみるのをお勧めする
私も勉強がてらに触っているが、こういうライブラリを勉強するとTUIやCUIに慣れてきたり、それこそ作ってみたいなあという気が強くなってくるので割とRustの初心者にも良いかなあと思う