LoginSignup
3
1

More than 1 year has passed since last update.

Rustとeguiによるテキスト処理

Last updated at Posted at 2022-12-30

はじめに

RustでGUIを持ったアプリを作ろうと思い、即時モードのGUIフレームワークであるeguiを少しずつ使い始めています。
前回の投稿では、簡単なお絵描きをeguiで行う方法について紹介しました。
今回は、テキスト入力のeguiでの扱い方について、シンプルな一行エディタを作ってみることで紹介したいと思います。

eguiでテキスト入力を扱う場合、text_editというモジュールが用意されているので本来であればこれを使うのですが、もう少し見栄えの自由度を持たせたいと思い、今回はラベルとお絵描きの延長でエディタ的なGUIを目指しています。

なお前回同様、Rustおよびeguiの機能的な説明というより、具体的な目標と実装方法のみを記述します。手っ取り早く、同様の方法を行いたい方向けの記事を目指しています。

Rust, egui のバージョンについて

本記事では

  • Rust 1.66.0
  • egui/eframe 0.20.1

を使用しています。
eguiはバージョンアップで容赦なく仕様を変えており、バージョン違いでコンパイルエラーが頻発しますので、eguiに関する記事を参照する際には、バージョンを十分確認したほうが良いです。
また、eguiの導入については、前回の記事を参照ください。

Main Windowの調整

まずは、main.rst を以下の状態から始めます。

main.rst
use eframe::{egui::*};

#[derive(Default)]
pub struct EguiSample {}

impl EguiSample {
    fn new(_cc: &eframe::CreationContext<'_>) -> Self {Self::default()}
}

impl eframe::App for EguiSample {
    fn save(&mut self, _storage: &mut dyn eframe::Storage) {}       
    fn update(&mut self, _ctx: &Context, _frame: &mut eframe::Frame) {}
}

fn main() {
    let options = eframe::NativeOptions::default();
    eframe::run_native("egui_sample", options, Box::new(|cc| Box::new(EguiSample::new(cc))));
}

最初に、このアプリのウインドウの大きさを調整してみます。一行のみのエディタなので、600×80pixelの大きさにしてみます。
main()関数を以下のように書き換えます。

main.rs
fn main() {
    let options = eframe::NativeOptions {
        initial_window_size: Some((600.0, 80.0).into()),
        resizable: false,
        ..eframe::NativeOptions::default()
    };
    eframe::run_native("egui_sample", options, Box::new(|cc| Box::new(EguiSample::new(cc))));
}

また、CentralPanel(eguiが提供する画面構成の一番基本の画面)の見た目は、update()内で以下の方法で設定できます。

main.rs
    fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
        // Configuration for CentralPanel
        let my_frame = egui::containers::Frame {
            inner_margin: egui::style::Margin { left: 0., right: 0., top: 0., bottom: 0. },
            outer_margin: egui::style::Margin { left: 0., right: 0., top: 0., bottom: 0. },
            rounding: egui::Rounding { nw: 0.0, ne: 0.0, sw: 0.0, se: 0.0 },
            shadow: eframe::epaint::Shadow { extrusion: 0.0, color: Color32::BLACK },
            fill: Color32::BLACK,
            stroke: egui::Stroke::new(0.0, Color32::BLACK),
        };

        CentralPanel::default().frame(my_frame).show(ctx, |ui| {
            // ....
        });
    }

let my_frame 以降では、CentralPanelのマージン設定や丸み、背景色などを設定することができます。好みに応じて、数値など変更してみてください。
上記をビルドして走らせると以下のような画面が現れます。
qiitq12301.png

Keyboard入力の捕捉

私が今回苦労したのはこの箇所ですが、ここでは結論のみ記載します。
なお、この方法より良い方法、あるいはeguiの想定する別の方法があるようでしたら、お知らせいただけると嬉しいです。

main.rs
    fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
        //  Get Keyboard Event from Egui::Context
        let evts = ctx.input().events.clone();
        let mut letters: Vec<&String> = vec![];
        for ev in evts.iter() {
            match ev {
                Event::Text(ltr) => letters.push(ltr),
                Event::Key {key, pressed, modifiers:_} => {
                    if pressed == &true { self.command_key(key);}
                },
                _ => {},
            }
        }
        if letters.len() >= 1 {self.input_letter(letters);}

update()の中に上記処理を追加します。
実際にイベント入力を得ているのは、let evtsの行です。
イベントは複数入っている可能性があるので、for文を使って、イベントを一つずつ解析します。今回はKeyboard入力のみを捕捉したいので、Event::TextとEvent::Keyの二種類をmatch文で処理します。
Event::Keyは全キーボードの押した時・離した時の情報が来ますが、ここでは押した時のみ検出します。
Event::Textは、キーボード入力から文字に変換された後の情報が来ます。通常の文字入力はこちらを使います。
ここに記載されているcommand_key()、input_letter()のメソッドについては後述します。

文字列の操作と保存

上記で補足したイベントから、実際に表示される文字列を生成します。
まずはそのために、モジュールの追加と、EguiSampleのアプリに以下のフィールド(メンバ変数)を追加します。

main.rst
use eframe::{egui::*};
use eframe::egui;
use std::time::{Duration, Instant};

pub struct EguiSample {
    input_locate: usize,
    input_text: String,
    start_time: Instant,
}

input_locateは文字を挿入する位置、input_textは入力されている文字列、またstart_timeは後述するプロンプトの点滅時に利用します。
次に、このEguiSampleに、command_key()と、input_letter()、二つのメソッドを実装します。

main.rst
    fn command_key(&mut self, key: &Key) {
        if key == &Key::Enter {
            // send text somewhere
            self.input_text = "".to_string();
            self.input_locate = 0;
            println!("Key>>{:?}",key);
        }
        else if key == &Key::Backspace {
            if self.input_locate > 0 {
                self.input_locate -= 1;
                self.input_text.remove(self.input_locate);
            }
            println!("Key>>{:?}",key);
        }
        else if key == &Key::ArrowLeft {
            if self.input_locate > 0 {self.input_locate -= 1;}
            println!("Key>>{:?}",key);
        }
        else if key == &Key::ArrowRight {
            self.input_locate += 1;
            let maxlen = self.input_text.chars().count();
            if self.input_locate > maxlen { self.input_locate = maxlen;}
            println!("Key>>{:?}",key);
        }
    }
    fn input_letter(&mut self, letters: Vec<&String>) {
        if self.input_locate <= Self::CURSOR_MAX_LOCATE {
            println!("Letters:{:?}",letters);
            letters.iter().for_each(|ltr| {
                self.input_text.insert_str(self.input_locate,ltr);
                self.input_locate+=1;
            });  
        }
    }

command_key()では、Enterキー、BackSpaceキー、左矢印キー、右矢印キーの処理を行っています。

  • Enterキー: 文字列をからにして、文字列の入力位置を0にする
  • BackSpaceキー: 入力位置を1減らし、その位置にある文字を削除
  • 左矢印キー: 入力位置を1減らす
  • 右矢印キー: 入力位置を1増やす

input_letter()では、入力された(複数あるかもしれない)文字を、入力位置に挿入します。
これで、文字列を操作してself.input_textに保存することが出来ました。

カーソルの点滅方法

テキストエディタのカーソルが点滅する様子を実装します(多分text_editモジュールを使えば、これを自力で実装する必要はないのでしょうけれど)。
eguiは毎frameごと書き換える即時モードを利用しているのですが、update()メソッドはイベントがあった時しか呼ばれないようです。
したがって、点滅させるためには、無理やりupdate()を周期的に呼ぶ必要があります。

main.rst
    fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
        // repaint 100msec interval
        ctx.request_repaint_after(Duration::from_millis(100));

そんな便利な機能がありました。
request_repaint_after()を使うと、次回再描画する時間を指定できます。ここでは100msecを指定しました。

また、カーソルの描画処理も特定時間だけ、表示されるようにする必要があります。

main.rst
    fn new(cc: &eframe::CreationContext<'_>) -> Self {
        Self {
            input_locate: 0,
            input_text: String::new(),
            start_time: Instant::now(), // 時間計測開始
        }
    }

    // ...
        let elapsed_time = self.start_time.elapsed().as_millis();
        if elapsed_time%500 > 200 {
            // カーソルの表示処理
    // ...

new()内で、start_timeを上記のように初期化すると、ここから時間計測を開始します。
あとで紹介する描画処理のカーソルの部分では、ここでカーソルを描画するかどうかを経過時間より判断しています。ここでは、500msecのうち300msecの間、表示するように設定しています。

描画処理全体

では、この一行エディタ全体を描画します。
まずフォント設定を、new()内に以下のように追記します。

main.rst
    fn new(cc: &eframe::CreationContext<'_>) -> Self {
        let mut fonts = FontDefinitions::default();
        fonts.font_data.insert(
            "monofont".to_owned(),
            FontData::from_static(include_bytes!("../assets/courier.ttc")),
        );
        fonts
            .families
            .entry(FontFamily::Monospace)
            .or_default()
            .insert(0, "monofont".to_owned());
        cc.egui_ctx.set_fonts(fonts);
        Self {
            input_locate: 0,
            input_text: String::new(),
            start_time: Instant::now(),
        }
    }

今回は、エディタっぽく、等幅のcourierをフォントとして登録しました。

最後に描画処理であるupdate()と、そこから呼ばれるメソッド、および関連する定数宣言を、以下のように実装します。

main.rst
impl EguiSample {
    const SPACE_LEFT: f32 = 30.0;
    const SPACE_RIGHT: f32 = 570.0;
    const LEFT_MERGIN: f32 = 5.0;
    const LETTER_WIDTH: f32 = 10.0;

    const SPACE_UPPER: f32 = 20.0;
    const SPACE_LOWER: f32 = 50.0;
    const UPPER_MERGIN: f32 = 2.0;
    const LOWER_MERGIN: f32 = 3.0;
    const CURSOR_MERGIN: f32 = 6.0;
    const CURSOR_THICKNESS: f32 = 4.0;

    const PROMPT_LETTERS: &str = "hasebe>";
    const PROMPT_LEN: usize = Self::PROMPT_LETTERS.len();
    const CURSOR_MAX_LOCATE: usize = 50;

    fn update_input_text(&mut self, ui: &mut egui::Ui) {
        // Paint input Letter Space
        ui.painter().rect_filled(
            Rect::from_min_max(pos2(Self::SPACE_LEFT,Self::SPACE_UPPER),
                               pos2(Self::SPACE_RIGHT,Self::SPACE_LOWER)),
            2.0,                              //  curve
            Color32::from_rgb(48, 48, 48)     //  color
        );
        // Paint cursor
        let cursor = self.input_locate + Self::PROMPT_LEN;
        let elapsed_time = self.start_time.elapsed().as_millis();
        if elapsed_time%500 > 200 {
            ui.painter().rect_filled(
                Rect { min: Pos2 {x:Self::SPACE_LEFT + Self::LEFT_MERGIN 
                                    + 5.0 + 9.5*(cursor as f32),
                                y:Self::SPACE_LOWER - Self::CURSOR_MERGIN},
                       max: Pos2 {x:Self::SPACE_LEFT + Self::LEFT_MERGIN 
                                    + 3.0 + 9.5*((cursor+1) as f32),
                                y:Self::SPACE_LOWER - Self::CURSOR_MERGIN + Self::CURSOR_THICKNESS},},
                0.0,                              //  curve
                Color32::from_rgb(160, 160, 160)  //  color
            );
        }
        // Draw Letters
        ui.put( // Prompt
            Rect { min: Pos2 {x:Self::SPACE_LEFT + Self::LEFT_MERGIN,
                              y:Self::SPACE_UPPER + Self::UPPER_MERGIN},
                   max: Pos2 {x:Self::SPACE_LEFT + Self::LEFT_MERGIN 
                                + Self::LETTER_WIDTH*(Self::PROMPT_LEN as f32),
                              y:Self::SPACE_LOWER - Self::LOWER_MERGIN},},
            Label::new(RichText::new(Self::PROMPT_LETTERS)
                .size(16.0).color(Color32::from_rgb(0,200,200)).family(FontFamily::Monospace))
        );
        let txtcnt = self.input_text.chars().count() + Self::PROMPT_LEN;
        ui.put( // User Input
            Rect { min: Pos2 {x:Self::SPACE_LEFT + Self::LEFT_MERGIN 
                                + Self::LETTER_WIDTH*(Self::PROMPT_LEN as f32)
                                + 3.25 - 0.25*(txtcnt as f32), // 謎の調整
                              y:Self::SPACE_UPPER + Self::UPPER_MERGIN},
                   max: Pos2 {x:Self::SPACE_LEFT + Self::LEFT_MERGIN 
                                + Self::LETTER_WIDTH*(txtcnt as f32)
                                + 3.25 - 0.25*(txtcnt as f32), // 謎の調整
                              y:Self::SPACE_LOWER - Self::LOWER_MERGIN},},
            Label::new(RichText::new(&self.input_text)
                .size(16.0).color(Color32::WHITE).family(FontFamily::Monospace))
        );
    }
}

impl eframe::App for EguiSample {
    fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
        // repaint 100msec interval
        ctx.request_repaint_after(Duration::from_millis(100));

        //  Get Keyboard Event from Egui::Context
        let evts = ctx.input().events.clone();
        let mut letters: Vec<&String> = vec![];
        for ev in evts.iter() {
            match ev {
                Event::Text(ltr) => letters.push(ltr),
                Event::Key {key,pressed, modifiers:_} => {
                    if pressed == &true { self.command_key(key);}
                },
                _ => {},
            }
        }
        if letters.len() >= 1 {self.input_letter(letters);}

        // Configuration for CentralPanel
        let my_frame = egui::containers::Frame {
            inner_margin: egui::style::Margin { left: 0., right: 0., top: 0., bottom: 0. },
            outer_margin: egui::style::Margin { left: 0., right: 0., top: 0., bottom: 0. },
            rounding: egui::Rounding { nw: 0.0, ne: 0.0, sw: 0.0, se: 0.0 },
            shadow: eframe::epaint::Shadow { extrusion: 0.0, color: Color32::BLACK },
            fill: Color32::BLACK,
            stroke: egui::Stroke::new(0.0, Color32::BLACK),
        };

        CentralPanel::default().frame(my_frame).show(ctx, |ui| {
            //  input text
            self.update_input_text(ui);
        });
    }
}

一気に表示処理を追加しましたが、中身はupdate_input_text()というメソッドに集約されています。
ここでは

  • 入力文字が描画される領域をpaint
  • 入力位置にカーソルをpaint
  • プロンプトの文字を表示
  • 入力した文字を表示

という処理の流れになっています。
最後にビルドしたときのGUI全体はこんなふうになります。
qiita12302.png

完成したサンプルコード全体は下記の関連リンクより参照ください。

終わりに

eguiで一行エディタを作ってみました。
この実装を通して、イベントの取得とKeyboard入力からの文字操作、また周期的な再描画の方法を知ることが出来ましたので、このような形で紹介させていただきました。
ここまで使ってもまだ、eguiを使いこなせるとは言えず、ネット上のわずかな情報を繋ぎ合わせながら何とか使っています。本記事が、そのわずかな情報の一つとしてお役に立てたら幸いです。

関連リンク

3
1
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
3
1