【Rust】TUIアプリケーションで起こる全角文字に関するトラブルあれこれ

# はじめに
Rustの学習の一環として，個人的にTUIアプリケーションを作っている．
その際に遭遇したトラブルを共有したい．

# 環境
- Windows11 (24H2)
- rustc 1.86.0

# #1 全角文字を入力するとクラッシュする

以下は，ユーザーが全角文字を入力するとクラッシュする例である．
以降，このプログラムを基に説明を進める．

::: note
実行する場合は，プロジェクトに `color_eyre`, `ratatui` を `add` してください．
:::

``` rust
use color_eyre::Result;
use ratatui::{
    crossterm::event::{self, Event, KeyCode, KeyEventKind},
    layout::{Constraint, Layout, Position},
    style::{Color, Style},
    text::Text,
    widgets::{Block, Paragraph},
    DefaultTerminal, Frame,
};

fn main() -> Result<()> {
    color_eyre::install()?;
    let terminal = ratatui::init();
    let app = App::new().run(terminal);
    ratatui::restore();
    app
}

struct App {
    input: String,
    cursor: usize,
}

impl App {
    fn new() -> Self {
        Self { input: String::new(), cursor: 0 }
    }

    fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
        loop {
            terminal.draw(|f| self.draw(f))?;

            if let Event::Key(key) = event::read()? {
                if key.kind == KeyEventKind::Press {
                    match key.code {
                        KeyCode::Char(c) => {
                            let idx = self.cursor;
                            self.input.insert(idx, c);
                            self.cursor += 1;
                        }
                        KeyCode::Backspace => {
                            if self.cursor > 0 {
                                let remove_idx = self.cursor - 1;
                                self.input.remove(remove_idx);
                                self.cursor -= 1;
                            }
                        }
                        KeyCode::Left => {
                            self.cursor = self.cursor.saturating_sub(1);
                        }
                        KeyCode::Right => {
                            self.cursor = (self.cursor + 1).min(self.input.len());
                        }
                        KeyCode::Esc => break,
                        _ => {}
                    }
                }
            }
        }
        Ok(())
    }

    fn draw(&self, f: &mut Frame) {
        let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(1)])
            .split(f.area());

        let input = Paragraph::new(Text::raw(&self.input))
            .style(Style::default().fg(Color::White))
            .block(Block::bordered().title("Input (MB非対応)"));
        f.render_widget(input, chunks[0]);

        let x = chunks[0].x + self.cursor as u16 + 1;
        let y = chunks[0].y + 1;
        f.set_cursor_position(Position::new(x, y));
    }
}
```

これを実行すると，ユーザーが入力できるブロックだけのシンプルなTUIアプリケーションが立ち上がる．
英語を入力しているぶんには正常に動作するが，全角文字を入力しだすと以下のようなエラーを出してクラッシュすると思う．

``` console
The application panicked (crashed).
Message:  assertion failed: self.is_char_boundary(idx)
Location: // 省略

Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.
error: process didn't exit successfully: `target\debug\user_input.exe` (exit code: 101)
```

## 原因

エラーのこの部分．

``` console
Message:  assertion failed: self.is_char_boundary(idx)
```

これは，文字の境界でないところで文字を分けようとしたことで起こるエラーである．
その箇所は，ここ．

``` rust
impl App {
    ...
    fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
        ...
                    match key.code {
                        KeyCode::Char(c) => {
                            let idx = self.cursor;
                            self.input.insert(idx, c);
                            self.cursor += 1;
                        }
        ...
    }
```

文字が入力されるたびに，
1. インデックス = 現在のカーソル位置
1. インデックスに入力された文字を挿入
1. カーソル位置を +1

という処理が行われる．
そのため，1バイト文字だけを扱う場合は正常に動作するが，
全角文字などのマルチバイト文字が文字列に含まれると，インデックスの値が実際のインデックス（文字の先頭）ではなくなりエラーが起こる．

## 解決 (入力だけ)

以下のようにすることで解決できる．

``` rust
KeyCode::Char(c) => {
    let idx = self.input.char_indices().map(|(i, _)| i).nth(self.cursor).unwrap_or(self.input.len());
    self.input.insert(idx, c);
    self.cursor += 1;
}
```
実際に先ほどのプログラムのこの部分を上記に書き換えると，
全角文字などのマルチバイト文字を入力してもクラッシュしなくなるだろう．

（個人的に仕組みをまとめた記事を作りました．）

https://qiita.com/Santonn/private/2adbdc4ff396cecdebd8


## 消去も

**しかし，まだ解決ではない．**

文字を消去する処理もマルチバイト文字に対応させる必要がある．
実際，先述の修正だけで再度プログラムを実行すると

``` 
Message:  byte index 5 is not a char boundary; it is inside 'え' (bytes 3..6) of `ええええええ`
Location: // 省略

Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.
error: process didn't exit successfully: `target\debug\user_input.exe` (exit code: 101)
```

これは，'ええええええ' と入力した後，Backspace を一度押した結果である．

原因は，

``` rust
impl App {
    ...
    fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
        ...
                        KeyCode::Backspace => {
                            if self.cursor > 0 {
                                let remove_idx = self.cursor - 1;
                                self.input.remove(remove_idx);
                                self.cursor -= 1;
                            }
                        }
        ...
    }
```

Backspace が押されるたびに，
1. インデックス = 現在のカーソル位置 - 1
1. インデックスにあたる文字を消去
1. カーソル位置を -1

これも，文字列にマルチバイト文字が含まれると ... 

## 解決 (消去だけ)
以下のようにすることで解決できる．

``` rust
KeyCode::Backspace => {
        if self.cursor > 0 {
            let mut cs: Vec<char> = self.input.chars().collect();
            cs.remove(self.cursor - 1);
            self.input = cs.into_iter().collect();
            self.cursor -= 1;
        }
}
```

## 解決（まとめ）
以下は，マルチバイト文字の入力・消去に対応させたプログラムである．

``` rust
use color_eyre::Result;
use ratatui::{
    crossterm::event::{self, Event, KeyCode, KeyEventKind},
    layout::{Constraint, Layout, Position},
    style::{Color, Style},
    text::Text,
    widgets::{Block, Paragraph},
    DefaultTerminal, Frame,
};

fn main() -> Result<()> {
    color_eyre::install()?;
    let terminal = ratatui::init();
    let app = App::new().run(terminal);
    ratatui::restore();
    app
}

struct App {
    input: String,
    cursor: usize,
}

impl App {
    fn new() -> Self {
        Self { input: String::new(), cursor: 0 }
    }

    fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
        loop {
            terminal.draw(|f| self.draw(f))?;

            if let Event::Key(key) = event::read()? {
                if key.kind == KeyEventKind::Press {
                    match key.code {
                        KeyCode::Char(c) => {
                            let byte_idx = self.input.char_indices()
                                .map(|(i, _)| i)
                                .nth(self.cursor)
                                .unwrap_or(self.input.len());
                            self.input.insert(byte_idx, c);
                            self.cursor += 1;
                        }
                        KeyCode::Backspace => {
                            if self.cursor > 0 {
                                let mut cs: Vec<char> = self.input.chars().collect();
                                cs.remove(self.cursor - 1);
                                self.input = cs.into_iter().collect();
                                self.cursor -= 1;
                            }
                        }
                        KeyCode::Left => {
                            self.cursor = self.cursor.saturating_sub(1);
                        }
                        KeyCode::Right => {
                            self.cursor = (self.cursor + 1).min(self.input.len());
                        }
                        KeyCode::Esc => break,
                        _ => {}
                    }
                }
            }
        }
        Ok(())
    }

    fn draw(&self, f: &mut Frame) {
        let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(1)])
            .split(f.area());

        let input = Paragraph::new(Text::raw(&self.input))
            .style(Style::default().fg(Color::White))
            .block(Block::bordered().title("Input (MB非対応)"));
        f.render_widget(input, chunks[0]);

        let x = chunks[0].x + self.cursor as u16 + 1;
        let y = chunks[0].y + 1;
        f.set_cursor_position(Position::new(x, y));
    }
}

```


# #2 カーソルがずれる

簡単のため，#1の最初に記載したプログラムをマルチバイト文字に対応させた段階の[直前のプログラム](#解決まとめ)を用いる．

## 「カーソルがずれる」とは

![abc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3627120/7a5c332f-8d09-41ed-8b08-bfe32e010cf5.png)

これが，

![あいう.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3627120/8afcc00e-717a-48aa-8c7d-cf178db01003.png)

こうなる．

アプリがクラッシュしたりすることはない．
何事もないように動作し続ける．

## 原因

``` rust
impl App {
    ...
    fn draw(&self, f: &mut Frame) {
        ...
        let x = chunks[0].x + self.cursor as u16 + 1;
        let y = chunks[0].y + 1;
        f.set_cursor_position(Position::new(x, y));
    }
}
```
- `chunks[0]`: 入力欄の左端
- `cursor as u16`: 「何文字目」を表す `cursor` を座標を表すu16に変換

よって，**文字数 = x座標** という前提の処理である．
そのため，マルチバイト文字すなわちx座標複数分に相当する文字が文字列に含まれているとカーソルがずれてしまう．

## 解決

以下のようにすることで解決できる．

``` rust
impl App {
    ...
    fn cursor_x(&self) -> u16 {
        let end = self.input.char_indices()
            .map(|(i, _)| i)
            .nth(self.cursor)
            .unwrap_or(self.input.len());
        UnicodeWidthStr::width(&self.input[..end]) as u16
    }
    ...
    fn draw(&self, f: &mut Frame) {
        ...
        let x = chunks[0].x + self.cursor_x() + 1;
        let y = chunks[0].y + 1;
        f.set_cursor_position(Position::new(x, y));
    }
}
```

まず，#1 の入力と同じ処理でマルチバイト文字を算出できるようにし，
`UnicodeWidthStr::width(&self.input[..end]) as u16` で，
その文字列がx座標何個分に相当するかを算出し返している．

この処理によって，カーソルもマルチバイト文字に対応することができる．

# まとめ
以下は，これまでの問題をすべて解決したプログラムである．

::: note
実行する場合は，プロジェクトに `color_eyre`, `ratatui`, `unicode_width` を `add` してください．
:::

``` rust
use color_eyre::Result;
use ratatui::{
    crossterm::event::{self, Event, KeyCode, KeyEventKind},
    layout::{Constraint, Layout, Position},
    style::{Color, Style},
    text::Text,
    widgets::{Block, Paragraph},
    DefaultTerminal, Frame,
};
use unicode_width::UnicodeWidthStr;

fn main() -> Result<()> {
    color_eyre::install()?;
    let terminal = ratatui::init();
    let app = App::new().run(terminal);
    ratatui::restore();
    app
}

struct App {
    input: String,
    cursor: usize,
}

impl App {
    fn new() -> Self {
        Self { input: String::new(), cursor: 0 }
    }

    fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
        loop {
            terminal.draw(|f| self.draw(f))?;

            if let Event::Key(key) = event::read()? {
                if key.kind == KeyEventKind::Press {
                    match key.code {
                        KeyCode::Char(c) => {
                            let byte_idx = self.input.char_indices()
                                .map(|(i, _)| i)
                                .nth(self.cursor)
                                .unwrap_or(self.input.len());
                            self.input.insert(byte_idx, c);
                            self.cursor += 1;
                        }
                        KeyCode::Backspace => {
                            if self.cursor > 0 {
                                let mut cs: Vec<char> = self.input.chars().collect();
                                cs.remove(self.cursor - 1);
                                self.input = cs.into_iter().collect();
                                self.cursor -= 1;
                            }
                        }
                        KeyCode::Left => {
                            self.cursor = self.cursor.saturating_sub(1);
                        }
                        KeyCode::Right => {
                            self.cursor = (self.cursor + 1).min(self.input.chars().count());
                        }
                        KeyCode::Esc => break,
                        _ => {}
                    }
                }
            }
        }
        Ok(())
    }

    fn cursor_x(&self) -> u16 {
        let end = self.input.char_indices()
            .map(|(i, _)| i)
            .nth(self.cursor)
            .unwrap_or(self.input.len());
        UnicodeWidthStr::width(&self.input[..end]) as u16
    }

    fn draw(&self, f: &mut Frame) {
        let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(1)])
            .split(f.area());

        let input = Paragraph::new(Text::raw(&self.input))
            .style(Style::default().fg(Color::White))
            .block(Block::bordered().title("Input (MB対応)"));
        f.render_widget(input, chunks[0]);

        let x = chunks[0].x + self.cursor_x() + 1;
        let y = chunks[0].y + 1;
        f.set_cursor_position(Position::new(x, y));
    }
}

```

# さいごに
この記事は，[ratatuiの公式サイト](https://ratatui.rs/)のプログラムを参考にしております．
**Examples** $\rightarrow$ **Apps** $\rightarrow$ **User Input** に記載されているプログラムです．

#1 に関しては，元プログラムに類似の関数が既に定義されているため，適用が簡単だと思います．