LoginSignup
11
2

Crosstermの概要

Last updated at Posted at 2022-11-04

👉 crossterm version = “0.25.0” "0.27.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(())
}

スクリーンショット 2024-01-23 200410.png

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してから一気に描画した方がちらつきがなくてすっきりと見れる

Untitled 1.png

ターミナル操作

個人的に重要かなと思うのが以下の6つ

  • EnterAlternateScreen 代替画面(バッファー)へ入る
  • LeaveAlternateScreen 代替画面から出る
  • enable_row_mode rawモードの有効化
  • disable_row_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::cursor::{Hide, MoveTo, Show};
use crossterm::style::Stylize;
use crossterm::terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{execute, queue, style::PrintStyledContent};
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(())
}

dig_maze.gif

代替画面で迷路が表示され戻ってくるときにはなくなっているので画面が綺麗なままになる

このように別のバッファーを開いて何かやってコマンドを実行した後に戻すことができる

大抵はループ処理で表示し続けるようにする

ローモードの切り替え

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)?;
    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,
                cursor::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,
                cursor::MoveTo(x as u16, y as u16),
                Clear(ClearType::FromCursorUp),
            )
            .unwrap();
        }
        println!();
    }
    execute!(stdout, LeaveAlternateScreen)?;
}

clear_window.gif

白く塗られていき、徐々に消えていく

実行したときに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

とりあえず指定した位置に文字を出力するのはこのようになる

fn main() -> Result<()> {
    execute!(
        stdout(),
        MoveTo(10, 1),
        SetForegroundColor(Color::Blue),
        SetBackgroundColor(Color::Red),
        Print("Styled text here."),
        ResetColor
    )?;
    Ok(())
}

MoveTo(10, 1)からわかる通り、10列目の1行目からStyled text hereが表示される

今表示しているターミナルの画面を基準にして大きさが測られて上書きするように表示される

MoveDownの場合は以下の通り

fn main() -> Result<()> {
    print!("{}", MoveTo(0, 0));
    for i in 0..10 {
        execute!(
            stdout(),
            MoveDown(i),
            SetForegroundColor(Color::Blue),
            SetBackgroundColor(Color::Red),
            Print("Styled text here."),
            ResetColor
        )?;
    }

    Ok(())
}

これも単純に最初に指定した位置から下に移動するのが分かる

UpやRight、Leftも同様である

入力系

とりあえずはドキュメントにある入力系の大まかななのは以下の通り

  • EnableFocusChange
  • DisableMouseCapture
  • KeyEvent
  • KeyModifiers
  • MouseEvent

それから読み込むためのread()と、そのread()をチェックするためのpoll()の2つの関数がある

crosstermもそうだが、割と多くのライブラリで独自のResultを実装していることが多い

そのためcrossterm::Resultio::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するのを忘れずに)

escCtrl+cで終了できる

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(())
}

fn main() -> Result<()> {
    execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
    print_events().unwrap();
    execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
    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(())
}

複数行にするときにはloopwhile letを使うとできる

keyKeyEvent { code, modifiers, kind, state }の4つのフィールドを持つ構造体になっている

  • code: 押されている特殊キーを含めたキー入力
  • modifiers: 押されている修飾キー入力
  • kind: キーが押される、押し続けられている、離されたを判断するバリアント
  • state: 変更されたキー入力を正確に読み取れるようにするフラグ

今回はここのcodemodifiersに限定して説明する

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::io::{stdout, Write};

use crossterm::{
    cursor::{CursorShape, MoveTo, SetCursorShape},
    event::{self, Event, KeyCode, KeyEvent},
    execute, queue,
    style::Print,
    terminal::{
        disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
        LeaveAlternateScreen,
    },
    Result,
};

struct App {
    lines: Vec<String>,
    line_length: usize,
    row: usize,
    col: usize,
    _width: u16,
    height: u16,
}

struct Range {
    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,
        }
    }

    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
        );
        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 visual_mode(&mut self) -> Result<()> { todo!() }

    fn insert_mode(&mut self) -> Result<()> {
        execute!(stdout(), SetCursorShape(CursorShape::Line))?;
        while let Event::Key(KeyEvent { code, .. }) = event::read()? {
            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 normal_mode(&mut self) -> std::io::Result<()> {
        loop {
            if let Event::Key(KeyEvent { code, .. }) = event::read()? {
                execute!(stdout(), SetCursorShape(CursorShape::Block))?;
                match code {
                    KeyCode::Esc => break,

                    KeyCode::Char('i') => {
                        self.insert_mode()?;
                        execute!(stdout(), SetCursorShape(CursorShape::Block))?;
                    }

                    KeyCode::Char('v') => {
                        self.visual_mode()?;
                        execute!(stdout(), SetCursorShape(CursorShape::Block))?;
                    }

                    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 main() -> Result<()> {
    let mut stdout = stdout();
    let mut app = App::new();
    execute!(
        stdout,
        EnterAlternateScreen,
        SetCursorShape(CursorShape::Block),
        MoveTo(app.col as u16, app.col as u16)
    )?;
    enable_raw_mode()?;

    app.normal_mode()?;

    execute!(stdout, LeaveAlternateScreen,)?;
    disable_raw_mode()?;
    Ok(())
}

visualモードも作ろうと思ったが面倒になったのであきらめた

文字列をVecで保持しているのでこれを単一のStringやそれこそPiece TableGup Bufferにすると本格的なテキストエディタになる

また、毎度画面を消して再描画するのを繰り返しているので編集した箇所のみを描画とか無効な時にcontinueするとか、いろいろ考慮できるところがある

もどきなので致し方ない

でも簡単な操作ならこんな感じでRustにしてはコードが少ない?と思われる量で書くことができる

まとめ

Crosstermの使い方を少しだが紹介することができたと思う

  • 色の指定
  • ターミナルの画面
  • カーソル位置
  • キー入力

それぞれの紹介しきれていない部分や自分の簡易コードだと表現しきれていない良さもあったりするので触ってみるのをお勧めする

私も勉強がてらに触っているが、こういうライブラリを勉強するとTUIやCUIに慣れてきたり、それこそ作ってみたいなあという気が強くなってくるので割とRustの初心者にも良いかなあと思う

11
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2