0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-05-05

はじめに

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

環境

  • Windows11 (24H2)
  • rustc 1.86.0

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

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

実行する場合は,プロジェクトに color_eyre, ratatuiadd してください.

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アプリケーションが立ち上がる.
英語を入力しているぶんには正常に動作するが,全角文字を入力しだすと以下のようなエラーを出してクラッシュすると思う.

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)

原因

エラーのこの部分.

Message:  assertion failed: self.is_char_boundary(idx)

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

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. インデックス = 現在のカーソル位置
  2. インデックスに入力された文字を挿入
  3. カーソル位置を +1

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

解決 (入力だけ)

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

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;
}

実際に先ほどのプログラムのこの部分を上記に書き換えると,
全角文字などのマルチバイト文字を入力してもクラッシュしなくなるだろう.

(個人的に仕組みをまとめた記事を作りました.)

消去も

しかし,まだ解決ではない.

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

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 を一度押した結果である.

原因は,

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
  2. インデックスにあたる文字を消去
  3. カーソル位置を -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;
        }
}

解決(まとめ)

以下は,マルチバイト文字の入力・消去に対応させたプログラムである.

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

これが,

あいう.png

こうなる.

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

原因

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座標複数分に相当する文字が文字列に含まれているとカーソルがずれてしまう.

解決

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

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座標何個分に相当するかを算出し返している.

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

まとめ

以下は,これまでの問題をすべて解決したプログラムである.

実行する場合は,プロジェクトに color_eyre, ratatui, unicode_widthadd してください.

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の公式サイトのプログラムを参考にしております.
Examples $\rightarrow$ Apps $\rightarrow$ User Input に記載されているプログラムです.

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

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?