はじめに
この記事はこちらの記事の続きです。
プラグインをGUIに拡張する
前回プラグインのロジック部分が完成したので、今回はそこにUIを付け加えていきます。
設計
UIを実装するにあたって、まずはどんなウィジェット(ボタン、チェックボックス、etc)をどのように配置するのかを大まかに考えておく必要があります。
そこで今回作成するプラグインの仕様をもう一度確認しましょう。
ノートを指定したスケール(音階)にスナップさせる
前回より
スケールというのは基本的にはいくつかが既に存在していて、ユーザーが新しく作り出すということは滅多にありません。多分。
ということは、Googleの検索ボックスのようなTextInputによってスケールを決定するよりも、リストのようなものから限られたスケールを選択する方が良さそうです。
また画面下部にRunボタンを配置して、それによって最終的なスケールの決定と変更の反映を行うようにしましょう。
技術構成を考える
設計が大まかに定まったので早速コーディングしたいところですが、残念ながらRustの標準ライブラリだけではGUIを作成することは非常に困難です。
よって、GUIを作成するにあたってどのようなライブラリを使用するのかを決める必要があります。
RustにどんなGUIライブラリがあるのかはこちらの記事がとても参考になりました。
記事では
- azul
- conrod
- gtk
- iced
- OrbTk
が紹介されていました。
その中でも今回はicedを使っていこうと思います。
icedとは、Elmライクなアーキテクチャを持つ型安全なGUIライブラリです。
また先の記事のexampleの大部分ではトレイトによる継承(?)のような実装がされていました。
これにより実装しなければいけない関数が明確になり、完成までの道のりがはっきりするのもいいですね。
実装
設計と技術構成が決まったところで、いよいよ実装していきます。
そしてできたものがこちら。
ソースコード
src/main.rs
# ![windows_subsystem="windows"]
mod even_scale;
mod app;
mod style;
use std::process;
use iced::{Application,Settings};
const WINDOW_WIDTH: u32=640;
const WINDOW_HEIGHT: u32=480;
fn main(){
    let window_setting=iced::window::Settings{
        size: (WINDOW_WIDTH,WINDOW_HEIGHT),
        min_size: None,
        max_size: None,
        resizable: false,
        decorations: false,
        transparent: true,
        always_on_top: false,
        icon: None,
    };
    let settings=Settings{
        window: window_setting,
        default_font: Some(include_bytes!("../fonts/NotoSansJP-Bold.otf")),
        ..Settings::default()
    };
    if let Err(err)=app::State::run(settings){
        eprint!("Error:{}\n",err);
        process::exit(1);
    }
}
src/app.rs
use std::process;
use iced::{Align,Application,button,Button,Clipboard,Color,Column,Command,Container,Element,executor,Font,HorizontalAlignment,image,Image,Length,pick_list,PickList,Text};
use utau_rs::*;
use super::{even_scale::*,style::*};
# [derive(Default)]
pub struct State{
    selected_scale: Option<Scale>,
    uta_sections: UtaSections,
    pick_list: pick_list::State<Scale>,
    run: button::State,
    exit: bool,
}
# [derive(Clone,Debug)]
pub enum Message{
    ScaleSelect(Scale),
    Run,
}
impl Application for State{
    type Executor=executor::Default;
    type Message=Message;
    type Flags=();
    fn new(_flags: ())->(Self,Command<Self::Message>){
        (Self::default(),Command::none())
    }
    fn title(&self)->String{
        String::from("even scale")
    }
    fn update(&mut self,message: Self::Message,_clipboard: &mut Clipboard)->Command<Self::Message>{
        match message{
            Message::ScaleSelect(scale)=>self.selected_scale=Some(scale),
            Message::Run=>{
                let selected_scale=match self.selected_scale{
                    Some(some)=>some,
                    None=>{
                        self.exit=true;
                        return Command::none();
                    }
                };
                if let Err(err)=even_scale(&mut self.uta_sections,selected_scale){
                    eprint!("Error:{}\n",err);
                    process::exit(1);
                }
                if let Err(err)=self.uta_sections.write(){
                    eprint!("Error:{}\n",err);
                    process::exit(1);
                }
                self.exit=true;
            }
        }
        Command::none()
    }
    fn view(&mut self)->Element<Message>{
        let comment_text=Text::new("↓choose scale that you want↓")
            .font(Font::External{name: "BRADHITC",bytes: include_bytes!("../fonts/karakaze-R.otf")})
            .size(30)
            .horizontal_alignment(HorizontalAlignment::Center);
        let scale_list=PickList::new(
            &mut self.pick_list,
            &Scale::ALL[..],
            self.selected_scale,
            Message::ScaleSelect,
        )
            .text_size(20)
            .style(PickList);
        let image=Container::new(Image::new(image::Handle::from_path("resource/background0.png")))
            .align_x(Align::Center)
            .align_y(Align::Center);
        let run_button=Button::new(
            &mut self.run,
            Text::new("Run")
                .size(30)
                .horizontal_alignment(HorizontalAlignment::Center)
        )
            .width(Length::Shrink)
            .height(Length::Shrink)
            .min_width(80)
            .style(Button)
            .on_press(Message::Run);
        let contents=Column::new()
            .align_items(Align::Center)
            .push(comment_text)
            .spacing(10)
            .push(scale_list)
            .push(image)
            .push(run_button);
        Container::new(contents)
            .width(Length::Fill)
            .height(Length::Fill)
            .center_x()
            .center_y()
            .style(Container)
            .into()
    }
    fn background_color(&self)->Color{
        Color::TRANSPARENT
    }
    fn should_exit(&self)->bool{
        self.exit
    }
}
src/style.rs
use iced::{Background,button,Color,container,pick_list};
const CONTAINER_R: u8=0x36;
const CONTAINER_G: u8=0x39;
const CONTAINER_B: u8=0x3F;
const BUTTON_R: u8=0xFF;
const BUTTON_G: u8=0x65;
const BUTTON_B: u8=0x1C;
const TEXT_R: u8=0xDE;
const TEXT_G: u8=0xDE;
const TEXT_B: u8=0xDE;
pub struct PickList;
impl pick_list::StyleSheet for PickList{
    fn menu(&self)->pick_list::Menu{
        pick_list::Menu::default()
    }
    fn active(&self)->pick_list::Style{
        pick_list::Style{
            text_color: Color::from_rgb8(CONTAINER_R,CONTAINER_G,CONTAINER_B),
            icon_size: 0.5,
            ..pick_list::Style::default()
        }
    }
    fn hovered(&self)->pick_list::Style{
        pick_list::Style{
            border_color: Color::BLACK,
            ..self.active()
        }
    }
}
pub struct Button;
impl button::StyleSheet for Button{
    fn active(&self)->button::Style{
        button::Style{
            background: Some(Background::Color(Color::from_rgb8(BUTTON_R,BUTTON_G,BUTTON_B))),
            border_radius: 10.0,
            text_color: Color::from_rgb8(CONTAINER_R,CONTAINER_G,CONTAINER_B),
            ..button::Style::default()
        }
    }
    fn hovered(&self)->button::Style{
        button::Style{
            border_width: 1.0,
            border_color: Color::WHITE,
            ..self.active()
        }
    }
    fn pressed(&self)->button::Style{
        let active=self.active();
        let hovered=self.hovered();
        button::Style{
            background: Some(Background::Color(active.text_color)),
            border_width: hovered.border_width,
            border_color: hovered.border_color,
            text_color: match active.background{
                Some(Background::Color(some))=>some,
                None=>panic!("Error<{}:{}>:不明なエラーが発生しました.\n",file!(),line!()),
            },
            ..active
        }
    }
}
pub struct Container;
impl container::StyleSheet for Container{
    fn style(&self)->container::Style{
        container::Style{
            text_color: Some(Color::from_rgb8(TEXT_R,TEXT_G,TEXT_B)),
            background: Some(Background::Color(Color::from_rgb8(CONTAINER_R,CONTAINER_G,CONTAINER_B))),
            border_radius: 10.0,
            ..container::Style::default()
        }
    }
}
src/even_scale.rs
use utau_rs::*;
# [allow(non_camel_case_types)]
# [derive(Clone,Copy,Debug,Eq,PartialEq)]
pub enum Scale{
    C,C_sharp,Cm,Cm_sharp,
    D,D_sharp,Dm,Dm_sharp,
    E,E_sharp,Em,Em_sharp,
    F,F_sharp,Fm,Fm_sharp,
    G,G_sharp,Gm,Gm_sharp,
    A,A_sharp,Am,Am_sharp,
    B,B_sharp,Bm,Bm_sharp,
}
impl Scale{
    pub const ALL: [Scale;28]=[
        Scale::C,Scale::C_sharp,Scale::Cm,Scale::Cm_sharp,
        Scale::D,Scale::D_sharp,Scale::Dm,Scale::Dm_sharp,
        Scale::E,Scale::E_sharp,Scale::Em,Scale::Em_sharp,
        Scale::F,Scale::F_sharp,Scale::Fm,Scale::Fm_sharp,
        Scale::G,Scale::G_sharp,Scale::Gm,Scale::Gm_sharp,
        Scale::A,Scale::A_sharp,Scale::Am,Scale::Am_sharp,
        Scale::B,Scale::B_sharp,Scale::Bm,Scale::Bm_sharp,
    ];
}
impl Default for Scale{
    fn default()->Self{
        Scale::C
    }
}
impl std::fmt::Display for Scale{
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>)->std::fmt::Result{
        write!(f,"{}",match self{
            Scale::C=>"C",Scale::C_sharp=>"C#",Scale::Cm=>"Cm",Scale::Cm_sharp=>"C#m",
            Scale::D=>"D",Scale::D_sharp=>"D#",Scale::Dm=>"Dm",Scale::Dm_sharp=>"D#m",
            Scale::E=>"E",Scale::E_sharp=>"E#",Scale::Em=>"Em",Scale::Em_sharp=>"E#m",
            Scale::F=>"F",Scale::F_sharp=>"F#",Scale::Fm=>"Fm",Scale::Fm_sharp=>"F#m",
            Scale::G=>"G",Scale::G_sharp=>"G#",Scale::Gm=>"Gm",Scale::Gm_sharp=>"G#m",
            Scale::A=>"A",Scale::A_sharp=>"A#",Scale::Am=>"Am",Scale::Am_sharp=>"A#m",
            Scale::B=>"B",Scale::B_sharp=>"B#",Scale::Bm=>"Bm",Scale::Bm_sharp=>"B#m",
        })
    }
}
pub struct KeyTone([u32;7]);
const C: u32=24;
const D: u32=26;
const E: u32=28;
const F: u32=29;
const G: u32=31;
const A: u32=33;
const B: u32=35;
impl KeyTone{
    pub fn new(scale: &Scale)->Result<KeyTone,&'static str>{
        Ok(match scale{
            Scale::C      |Scale::Am      =>KeyTone([C,D,E,F,G,A,B]),
            Scale::C_sharp|Scale::Am_sharp=>KeyTone([C+1,D+1,E+1,F+1,G+1,A+1,B+1]),
            Scale::D      |Scale::Bm      =>KeyTone([C+1,D,E,F+1,G,A,B]),
            Scale::D_sharp|Scale::Cm      =>KeyTone([C,D,E-1,F,G,A-1,B-1]),
            Scale::E      |Scale::Cm_sharp=>KeyTone([C+1,D+1,E,F+1,G+1,A,B]),
            Scale::F      |Scale::Dm      =>KeyTone([C,D,E,F,G,A,B-1]),
            Scale::F_sharp|Scale::Dm_sharp=>KeyTone([C+1,D+1,E+1,F+1,G+1,A+1,B]),
            Scale::G      |Scale::Em      =>KeyTone([C,D,E,F+1,G,A,B]),
            Scale::G_sharp|Scale::Fm      =>KeyTone([C,D-1,E-1,F,G,A-1,B-1]),
            Scale::A      |Scale::Fm_sharp=>KeyTone([C+1,D,E,F+1,G+1,A,B]),
            Scale::A_sharp|Scale::Gm      =>KeyTone([C,D,E-1,F,G,A,B-1]),
            Scale::B      |Scale::Gm_sharp=>KeyTone([C+1,D+1,E,F+1,G+1,A+1,B]),
            _=>return Err("不明なエラーが発生しました."),
        })
    }
    fn unwrap(&self)->[u32;7]{
        match self{
            &KeyTone(some)=>some,
        }
    }
}
pub fn even_scale(uta_sections: &mut UtaSections,scale: Scale)->Result<(),&'static str>{
    let tones=KeyTone::new(&scale).unwrap();
    for section in uta_sections.sections.iter_mut(){
        if tones.unwrap().iter().all(|&x|(x%section.note_num)!=0){
            let mut near=section.note_num as i32-tones.unwrap()[0] as i32;
            for tone in tones.unwrap(){
                let tone=match section.note_num{
                    24..=35=>tone+12*0,
                    36..=47=>tone+12*1,
                    48..=59=>tone+12*2,
                    60..=71=>tone+12*3,
                    72..=83=>tone+12*4,
                    84..=95=>tone+12*5,
                    96..=107=>tone+12*6,
                    _=>return Err("不明なエラーが発生しました."),
                };
                if (section.note_num as i32-tone as i32).abs()<near{
                    near=(section.note_num as i32-tone as i32).abs();
                }
            }
            section.note_num=(section.note_num as i32+near) as u32;
        }
    };
    Ok(())
}
疲れました(本音
気をとりなしてコードの説明をしていきます。
今回は前回と違い、icedの説明も交えながらいきます。
解説
src/main.rs
# ![windows_subsystem="windows"]
DOS窓を表示しないように設定します。
ちなみにDOS窓というのは、いわゆるコマンドプロンプトみたいなウィンドウのことを指します。
これをしないと、プラグインを実行したときに空黒空白のDOS窓とGUIの二つのウィンドウが表示されることになります。
let window_setting=iced::window::Settings{/*省略*/}
ウィンドウの設定情報を変数に束縛します。
今回ウィンドウを伸縮不可のタイルライクなものにしたかったので、sizeとresizableとdecorationsを設定していきます。
let settings=Settings{/*省略*/}
実行に必要な設定情報を変数に束縛します。
今回はicedで日本語表示を可能にするためにデフォルトで使用するフォントを指定し、先程指定したウィンドウについての設定を追加し、あとの設定は..Settings::default()で片づけます。
..○○::default()ってめちゃくちゃ便利ですね。びっくりしました。
if let Err(err)=app::State::run(settings){/*省略*/}
先程の設定情報をもとにプラグインを実行します。
icedを用いたGUIアプリケーションでは、main()は基本的にこの実行のみを請け負います。
後述するshould_exit()次第ではrun()の後に他の処理を実装することも可能ですので、ケースバイケースでいきましょう。
src/app.rs
# [derive(Default)]
pub struct State{/*省略*/}
各ウィジェットの状態を格納する変数や実行に関連する変数を、ひとつのオブジェクトとして定義します。
#[derive(Default)]でDefaultトレイトを実装しておくと便利です。
しかし型の中には手動でDefaultトレイトを実装しなければならないものもあるので注意しましょう。
# [derive(Clone,Debug)]
pub enum Message{/*省略*/}
ボタンを押したときやリストを選択したときなどに送受信されるMessage列挙体を定義します。
CloneトレイトやDebugトレイトは後の関数で要求されるため、deriveで実装する必要があります。
impl Application for State{/*省略*/}
ApplicationトレイトをState構造体に実装します。
この実装が完了するとState::run()を使用できるようになります。
ちなみにこのApplicationトレイトに似たものにSandboxトレイトがありますが、これはApplicationトレイトの機能制限版になります。
Applicationトレイトよりも軽量な半面、should_exit()のような関数は実装できません。
はじめはSandboxトレイトで実装を進め、必要があればApplicationトレイトに移行しましょう。
IDEにもよりますが、移行は関数の引数と返り値を若干変えるだけなので2、3分程度で完了します。
fn new(_flags: ())->(Self,Command<Self::Message>){/*省略*/}
Stateの初期値を定義します。
ここで返り値は(Self,Command<Self::Message>)というタプルになりますが、このCommand<Self::Message>は非同期処理用のものです。
今回非同期処理を実装する必要はないので、Command::none()を返します。
ちなみに他の非同期処理用の関数にはsubscription()があります。
Commandとsubscription()の詳しい説明については以下の記事が参考になりました。
https://sukawasatoru.com/docs/2021-02-13-iced
fn update(&mut self,message: Self::Message,_clipboard: &mut Clipboard)
    ->Command<Self::Message>{/*省略*/}
後述のview()等で何らかのMessageが送られたときに任意の処理を実行します。
基本的には
match message{
    Message::ScaleSelect(scale)=>/*省略*/,
    Message::Run=>/*省略*/,
}
上記のように、引数のmassageに対してパターンマッチングを行い、各Messageについての処理を実装します。
今回はScaleSelect(scale)の場合にはself.selected_scale=scaleをし、Runの場合にはeven_scale()と.write()を行うようにしました。
fn view(&mut self)->Element<Message>{/*省略*/}
ウィンドウにウィジェットやテキストをどのように配置するのかを定義します。
自分なりのicedでウィジェットをいい感じにコーディングするためのコツですが、まずウィジェット毎に変数を用意するといいと思います。
この時点で.style()や.size()のようなメソッドは付けておくと、次のColumn::new()で.push()するだけでよくなります。
またColumn::new()では.spacing()などのメソッドでウィジェット間の縦方向のスペースを決定することができます。
ウィジェットを横に並べたい場合には
.push(
    Row::new()
        .push(some_widget)
)
上記をColumn::new()のメソッドチェーンに付け加えます。
一通りColumn::new()にメソッドを付け加えたら、それを束縛する変数を引数としてContainer::new(some_contents)をします。
これはview()の返り値となるので、;は付けません。
またContainer::new()のメソッドチェーンに
Container::new(contents)
    .width(Length::Fill)
    .height(Length::Fill)
    .center_x()
    .center_y()
    .style(Container)
    .into()
上記を加えておくと、ウィジェットが画面中央に配置されて見栄えがよくなります。
.style()は後述のcontainer::StyleSheetトレイトを実装しているならば付けておきましょう。
メソッドチェーンの最後に.into()を付け加えるのも忘れずに。
fn should_exit(&self)->bool{
    self.exit
}
self.exitの値によって、run()を終了するべきかそうでないかを判断します。
update()等でself.exitにtrueを代入することでrun()を終えつつmain()は続かせるのが主な使い道だと思います。
src/style.rs
impl pick_list::StyleSheet for PickList{/*省略*/}
pick_listのスタイルを設定します。
アクティブ時の場合やホバー時の場合など、ウィジェットの状態別にスタイルを設定できます。
icedのCSSのようなものだと思っていただいて多分間違いないでしょう。
またここでStyleSheetを実装した構造体を先述の.style()の引数とすることで、そのウィジェットにスタイルを適用することができます。
button、containerについても同様の説明ができるので省略します。
完成!!!
長い戦いでした。
GUI作成自体が初めてであるとはいえ、結構完成までに遠回りをしました。
最後に完成品のスクショを貼っておきます。
本当は五線譜のところで選択したスケールの構成音を表示したりとかもしたかったのですが、流石にそこまでする気力は残っていませんでした。
これはまた次の機会のアイディアとして残しておこうと思います。
おわりに
API作成編からGUI作成編までの三部構成だった記事もようやっと終わりです。
初めての記事執筆でしたが、自分の理解していることの再確認をするにはもってこいの場でした。
この記事でRustでUTAUプラグインを作成する方が一人でも増えたり、icedの実装で困っている方が少しでも解決へと繋がってくれたら、本当に嬉しい限りです。
また何かあればQiitaに記事を投稿するつもりです。
その時はまたよしなに(?)していただければと思います。
またね。
