いまさらGTKとRustでLinuxデスクトップアプリ入門

概要

いまさらですが、はじめてLinuxのデスクトップアプリを作成したので、備忘の意味も含めてその手順を書きます。

使うツール

GUIツールキットはGTK+を使います。Qtでも良かったのですが、環境整備やチュートリアルがシンプルで手っ取り早く始められそうな感じがしたのでGTK+を選びました。

また、言語はRustにしました。Cだと目新しさもないので、他の言語を使って書きたかったからです。
また、GTK+のバイディングのサポート状況を見て、GTK+3を完全にサポートしている言語のうちVMを使わない言語で
今後も使われていきそうなものを選びました。
(完全に直感です。C++は学習自体辛そうなので避けました。)

前提

この記事を読むにあたり、GTK+の知識は不要ですが、Rustの入門程度は済ませておいた方が良いです。
Rust自体の説明は殆どしないつもりです。1

作るアプリ

最初なのでシンプルなテキストエディタを作ります。Windowsのメモ帳と全く同じだとつまらないのでタブ型のUIにします。
アプリ名はVanilla Text2としておきます。

PrtSc_VanillaText.png

環境

本記事では以下の環境を使っています。

OS: Ubuntu 16.04 LTS
言語: Rust 1.16.0 (https://www.rust-lang.org/)
GTK+ライブラリ: GTK+ 3.18 (https://www.gtk.org/)
Rustバインディング: Gtk-rs 0.2.0 (http://gtk-rs.org/)

Ubuntuの場合、Rustは公式サイトからダウンロードしてインストールするのが簡単です。
GTK+は、以下のようにaptでインストールできます。

~$ sudo apt install libgtk-3-dev

Gtk-rsは、後ほどCargoで自動的にインストールします。

Hello Gtk-rs

では早速始めます。
まずは、Cargoを使って作業環境を作成します。アプリケーションを作成するのでbinary template(--bin)を指定します。

~$ cargo new --bin vanilla_text
     Created binary (application) `vanilla_text` project
~$ cd vanilla_text/
~/vanilla_text$ tree
.
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

次に、Gtk-rsを使うために、Cargo.tomlに依存関係を記述します。
Gtk-rsのバージョンは0.2.0で、GTK+はバージョン3.16相当の機能を使う為、以下のようにします。

Cargo.toml
[package]
name = "vanilla_text"
version = "0.1.0"
authors = ["Your Name <Your Email Address>"]

[dependencies]
gio= "0.2.0"

[dependencies.gtk]
version = "0.2.0"
features = ["v3_16"] 

それでは、画面にウィンドウを表示させてみます。
main.rsを以下のように編集します。

main.rs
extern crate gtk;
extern crate gio;

use gtk::{ WidgetExt, WindowExt };
use gio::{ ApplicationExt };

fn main() {
    //アプリケーションを生成する
    match gtk::Application::new("com.github.koji-m.vanilla_text", gio::APPLICATION_HANDLES_OPEN) {
        Ok(app) => {
            //アプリケーションへ、activateシグナルに対するハンドラを設定する
            app.connect_activate(|app| {
                //ウィンドウを生成する
                let win = gtk::ApplicationWindow::new(&app);
                //ウィンドウのタイトルに表示する文字列を設定する
                win.set_title("Hello Gtk-rs");
                //ウィンドウとその中身全てを可視状態にする
                win.show_all();
            });

            //アプリケーションを開始、アプリケーションへactivateシグナルがエミットされる
            app.run(&[""]);
        },
        Err(_) => {
            println!("Application start up error");
        }
    };
}

Cargoでアプリケーションをコンパイル・実行します。

~/vanilla_text$ cargo run

以下のように表示されれば成功です。
PrtSc_Hello_Gtk-rs.png

GTK+以外にも、extern crate gioでGIO crateを利用しています。GIOは、GNOMEのVFS APIやデスクトップアプリケーションの為のAPIを提供するものです。GTK+は、このGIOやオブジェクトシステムを提供するGObjectなどのライブラリの上に構築されているライブラリです。

main関数では最初に、gtk::Application::new()GtkApplicationのインスタンスを生成しています。このGtkApplicationがアプリケーション自体を表すクラスとなります。

ここで、クラスという表現を使いましたが、GTK+はクラスとその継承を使ったオブジェクト指向ベースのライブラリとなっています。

GTK+のAPIリファレンスを見ると、GtkApplicationのクラス階層は以下のようになっています。

GObject
└── GApplication
    └── GtkApplication

GApplicationは、GtkApplicationなどのアプリケーションを表すクラスの基礎となる機能を持ったGIOのクラスとなります。

コードの説明に戻ります。gtk::Application::new()は、第一引数にアプリケーションID(アプリケーションを一意に識別する文字列)、第二引数にはアプリケーションフラグ(アプリケーションの挙動を指定するフラグ)を受け取り、Result<gtk::Application, glib::error::BoolError>を返すので、パターンマッチでその値を取り出しています。Okのケースで、実際にアプリケーションを実行しています。

流れとしては、最初にapp.connect_activate()GtkApplicationへactivateシグナルに対するハンドラを定義し、最後にapp.run()でアプリケーションの実行を開始しています。

GTK+のオブジェクトの多くはシグナルを受け取ることができ、また、シグナルを受け取った時に実行するハンドラを定義することができます。

今回の場合は、app.run()を実行すると、appに対してactivateシグナルを送信されます。GtkApplicationはactivateシグナルの受信をもってアプリケーションのメイン処理を開始することになるので、app.connect_activate()の引数にactivateシグナルに対するハンドラとしてメイン処理を含むクロージャを渡しています。

ハンドラの引数には、activateシグナルを受信したgtk::Applicationのインスタンスが渡されます。

メイン処理(クロージャの中)では、gtk::ApplicationWindow::new()で、アプリケーションのウィンドウを表すGtkApplicationWindowオブジェクトを生成し、win.set_title()でウィンドウのタイトルバーに表示する文字列を設定しています。

ウィンドウのオブジェクトは生成しただけでは画面には表示されない為、最後にwin.show_all()でオブジェクトを可視状態にしています。

説明を飛ばしましたが、冒頭でいくつかトレイトをスコープに取り込んでいます。gtk::WindowExtは、set_title()メソッドの為に、gtk::WidgetExtshow_all()メソッドの為に、そしてgio::ApplicationExtrun()メソッドの為に取り込んでいます。

Gtk-rsでは、構造体「***」に対するメソッドは、「***Ext」という名前のトレイトに定義されています。

例えば、gtk::Window構造体に対するメソッドは、gtk::WindowExtトレイトに定義されています。
(但し、gtk::Applicationに対するメソッドは、gtk::GtkApplicationExtトレイトに定義されています。)

gtk::Applicationは、gio::ApplicationExtトレイトも実装しており、そのrun()メソッドを呼び出しています。

エディタ領域を作成する

次に、エディタの編集領域を実装します。

GTK+では、GUIを構成する要素をWidgetと呼びます。例えば、先ほど作成したウィンドウgtk::ApplicationWindowはWidgetです。また、ウィンドウ内に配置するボタンgtk::Buttonや、文字列を表示するgtk::Label等もWidgetです。

アプリケーションのGUIを構成するには、ウィンドウを起点に、その子要素として各種Widgetを追加していくことで実現します。例えば、ウィンドウ内にボタンを1つ設置したい場合は、gtk::ApplicationWindowに子要素としてgtk::Buttonを追加します。

今回はウィンドウ内にテキスト編集領域を設置したいので、テキスト編集領域のオブジェクトとしてgtk::TextViewgtk::ApplicationWindowの子要素として追加すれば良いように思います。しかし、このオブジェクトだけでは、ウィンドウのスクロール機能がありませんので、gtk::ScrolledWindowオブジェクトに包んでウィンドウの子要素に追加します。
Widgetの親子関係は以下になります。

gtk::ApplicationWindow
└── gtk::ScrolledWindow
    └── gtk::TextView

これを実装したコードは以下になります。(以降、変更部分のみ抜粋)

main.rs
...
// ContainerExtトレイトを使用するので取り込んでおく
use gtk::{ WidgetExt, WindowExt, ContainerExt };
...
...
        Ok(app) => {
            app.connect_activate(|app| {
                let win = gtk::ApplicationWindow::new(&app);
                //ウィンドウのデフォルトサイズを幅800ピクセル、高さ600ピクセルに指定
                win.set_default_size(800, 600);
                win.set_title("Vanilla Text"); 
                //スクロール可能な領域を生成
                let scr_win = gtk::ScrolledWindow::new(None, None);
                //テキスト編集領域を生成
                let txt_view = gtk::TextView::new();
                //TextViewをScrolledWindowの子要素として追加
                scr_win.add(&txt_view);
                //ScrolledWindowをApplicationWindowの子要素として追加
                win.add(&scr_win);
                win.show_all();
            });

            app.run(&[""]);
        },
...

cargo runを実行して以下のようにテキストの入力やウィンドウのスクロールができることを確認します。
PrtSc_TextView.png

編集コマンドを実装する

TextViewを設置しただけではコピペ等の編集コマンドは使えませんので、これらの機能を実装していきます。

まずは、コピーとペーストを実装します。
メニューバーから[Edit]-[Copy]、[Edit]-[Paste]のように選択して、コピー・ペーストができるようにします。
各メニューアイテムを選択した時に、コピーやペーストを実行させるシグナルをウィンドウに送信する必要があります。

ここで、シグナルとハンドラを紐付けるActionインターフェイス(今回はその具象クラスのSimpleAction)を使います。SimpleActionオブジェクトへ、activateシグナルに対するハンドラを紐付け、ウィンドウにこのSimpleActionを設定します。

main.rs
...
//クリップボードを扱う為GDKを使用
extern crate gdk;
...
//必要なトレイトを追加
use gtk::{ ...
           TextViewExt, TextBufferExt
};

use gio::{ ...
           SimpleActionExt, ActionMapExt
};

...
        Ok(app) => {
            app.connect_activate(|app| {
                ...
                //CopyコマンドのActionをアクション名"copy"として生成
                let copy_action = gio::SimpleAction::new("copy", None);
                {
                    let txt_view = txt_view.clone();
                    //activateシグナルに対するハンドラを定義
                    copy_action.connect_activate(move |_, _| {
                        //アプリケーションのデフォルトクリップボードを取得
                        let clipboard = txt_view.get_clipboard(&gdk::SELECTION_CLIPBOARD);
                        //TextViewのTextBufferから現在選択している部分の内容をクリップボードにコピー
                        txt_view.get_buffer().unwrap().copy_clipboard(&clipboard);
                    });
                }

                //PasteコマンドのActionをアクション名"paste"として生成
                let paste_action = gio::SimpleAction::new("paste", None);
                {
                    let txt_view = txt_view.clone();
                    paste_action.connect_activate(move |_, _| {
                        let clipboard = txt_view.get_clipboard(&gdk::SELECTION_CLIPBOARD);
                        let buf = txt_view.get_buffer().unwrap();
                        //TextBufferのカーソル位置又は選択部分にクリップボードの内容をペースト
                        buf.paste_clipboard(&clipboard, None, txt_view.get_editable());
                    });
                }

                //ウィンドウにActionを設定
                win.add_action(&copy_action);
                win.add_action(&paste_action);
                ...

GDKを使うので、Cargo.tomlの[dependencies]gdk = "0.6.0"を追加しておいて下さい。

activateシグナルに対するハンドラ内では、先ほど生成したTextViewオブジェクトを使用するので、クロージャでtxt_viewをキャプチャします。このクロージャはstaticライフタイムを持つことになるので、キャプチャする値はmoveします。その為、事前にtxt_viewのコピーを作成し、そのコピーをキャプチャしています。

Gtk-rsが提供するWidgetのclone()によるコピーは、内部的にはオブジェクトへのポインターのコピーなのでborrowチェックをパスし安全に扱えます。(コピーのコストも低い)

これで、ウィンドウに対して2つのアクションを設定できました。

次に、メニューバーからコピー・ペーストを実行できるようにメニューバーを作成します。
メニューバーは、サブメニューとして[Edit]を持ち、[Edit]サブメニューはメニューアイテムとして[Copy]と[Paste]を持つ構成となります。各メニューアイテムにアクション名を指定する事で、選択時にそのアクションへactivateシグナルを送信します。

main.rs
...
use gtk::{ ...
           GtkApplicationExt
};

use gio::{ ...
           MenuExt
};
...
        Ok(app) => {
            app.connect_activate(|app| {
            ...
                //gio::Menu、MenuItemを一時的に使いたい為スコープを作っておく(必須ではない)
                {
                    use gio::{Menu, MenuItem};

                    //メニューバーを表すオブジェクトを生成
                    let menubar = Menu::new();
                    //Editサブメニューを表すオブジェクトを生成
                    let submenu_edit = Menu::new();
                    //メニュー表示文字列"Copy"、対応するアクション名"win.copy"でメニューアイテム作成
                    let copy = MenuItem::new("Copy", "win.copy");
                    //同様に"Paste"のメニューアイテム作成
                    let paste = MenuItem::new("Paste", "win.paste");

                    //Editサブメニューにメニューアイテムを設定
                    submenu_edit.append_item(&copy);
                    submenu_edit.append_item(&paste);

                    //メニューバーにEditサブメニューを設定
                    menubar.append_submenu("Edit", &submenu_edit);

                    //アプリケーションにメニューバーを設定
                    app.set_menubar(&menubar);
                }
...

ActionActionGroupを使って特定のグループに分類することができます。ActionGroupは、その名前とそのグループに属するActionで構成されます。
GtkApplicationWindowオブジェクトは内部にActionGroupを持っており、そのActionGroupの名前は"win"として登録されます。

MenuItem::new()で指定するアクションは"<プレフィクス>.<アクション>"という形式で指定しますがこの<プレフィクス>にはActionGroupの名前を指定し、<アクション>にActionオブジェクトのアクション名を指定します。
(よってコピーのアクションは"win.copy")

こんな感じでメニューバーができました。
PrtSc_copy_paste.png

新規ウィンドウ作成

メニューバーから[File]-[New window]を選択して、新規にウィンドウを作成できるようにします。

新規に作成されるウィンドウはアプリケーション起動時のウィンドウと同じまっさらなウィンドウにするのでウィンドウのUI作成とアクション設定のコードをcreate_window()関数として再利用できるようにします。
また、ついでに[File]-[Quit]でアプリケーションを終了できるようにしておきます。

以上の変更を加えたここまでの全コードをは以下になります。

main.rs
extern crate gtk;
extern crate gio;
extern crate gdk;

use gtk::{ WidgetExt, WindowExt, ContainerExt,
           GtkApplicationExt, TextViewExt, TextBufferExt            
};

use gio::{ ApplicationExt, MenuExt, SimpleActionExt,
           ActionMapExt
};

fn main() {
    match gtk::Application::new("com.github.koji-m.vanilla_text", gio::APPLICATION_HANDLES_OPEN) {
        Ok(app) => {
            app.connect_activate(|app| {
                let new_window_action = gio::SimpleAction::new("new_window", None);
                {
                    let app = app.clone();
                    new_window_action.connect_activate(move |_, _| {
                        create_window(&app);
                    });
                }

                let quit_action = gio::SimpleAction::new("quit", None);
                {
                    let app = app.clone();
                    quit_action.connect_activate(move |_, _| {
                        app.quit();
                    });
                }

                app.add_action(&new_window_action);
                app.add_action(&quit_action);

                {
                    use gio::{Menu, MenuItem};

                    let menubar = Menu::new();

                    let submenu_file = Menu::new();
                    let newwindow = MenuItem::new("New Window", "app.new_window");
                    let quit = MenuItem::new("Quit", "app.quit");

                    submenu_file.append_item(&newwindow);
                    submenu_file.append_item(&quit);

                    let submenu_edit = Menu::new();
                    let copy = MenuItem::new("Copy", "win.copy");
                    let paste = MenuItem::new("Paste", "win.paste");

                    submenu_edit.append_item(&copy);
                    submenu_edit.append_item(&paste);

                    menubar.append_submenu("File", &submenu_file);
                    menubar.append_submenu("Edit", &submenu_edit);

                    app.set_menubar(&menubar);
                }


                create_window(&app);

            });

            app.run(&[""]);
        },
        Err(_) => {
            println!("Application start up error");
        }
    };
}

fn create_window(app: &gtk::Application) -> gtk::ApplicationWindow {
    let win = gtk::ApplicationWindow::new(app);
    win.set_default_size(800, 600);
    win.set_title("Vanilla Text");
    let scr_win = gtk::ScrolledWindow::new(None, None);
    let txt_view = gtk::TextView::new();
    scr_win.add(&txt_view);
    win.add(&scr_win);

    let copy_action = gio::SimpleAction::new("copy", None);
    {
        let txt_view = txt_view.clone();
        copy_action.connect_activate(move |_, _| {
            let clipboard = txt_view.get_clipboard(&gdk::SELECTION_CLIPBOARD);
            txt_view.get_buffer().unwrap().copy_clipboard(&clipboard);
        });
    }

    let paste_action = gio::SimpleAction::new("paste", None);
    {
        let txt_view = txt_view.clone();
        paste_action.connect_activate(move |_, _| {
            let clipboard = txt_view.get_clipboard(&gdk::SELECTION_CLIPBOARD);
            let buf = txt_view.get_buffer().unwrap();
            buf.paste_clipboard(&clipboard, None, txt_view.get_editable());
        });
    }

    win.add_action(&copy_action);
    win.add_action(&paste_action);

    win.show_all();

    win
}

ファイルを編集・保存できるようにする

まだファイルを開いて編集して保存という重要な機能がありませんのでここで実装していきます。

まずは、ファイルを開く機能ですが、メニューバーから[File]-[Open]を選択するとファイル選択のダイアログウィンドウが表示され、そこで開くファイルを選択するという、よくある流れを実装します。

main.rs
use gtk::{ ...
           DialogExt, FileChooserExt, BinExt, Cast
};

use gio::{ ...
           FileExt
};

...

        Ok(app) => {
            app.connect_activate(|app| {
            ...
                //Openコマンドアクションを生成
                let open_action = gio::SimpleAction::new("open", None);
                {
                    let app = app.clone();
                    //ファイル選択ダイアログを起動して選択されたファイルの内容をウィンドウ内に表示
                    open_action.connect_activate(move |_, _| {
                        if let Some(file) = run_file_chooser_dialog() {
                            open(file, app.clone());
                        }
                    });
                }
            ...
                app.add_action(&open_action);
            ...
                {
                 ...
                    let open = MenuItem::new("Open", "app.open");
                    ...
                    submenu_file.append_item(&open);

...

//ファイル選択ダイアログ表示し、選択されたファイルをGFileオブジェクトとして返す
fn run_file_chooser_dialog() -> Option<gio::File> {
    //GtkFileChooserDialogオブジェクトを生成
    let dialog = gtk::FileChooserDialog::new::<gtk::Window>(Some("Open File"),
                                     None,
                                     gtk::FileChooserAction::Open);
    //ファイル選択ダイアログに"Open"ボタンと"Cancel"ボタンを追加
    dialog.add_button("Cancel", gtk::ResponseType::Cancel.into());
    dialog.add_button("Open", gtk::ResponseType::Accept.into());

    let file;
    //ファイル選択ダイアログを表示しユーザの入力を受け取る
    if dialog.run() == gtk::ResponseType::Accept.into() {
        //ユーザが"Open"ボタンをクリックした場合は選択したファイルのパスを取得する
        if let Some(path) = dialog.get_filename() {
            //ファイルのパスからGFileオブジェクトを生成する
            file = Some(gio::File::new_for_path(path.as_path()))
        } else {
            file = None
        }
    } else {
        file = None
    }

    //ファイル選択ダイアログを閉じる
    dialog.destroy();

    file
}

//ファイルを開いてウィンドウのタイトルバーにファイル名を設定する
fn open(file: gio::File, app: gtk::Application) {
    //新規にウィンドウを生成
    let win = create_window(&app);

    //ファイル読み込み
    load_file(file.clone(), win.clone());
    //ウィンドウのタイトルバーにファイル名を設定
    win.set_title(file.get_basename().unwrap().to_str().unwrap());
}

//ファイルをウィンドウのTextViewに読み込む
fn load_file(file: gio::File, win: gtk::ApplicationWindow) {
    //GFileオブジェクトからファイルの内容をファイルの内容(型はVec<u8>)を読み込む
    if let Ok((v, _)) = file.load_contents(None) {
        //Vec<u8>からStringへ変換
        let text = String::from_utf8(v).unwrap();

        //winから子要素を辿ってTextBufferを取り出す
        let scr_win = win.get_child().unwrap().downcast::<gtk::ScrolledWindow>().ok().unwrap();
        let txt_view = scr_win.get_child().unwrap().downcast::<gtk::TextView>().ok().unwrap();
        let buf = txt_view.get_buffer().unwrap();

        //TextBufferにファイルの内容(型はString)を読み込む
        buf.set_text(&text);
    }

}

ファイル選択ダイアログはGtkFileChooserDialogオブジェクトになります。このオブジェクトにボタンを設置し、各ボタンに対応するGtkResponseTypeによってファイルを読み込むのか何もしないのかを判断します。GtkFileChooserDialogは、run()メソッドでダイアログウィンドウを起動し、ユーザがボタンをクリックすることでGtkResponseTypeを返します。この時点で、GtkFileChooserDialogオブジェクトにはユーザが選択したファイルのパスを内部で保持しているので、get_filename()メソッドでそれを取得します。

尚、GtkFileChooserDialogは、ユーザがボタンをクリックしただけでは閉じないので、結果を受け取った後destroy()メソッドで閉じます。

実際にウィンドウ内のGtkTextViewにファイルの内容を読み込むには、GtkTextView内のGtkTextBufferに内容を読み込みます。

次に、ウィンドウ内のテキストをファイルに保存する機能を実装します。

ファイルへの保存は、GtkTextBufferの内容をファイルに書き込めば良いのですが、現状そのGtkTextBufferの内容に紐付くファイルを特定する方法がありません。つまり、ウィンドウに現在編集中のファイルが紐付いていませんので、これらを紐付けるデータ構造を新規に作ります。

GTK+はクラスとその継承を利用してプログラムを作成できるので、通常今回のようなケースではGtkApplicationWindowクラスを継承するクラスを作成して、そのクラスにファイル名を保持するメンバー変数を定義すればスマートに実装できます。

しかし、Rustは構造体とトレイトを組み合わせてプログラムを作成するので、既存のgtk::ApplicationWindow構造体に直にフィールドを追加することはできません。

その為今回は、新たにWindowCoreという構造体を定義して、gtk::ApplicationWindow構造体とファイル情報(gio::File構造体)をフィールドに持たせます。

ついでに、ウィンドウ内のテキストの変更を保存せずにウィンドウを閉じようとした場合、ダイアログを出して[変更を破棄する]か[保存する]もしくは[キャンセル]するかをユーザに確認するようにしたいので、GtkTextBufferの内容が変更されているかについてもWindowCore構造体にステータスとして保存しておくようにします。

以下がWindowCore構造体の定義になります。

struct WindowCore {
    window: gtk::ApplicationWindow,  //GtkApplicationWindow本体
    file: Option<gio::File>,         //このウィンドウに紐付くファイルの情報
    changed: bool,                   //ウィンドウ内の内容が変更されているかの状態
}

このWindowCore構造体を使用して、Saveアクションを実装します。

具体的には、WindowCorefileに設定されているファイルにGtkTextBufferの内容を書き出し、changedフィールドにfalseを設定します。

この処理は以下のように書きたいのですが、これではうまくいきません。

let save_action = gio::SimpleAction::new("save", None);
{
    //Saveアクションのハンドラ内でwinのchangedフィールドの値を設定したいので
    //winの複製ではなく、winへの参照をクロージャにキャプチャさせたいがlifetimeの制約によりNG。
    let win = &mut win;
    save_action.connect_activate(move |_, _| {
        win.save_file();
        win.changed = false;
    });
}

上記コード内のコメントの通り、WindowCore構造体への参照をstatic lifetimeのクロージャにキャプチャさせることはできない為3、別の方法で参照をキャプチャさせます。

今回その方法として、スマートポインタstd::rc::Rc<T>を使います。Rc<T>はヒープ領域上のTへの参照のようなものを実現する構造体で、clone()メソッドでその参照を複製できます。また、ダングリングポインタやメモリリークが発生しないようTへの参照数を管理しています。(RcはReference Countedの略)

つまり、Rc<WindowCore>を作って、これをclone()してクロージャにキャプチャさせたいのですが、まだこれでもうまくいきません。WindowCore構造体が複数箇所から参照されることになると、RustのルールによりWindowCore自体への変更ができなくなります。4

なので、さらにstd::cell:RefCellでラッピングして、Rc<RefCell<WindowCore>>として扱います。
RefCellは、borrow()borrow_mut()メソッドを使うことでそれぞれimmutable borrowとmutable borrowを実行します。

以上の方法により、Rc<RefCell<WindowCore>>clone()したものをクロージャにキャプチャして、クロージャ内では
その構造体に対してborrow_mut()WindowCoreをmutable borrowして変更を加えます。

RefCellは実行時にRustのborrowルールに則ってborrowチェックをするので、既にborrow()されている状態でborrow_mut()を実行するとランタイムパニックとなります。
(RcRefCellはコンパイル時にチェックすることを実行時にチェックしているので、その分実行時のコストが増えます。)

ウィンドウをRc<RefCell<WindowCore>>として扱うように変更し、これをWindow型として別名を付けて別ファイルwin.rsに纏めたのが以下になります。

win.rs
extern crate gtk;
extern crate gio;
extern crate gdk;

use gtk::{
    ResponseType, TextViewExt,
    TextBufferExt,
    WidgetExt, FileChooserExt, DialogExt,
    ContainerExt,
    BinExt, Cast, WindowExt,
};

use gio::{
    FileExt, SimpleActionExt, ActionMapExt
};

use std::cell::RefCell;
use std::rc::Rc;
use std::path::Path;


pub struct WindowCore {
    window: gtk::ApplicationWindow,
    file: Option<gio::File>,
    text_view: gtk::TextView,
    changed: bool,
}

pub type Window = Rc<RefCell<WindowCore>>;

pub trait WindowExtend {
    fn create(app: &gtk::Application) -> Window;
    fn init(&self);
    fn load_file(&self, file: gio::File);
    fn save_file(&self);
    fn save_buffer(&self);
    fn save_as(&self);
    fn save_file_chooser_run(&self) -> Option<gio::File>;
    fn window(&self) -> gtk::ApplicationWindow;
    fn file(&self) -> Option<gio::File>;
    fn set_file(&self, file: Option<gio::File>);
    fn text_view(&self) -> gtk::TextView;
    fn set_changed(&self, changed: bool);
    fn set_title(&self, title: &str);
}

impl WindowExtend for Window {
    fn create(app: &gtk::Application) -> Window {
        let window = gtk::ApplicationWindow::new(app);
        window.set_default_size(800, 600);
        window.set_title("Vanilla Text");
        let scr_win = gtk::ScrolledWindow::new(None, None);
        let txt_view = gtk::TextView::new();
        scr_win.add(&txt_view);
        window.add(&scr_win);

        let win = Rc::new(RefCell::new(
                WindowCore {window: window.clone(),
                            file: None,
                            text_view: txt_view.clone(),
                            changed: false}));

        let copy_action = gio::SimpleAction::new("copy", None);
        {
            let txt_view = txt_view.clone();
            copy_action.connect_activate(move |_, _| {
                let clipboard = txt_view.get_clipboard(&gdk::SELECTION_CLIPBOARD);
                txt_view.get_buffer().unwrap().copy_clipboard(&clipboard);
            });
        }

        let paste_action = gio::SimpleAction::new("paste", None);
        {
            let txt_view = txt_view.clone();
            paste_action.connect_activate(move |_, _| {
                let clipboard = txt_view.get_clipboard(&gdk::SELECTION_CLIPBOARD);
                let buf = txt_view.get_buffer().unwrap();
                buf.paste_clipboard(&clipboard, None, txt_view.get_editable());
            });
        }

        let save_action = gio::SimpleAction::new("save", None);
        {
            let win = win.clone();
            save_action.connect_activate(move |_, _| {
                win.save_file();
            });
        }

        window.add_action(&copy_action);
        window.add_action(&paste_action);
        window.add_action(&save_action);

        window.show_all();

        win
    }


    fn init(&self) {
        self.borrow().window.show_all();
    }

    fn load_file(&self, file: gio::File) {
        if let Ok((v, _)) = file.load_contents(None) {
            let text = String::from_utf8(v).unwrap();

            let scr_win = self.window().get_child().unwrap().downcast::<gtk::ScrolledWindow>().ok().unwrap();
            let txt_view = scr_win.get_child().unwrap().downcast::<gtk::TextView>().ok().unwrap();
            let buf = txt_view.get_buffer().unwrap();
            buf.set_text(&text);
        }

    }

    fn save_file(&self) {
        if self.file().is_some() {
            self.save_buffer();
        } else {
            self.save_as()
        }
    }

    fn save_buffer(&self) {
        if let Some(buf) = self.text_view().get_buffer() {
            let (start, end) = buf.get_bounds();
            if let Some(text) = buf.get_text(&start, &end, true) {
                if self.file().as_ref().unwrap().replace_contents(text.as_bytes(),
                                         None,
                                         true,
                                         gio::FILE_CREATE_NONE,
                                         None).is_ok() {
                    self.set_changed(false);
                } else {
                    let dialog = gtk::MessageDialog::new(Some(&self.window()),
                                                         gtk::DIALOG_MODAL,
                                                         gtk::MessageType::Error,
                                                         gtk::ButtonsType::Close,
                                                         "Error: Cannot save file");
                    dialog.run();
                    dialog.destroy();
                }
            }
        }
    }

    fn save_as(&self) {
        if let Some(file) = self.save_file_chooser_run() {
            self.set_title(file.get_basename().unwrap().to_str().unwrap());
            self.set_file(Some(file));
            self.save_buffer();
        }
    }

    fn save_file_chooser_run(&self) -> Option<gio::File> {
        let dialog = gtk::FileChooserDialog::new::<gtk::Window>(Some("Save File"),
                                                                Some(&self.window().upcast()),
                                                                gtk::FileChooserAction::Save);
        dialog.add_button("Cancel", ResponseType::Cancel.into());
        dialog.add_button("Save", ResponseType::Accept.into());
        dialog.set_do_overwrite_confirmation(true);

        if let Some(ref f) = self.file() {
            dialog.set_filename(f.get_path().unwrap().as_path());
        } else {
            dialog.set_current_name(Path::new("untitled"));
        }

        let file;
        if dialog.run() == ResponseType::Accept.into() {
            if let Some(path) = dialog.get_filename() {
                file = Some(gio::File::new_for_path(path.as_path()))
            } else {
                file = None
            }
        } else {
            file = None
        }

        dialog.destroy();

        file
    }

    fn window(&self) -> gtk::ApplicationWindow {
        self.borrow().window.clone()
    }

    fn file(&self) -> Option<gio::File> {
        self.borrow().file.clone()
    }

    fn set_file(&self, file: Option<gio::File>) {
        self.borrow_mut().file = file;
    }

    fn set_changed(&self, changed: bool) {
        self.borrow_mut().changed = changed;
    }

    fn text_view(&self) -> gtk::TextView {
        self.borrow().text_view.clone()
    }

    fn set_title(&self, title: &str) {
        self.borrow().window.set_title(title);
    }
}

長くなるので、main.rsの修正版はgist(main.rs)に貼っておきます。

これで、ファイルへの保存ができるようになりましたが、まだ以下の機能がありません。

  • ウィンドウを閉じる際、内容が変更されている場合は保存を促す。
  • ファイルを開く際、すでに別のウィンドウでそのファイルを編集中の場合は新規ウィンドウを開かない。
  • ファイルを開く際、未編集のウィンドウがある場合はそのウィンドウでファイルを開く。

最後の2つを実装するには、現在開いている全ウィンドウを管理する必要があります。
(Windowのリストをアプリケーション内で保持しておく必要があります。)

現在開いている全WindowVecに保持しておいて、今開こうとしているファイルを保持しているWindowがないか、また、未編集のWindowがないかをこのVecから探して適切な処理をします。

これを実現にするには、下記のようにOpenアクションのハンドラ内のどこかでVecWindowを追加する必要があります。

let wins: Vec<Window>;
...
{
    let win = win.clone();
    open_action.connect_activate(move |_, _| {
        ...
        wins.push(win);
        ...
    });
}

ここでも、Vecへの参照をクロージャにキャプチャさせ、かつVecへの追加・削除ができるようにする為、以下のようにWindows型を導入します。

type Windows = Rc<RefCell<Vec<Window>>>;

以上の追加と以下追加を含めたコードをgistに貼っておきます。(main.rswin.rs)

  • Ctrl-c などのショートカットキー(accelerator)を設定
  • メニューをいくつか追加、[about]ではダイアログ内にロゴ画像(自前で用意したassets/logo.png)を表示。
  • コマンドライン引数にファイルを指定できるように、アプリケーションにopenシグナルハンドラを設定(app.connect_open())
  • アプリケーションのactivateやopenシグナルより前に発生するstartupシグナルに対するハンドラ内で、アクションやUIを設定(app.connect_startup())

ここまでで、タブ無しのテキストエディタができました。cargo run -- <file name>...と実行すると、起動時に(複数の)ファイルを開くこともできます。

タブを導入

最後に、ここまで作ってきたテキストエディタにタブを導入します。

方法はいくつかあるかと思いますが、今回は単純にGtkScrolledWindowをタブのボディ部分(ページ内)に持たせます。

タブ型UIのWidgetの構成

タブはGtkNotebookクラスで実現します。
GtkNotebookクラスは以下のような構造をしており、1つのGtkNotebookに複数のGtkNotebookPageを保持させることができ、各GtkNotebookPageはコンテンツ(child)とそれに紐付くタブ(tab_label)で構成されます。

GtkNotebook
├── GtkNotebookPage
│   ├── child
│   └── tab_label
├── GtkNotebookPage
│   ├── child
│   └── tab_label
.
.

今回はタブに、開いているファイルのファイル名と閉じるボタンを設置し、コンテンツにはテキスト編集領域を設置したいのでオブジェクトの配置は下記のようにします。

GtkApplicationWindow
└── GtkNotebook
    ├── GtkNotebookPage
    │   ├── GtkBox (child)
    │   │   ├── GtkRevealer
    │   │   │   └── GtkInfoBar
    │   │   │       └── GtkBox
    │   │   │           └── GtkLabel
    │   │   └── GtkScrolledWindow
    │   │       └── GtkTextView
    │   └── GtkBox (tab_label)
    │       ├── GtkLabel
    │       └── GtkButton
    ├── GtkNotebookPage
    .
    .

コンテンツ(child)内のGtkRevealer以下のオブジェクトは、ウィンドウ内の上部に表示するインフォメーションバーになります。

ファイルを開くときに既にそのファイルが別のタブで編集中の場合、その旨を知らせるWarningメッセージを表示するのに使います。

メッセージ表示はGtkInfoBarで行っていますが、そのメッセージ自体を表示したり隠したりをコントロールする為にGtkRevealerを使っています。

タブを管理するデータ構造

これまではウィンドウ(WindowCore)で、編集しているファイルやテキストが変更されているかを管理してきましたが、ここからはタブ(ページ)で管理していく必要がありますので、新たにデータ構造を導入します。

pub struct PageCore {
    tab: gtk::Box,
    tab_label: gtk::Label,
    revealer: gtk::Revealer,
    contents: gtk::Box,
    text_view: gtk::TextView,
    close_button: gtk::Button,
    file: Option<gio::File>,
    changed: bool,
}

先ほどのGtkNotebookPage内の各部品に直接アクセスできるようにフィールドを持たせています。

更にWindowWindowsと同様に、PageCoreを直接使わずにPagePagesを使うようにします。

pub type Page = Rc<RefCell<PageCore>>;
pub type Pages = Rc<RefCell<Vec<Page>>>;

以上、UIのオブジェクト構成とタブを管理するデータ構造となります。

これらを扱う関数も実装していきますが、扱う対象をWindowからPageに変えて少し修正するだけなので難しいところはありません。

ここまでで完成となりますが、一部コードを改善したいところがありますので最後の修正をしていきます。

UIをXMLで定義

このアプリではUIの構成はシンプルなので気になりませんが、より複雑なUIを構成する場合、UIオブジェクトを構成するコードの見通しが悪くなり、画面のイメージもつきにくくなります。

GTK+ではUIの構成をXMLで記述することができるので、UIを構成するコードを個別のXMLファイルとして切り出していきます。

以下ウィンドウの構造を定義したXMLになります。

window.ui
<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <object class="GtkApplicationWindow" id="window">
    <property name="default-width">800</property>
    <property name="default-height">600</property>
    <property name="title">~new file~</property>
    <child>
      <object class="GtkNotebook" id="notebook">
      </object>
    </child>
  </object>
</interface>

コード内でこのXMLファイルを読み込んでGtkBuilderオブジェクトを生成し、id属性の文字列を指定してオブジェクトを取得します。

さらに、アプリケーションのメニューもXMLに切り出します。こちらもコード内ではGtkBuilderオブジェクトとして扱えます。

menu.ui
<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <menu id="app_menu">
    <section>
      <item>
        <attribute name="label">New Window</attribute>
        <attribute name="action">app.new_window</attribute>
      </item>
      <item>
        <attribute name="label">Quit</attribute>
        <attribute name="action">app.quit</attribute>
      </item>
    </section>
  </menu>
  <menu id="menu_bar">
    <submenu>
      <attribute name="label">File</attribute>
      <section>
        <item>
          <attribute name="label">New Tab</attribute>
          <attribute name="action">win.new_tab</attribute>
        </item>
      </section>
      <section>
        <item>
          <attribute name="label">Save</attribute>
          <attribute name="action">win.save</attribute>
        </item>
        <item>
          <attribute name="label">Save as...</attribute>
          <attribute name="action">win.saveas</attribute>
        </item>
      </section>
      .
      .

今回作成したアプリケーションはGitHub(VanillaText)にあげておきます。

おまけ

GTK+とあまり関係ないですが、アプリケーションを配布する際に役立つ情報も簡単に纏めておきます。

メニューバーにアプリケーション名を表示する

作成したアプリをUbuntuで実行するとメニューバーに「名前なし」と表示されます。
PrtScr_no_app_name.png

通常、配布されているアプリではここにアプリケーション名が表示されます。(Chromeの場合、「Google Chrome」と表示される。)
アプリケーション名を表示するには、/usr/share/applications/に<アプリケーション名>.desktopというファイルを設置します。

.desktopファイルには以下のように、Name=Vanilla Textと指定することでアプリケーション名が表示されます。

vanilla_text.desktop
[Desktop Entry]
Type=Application
Encoding=UTF-8
Name=Vanilla Text
Comment=A simple text editor
Exec=/usr/bin/vanilla_text
Icon=vanilla_text
Terminal=false

PrtSc_app_name.png

アイコンを設定する

デスクトップのランチャーに表示されるアイコンやアプリケーション切り替え時のアイコンも「?」アイコンのままなので設定します。

アイコンはそのサイズごとに特定のディレクトリに保存することで表示されるようになります。
保存場所は複数ありますが、今回は/usr/share/icons/hicolor/配下に保存します。

今回用意するアイコンのサイズは、ランチャー表示用に48x48ピクセルとDash検索表示用に64x64ピクセル、アプリ切り替え表示用に128x128ピクセルのPNGファイルとします。

それぞれ、上記ディレクトリ配下のサイズごとにわかれたディレクトリに<実行ファイル名>.pngというファイル名で保存します。

例えば、64x64ピクセルのPNGファイルは/usr/share/icons/hicolor/64x64/apps/vanilla_text.pngに保存しています。

尚、アイコンをシステムに認識させるには、画像ファイルを保存した後、以下コマンドを実行してアイコンのキャッシュを更新する必要があります。

~$ gtk-update-icon-cache /usr/share/icons/hicolor

ランチャーにアイコンが表示されます。

PrtSc_launcher_icon.png

※アイコンの作り方に興味がある方は以下ご参考まで。
Gravit Designerを使って3分でアイコンを作る

Makefileを作る

最後にCargoからではなく、コマンドとして実行できるようにビルドしてインストールします。

UIを記述したXMLファイルや.desktopファイル、アイコンファイルなども適切な場所にインストールする必要があるので以下のようなMakefileを作成します。

Makefile
all:
    cargo build --release

install:
    install -Dm 755 target/release/vanilla_text /usr/bin/vanilla_text
    mkdir -p /usr/share/vanilla_text/ui
    install -Dm 755 ui/* /usr/share/vanilla_text/ui/
    install -Dm 644 assets/vanilla_text.desktop /usr/share/applications/vanilla_text.desktop
    install -Dm 644 assets/icon_48x48.png /usr/share/icons/hicolor/48x48/apps/vanilla_text.png
    install -Dm 644 assets/icon_64x64.png /usr/share/icons/hicolor/64x64/apps/vanilla_text.png
    install -Dm 644 assets/icon_128x128.png /usr/share/icons/hicolor/128x128/apps/vanilla_text.png
    gtk-update-icon-cache /usr/share/icons/hicolor

uninstall:
    rm /usr/bin/vanilla_text
    rm -r /usr/share/vanilla_text
    rm /usr/share/applications/vanilla_text.desktop
    rm /usr/share/icons/hicolor/48x48/apps/vanilla_text.png
    rm /usr/share/icons/hicolor/64x64/apps/vanilla_text.png
    rm /usr/share/icons/hicolor/128x128/apps/vanilla_text.png
    gtk-update-icon-cache /usr/share/icons/hicolor

最後に

以上でGTK+とGtk-rsを使ったテキストエディタが完成しました。

コードはGitHub(VanillaText)にあげておきます。

また、参考にしたサイトやコードを挙げておきます。


  1. 自分自身GTK+とRustのチュートリアルを読みながらこの記事のアプリを作成したので、Rustに対する理解は浅いです。。 

  2. Vanilla JSを真似し「何もトッピングされていないバニラアイスのような」という意味を込めて。 

  3. コンパイラからすると実体より、キャプチャした実体への参照の方が長く生き続けるように見えるのでNG。 

  4. WindowCoreに対するimmutable borrowが発生している状態で、mutable borrowはできない。