概要
いまさらですが、はじめてLinuxのデスクトップアプリを作成したので、備忘の意味も含めてその手順を書きます。
使うツール
GUIツールキットはGTK+を使います。Qtでも良かったのですが、環境整備やチュートリアルがシンプルで手っ取り早く始められそうな感じがしたのでGTK+を選びました。
また、言語はRustにしました。Cだと目新しさもないので、他の言語を使って書きたかったからです。
また、GTK+のバイディングのサポート状況を見て、GTK+3を完全にサポートしている言語のうちVMを使わない言語で
今後も使われていきそうなものを選びました。
(完全に直感です。C++は学習自体辛そうなので避けました。)
前提
この記事を読むにあたり、GTK+の知識は不要ですが、Rustの入門程度は済ませておいた方が良いです。
Rust自体の説明は殆どしないつもりです。1
作るアプリ
最初なのでシンプルなテキストエディタを作ります。Windowsのメモ帳と全く同じだとつまらないのでタブ型のUIにします。
アプリ名はVanilla Text2としておきます。
環境
本記事では以下の環境を使っています。
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相当の機能を使う為、以下のようにします。
[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を以下のように編集します。
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
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::WidgetExt
はshow_all()
メソッドの為に、そしてgio::ApplicationExt
はrun()
メソッドの為に取り込んでいます。
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::TextView
をgtk::ApplicationWindow
の子要素として追加すれば良いように思います。しかし、このオブジェクトだけでは、ウィンドウのスクロール機能がありませんので、gtk::ScrolledWindow
オブジェクトに包んでウィンドウの子要素に追加します。
Widgetの親子関係は以下になります。
gtk::ApplicationWindow
└── gtk::ScrolledWindow
└── gtk::TextView
これを実装したコードは以下になります。(以降、変更部分のみ抜粋)
...
// 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
を実行して以下のようにテキストの入力やウィンドウのスクロールができることを確認します。
編集コマンドを実装する
TextViewを設置しただけではコピペ等の編集コマンドは使えませんので、これらの機能を実装していきます。
まずは、コピーとペーストを実装します。
メニューバーから[Edit]-[Copy]、[Edit]-[Paste]のように選択して、コピー・ペーストができるようにします。
各メニューアイテムを選択した時に、コピーやペーストを実行させるシグナルをウィンドウに送信する必要があります。
ここで、シグナルとハンドラを紐付けるAction
インターフェイス(今回はその具象クラスのSimpleAction
)を使います。SimpleAction
オブジェクトへ、activateシグナルに対するハンドラを紐付け、ウィンドウにこのSimpleAction
を設定します。
...
//クリップボードを扱う為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(©_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シグナルを送信します。
...
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(©);
submenu_edit.append_item(&paste);
//メニューバーにEditサブメニューを設定
menubar.append_submenu("Edit", &submenu_edit);
//アプリケーションにメニューバーを設定
app.set_menubar(&menubar);
}
...
Action
はActionGroup
を使って特定のグループに分類することができます。ActionGroup
は、その名前とそのグループに属するAction
で構成されます。
GtkApplicationWindow
オブジェクトは内部にActionGroup
を持っており、そのActionGroup
の名前は"win"
として登録されます。
MenuItem::new()
で指定するアクションは"<プレフィクス>.<アクション>"
という形式で指定しますがこの<プレフィクス>には`ActionGroup`の名前を指定し、<アクション>にAction
オブジェクトのアクション名を指定します。
(よってコピーのアクションは"win.copy"
)
新規ウィンドウ作成
メニューバーから[File]-[New window]を選択して、新規にウィンドウを作成できるようにします。
新規に作成されるウィンドウはアプリケーション起動時のウィンドウと同じまっさらなウィンドウにするのでウィンドウのUI作成とアクション設定のコードをcreate_window()
関数として再利用できるようにします。
また、ついでに[File]-[Quit]でアプリケーションを終了できるようにしておきます。
以上の変更を加えたここまでの全コードをは以下になります。
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(©);
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: >k::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(©_action);
win.add_action(&paste_action);
win.show_all();
win
}
ファイルを編集・保存できるようにする
まだファイルを開いて編集して保存という重要な機能がありませんのでここで実装していきます。
まずは、ファイルを開く機能ですが、メニューバーから[File]-[Open]を選択するとファイル選択のダイアログウィンドウが表示され、そこで開くファイルを選択するという、よくある流れを実装します。
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アクションを実装します。
具体的には、WindowCore
のfile
に設定されているファイルに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()
を実行するとランタイムパニックとなります。
(Rc
やRefCell
はコンパイル時にチェックすることを実行時にチェックしているので、その分実行時のコストが増えます。)
ウィンドウをRc<RefCell<WindowCore>>
として扱うように変更し、これをWindow
型として別名を付けて別ファイル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: >k::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: >k::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(©_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
のリストをアプリケーション内で保持しておく必要があります。)
現在開いている全Window
をVec
に保持しておいて、今開こうとしているファイルを保持しているWindow
がないか、また、未編集のWindow
がないかをこのVec
から探して適切な処理をします。
これを実現にするには、下記のようにOpenアクションのハンドラ内のどこかでVec
へWindow
を追加する必要があります。
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.rs、win.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
内の各部品に直接アクセスできるようにフィールドを持たせています。
更にWindow
、Windows
と同様に、PageCore
を直接使わずにPage
とPages
を使うようにします。
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になります。
<?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
オブジェクトとして扱えます。
<?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で実行するとメニューバーに「名前なし」と表示されます。
通常、配布されているアプリではここにアプリケーション名が表示されます。(Chromeの場合、「Google Chrome」と表示される。)
アプリケーション名を表示するには、/usr/share/applications/
に<アプリケーション名>.desktopというファイルを設置します。
.desktopファイルには以下のように、Name=Vanilla Text
と指定することでアプリケーション名が表示されます。
[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
アイコンを設定する
デスクトップのランチャーに表示されるアイコンやアプリケーション切り替え時のアイコンも「?」アイコンのままなので設定します。
アイコンはそのサイズごとに特定のディレクトリに保存することで表示されるようになります。
保存場所は複数ありますが、今回は/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
ランチャーにアイコンが表示されます。
※アイコンの作り方に興味がある方は以下ご参考まで。
Gravit Designerを使って3分でアイコンを作る
Makefileを作る
最後にCargoからではなく、コマンドとして実行できるようにビルドしてインストールします。
UIを記述したXMLファイルや.desktopファイル、アイコンファイルなども適切な場所にインストールする必要があるので以下のような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)にあげておきます。
また、参考にしたサイトやコードを挙げておきます。
- Getting Started with GTK+ (まずはここから)
- GNOME DEVELOPER API Reference (APIの使い方はこちら)
- GTK+のソースコード (リファレンスだけではわからないときに参照)
- [Gtk-rs documentation] (http://gtk-rs.org/docs-src/) (Gtk-rsのAPIリファレンス)
- Gtk-rsのソースコード (リファレンスだけではわからないときに参照)
- geditのソースコード (テキストエディタの作り方の参考に)
GtkTreeViewを使ったGUIアプリの作り方についてはこちらをどうぞ。
いまさらGTKとRustでLinuxデスクトップアプリ入門 -LANアナライザを作る-