Rust Monthly Topics

家計簿アプリを作ってみる ~gtk-rsでデスクトップアプリ開発をはじめよう[後編]

2回にわたって取り上げてきた、gtk-rsを使ったデスクトップアプリ開発。このテーマの締めくくりとして、今まで学んだことのまとめとして簡単なアプリを作ってみましょう。目指すのは財布からの出入金と残高を確認できる家計簿アプリです。

完成したアプリケーションの画面
図 完成したアプリケーションの画面

複雑な機能はありませんが、これまでに学んだことを試すには手頃な例題でしょう。それでは作っていきます。

前回と同様に⁠本記事で登場するコードは以下に置いておきます⁠
https://github.com/KeenS/gtk-examples

レイアウト

最終的には以下のようなレイアウトで画面を作っていきましょう。

画面のレイアウト図
図 画面のレイアウト図

前回までの記事で登場していないウィジェットや、これから作るものもありますが、追い追い説明していきます。

プロジェクトの初期化

gtk4-rsを使うプロジェクトを作りましょう。ここでのプロジェクト名は、和同開珎からとってワドーにしておきます。みなさんは好きに名前をつけてください。

$ cargo new wado
$ cd wado
$ cargo add gtk4 --rename gtk

前回までと同様にgtk4gtkにリネームして使います。

main.rsにはHello Worldと同様のコードを置いておきましょう。これを骨格にしてコードを組み立てていきます。

use gtk::prelude::*;
use gtk::{self, glib};

fn main() {
    let application =
        // プロジェクト名
        gtk::Application::new(Some("com.github.keens.gtk-examples.wado"), Default::default());
    application.connect_activate(build_ui);
    application.run();
}

fn build_ui(application: &gtk::Application) {
    let window = gtk::ApplicationWindow::new(application);
    window.set_title(Some("ワドー"));
    window.set_default_size(600, 600);
    let vbox = gtk::Box::new(gtk::Orientation::Vertical, 10);
    // ☆ 後でいろいろウィジェットを追加する
    window.set_child(Some(&vbox));
    window.show();
}

コード中の ☆ の箇所にこれからコードを書いていきます。

ラベルの追加

まずは1つ目のウィジェット、合計金額を表示しましょう。Frameの中にLabelを入れます。Labelは普通の文字列を表示する他に、多少のマークアップをすることもできます。.use_markup(true)をしておきましょう。

let label = gtk::Label::builder()
    .use_markup(true)
    .label(format!("<big>{}円</big>", 0))
    .build();
let frame = gtk::Frame::builder()
    .label("残高")
    .child(&label)
    .build();
vbox.append(&frame);

今回は金額を大きめに表示するためにbigタグを使いました。他に使えるタグはPangoのドキュメントに記載されています。

ここまでを実行すると、以下のような見た目になります。

残高までのアプリケーション
図 残高までのアプリケーション

出入金リストの追加

次のウィジェットとなる出入金リストを追加しましょう。使うのはListBoxですが、いくつか準備が必要です。まず、行データの準備が必要です。まずはひとつひとつの出入金を表わすデータ型を定義しましょう。そして行データをどう表示するかも決めないといけません。

データ型の準備

出入金を表わすデータ型Paymentを定義します。これはウィジェットにデータとして渡したいので、GLibのオブジェクトとして定義しておきます。

GLibのオブジェクトを作るときの定石どおりpaymentディレクトリを作ってimp.rsmod.rsを置きます。

.
├── Cargo.toml
└── src
    ├── main.rs
    └── payment
        ├── imp.rs
        └── mod.rs

まずはimp.rsにコードを書いていきます。出入金を表わすオブジェクトのプロパティは以下があれば十分でしょう。

  • 名前
  • 金額
  • 日付

出金と入金は区別せず、金額の正負で表わすことにします。また、日付に時刻はあまり必要ありませんが、他のAPIとのやりとりの具合で時刻まで含んだDateTimeを使います。

オブジェクトとしては特に求められる要件がないので素直にObjectを継承します。

前回までの記事の内容を思い出して、以下のようなコードが書けるはずです。

use gtk::glib;
use gtk::glib::{prelude::*, DateTime, Properties};
use gtk::subclass::prelude::*;
use std::cell::{Cell, RefCell};

#[derive(Debug, Properties)]
#[properties(wrapper=super::Payment)]
pub struct Payment {
    #[property(get, set)]
    name: RefCell<String>,
    #[property(get, set)]
    amount: Cell<i64>,
    #[property(get, set)]
    date: RefCell<DateTime>,
}

// object_subclassに要求される
impl Default for Payment {
    fn default() -> Self {
        Self {
            name: RefCell::new(String::new()),
            amount: Cell::new(0),
            date: RefCell::new(DateTime::now_local().unwrap()),
        }
    }
}

#[glib::object_subclass]
impl ObjectSubclass for Payment {
    const NAME: &'static str = "Payment";
    type Type = super::Payment;
    type ParentType = glib::Object;
}

impl ObjectImpl for Payment {
    fn properties() -> &'static [glib::ParamSpec] {
        Self::derived_properties()
    }

    fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
        self.derived_set_property(id, value, pspec)
    }

    fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value {
        self.derived_property(id, pspec)
    }
}

コードを見ながら復習しましょう。

まず、オブジェクトを定義するのは、構造体を定義したあとDefault#[glib::object_subclass]を使ったObjectSubclassを実装し、継承するクラスごとにXxxImplを実装するのでした。今回はObjectを継承するだけなのでObjectImplだけを実装しています。

次に、オブジェクトのフィールドをプロパティにするには、#[properties]属性マクロをつけたあと、各フィールドにpropertyをつけるのでした。そのうえでObjectImplのいくつかのメソッドのデフォルト実装をオーバーライドします。各フィールドは変更可能なようにCellRefCellを使います。また、GLibと互換があるデータ型を使いましょう。今回の日付データもGLibのDateTimeを使っています。

さて、これをラップするコードをmod.rsに書きます。

mod imp;
use gtk::glib::{self, DateTime};

glib::wrapper! {
    pub struct Payment(ObjectSubclass<imp::Payment>);
}

impl Payment {
    pub fn new(name: String, amount: i64, date: DateTime) -> Self {
        let obj = glib::Object::new::<Payment>();
        obj.set_name(name);
        obj.set_amount(amount);
        obj.set_date(date);
        obj
    }
}

ラップするコードはglib::wrapperでおおむね自動生成できるのでした。

これであとはこのモジュールを使うだけです。

main.rsにインポートしておきましょう。

mod payment;
use payment::Payment;

これからPaymentを使います。

UIの構築

次に、行、つまりPaymentの表示方法を決めましょう。新しくWidgetを継承したクラスを定義する方法もありますが、今回は簡素に関数でUIを作ります。すなわち、以下のような型の関数を定義してPaymentからUIを構築します。

pub fn display_ui(payment: &Payment) -> impl IsA<gtk::Widget>;

ui.rsというファイルを作り、そこに関数を書いていきます。

.
├── Cargo.toml
└── src
    ├── ui.rs      ← NEW
    ├── main.rs
    └── payment
        ├── imp.rs
        └── mod.rs

行は以下のように日付、名前、金額の順に同じ幅を割り当て、日付を左寄せ、金額を右寄せで表示することにしましょう。

+------------+------------+------------+
| datetime~~ | ~~~name~~~ |  ~~~amount |
+------------+------------+------------+

それではコードを書いていきます。書き始めは素直にhboxを作ります。

use gtk::glib::DateTime;
use gtk::prelude::*;
use gtk;
use crate::Payment;

pub fn display_ui(payment: &Payment) -> impl IsA<gtk::Widget> {
    let hbox = gtk::Box::builder()
        .orientation(gtk::Orientation::Horizontal)
        .spacing(10)
        .homogeneous(true)
        .build();
    // ...
}

ここで.homogeneous(true)を指定することでそれぞれの列が同じ幅になります。

それぞれの表示にはLabelを使います。Labelhalignを指定することで、始端寄せ(左から右に書く体系では左寄せ)や終端寄せ(同右寄せ)を指定できます。

let date = gtk::Label::builder().halign(gtk::Align::Start).build();
let name = gtk::Label::new(None);
let amount = gtk::Label::builder().halign(gtk::Align::End).build();

さて、このラベルに表示する文字列データですが、少し懸念があります。素直にPaymentから文字列をコピーするとPaymentとは独立してデータを持つことになるのでPaymentが更新されてもラベルが更新されません。Paymentを更新するたびに手動でUIも更新するという手もありますが、もっと便利な方法があります。bind_propertyという機能を使えば、あるオブジェクトのプロパティを更新すると別のオブジェクトのプロパティも連動して更新することができます。今回はこの仕組みを使いましょう。

例えば以下のコードでpayment"name"プロパティが更新されたらnameという変数に束縛してあるLabel"label"プロパティも連動して更新させられます。

payment
    .bind_property("name", &name, "label")
    .sync_create()
    .build();

同様にしてdateamountもやりたいのですが、これらは文字列型ではないのでもうワンクッション必要です。そこでtransform_toを使うことで変換方法を指定できるので、文字列に変換しましょう。例えばamountなら以下のとおりです。

payment
    .bind_property("amount", &amount, "label")
    .transform_to(|_, a: i64| Some(format!("{}円", a)))
    .sync_create()
    .build();

日付については、日時データ型を日付に流用していることもあって少し手間がかかります。今後も何度か使うので、今のうちに便利関数を定義しておきましょう。

src/util.rsを作りましょう。

.
├── Cargo.toml
└── src
    ├── ui.rs
    ├── util.rs      ← NEW
    ├── main.rs
    └── payment
        ├── imp.rs
        └── mod.rs

そして以下の関数をutil.rsに書いて保存します。

use gtk::glib;

pub fn format_date(d: glib::DateTime) -> String {
    format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day_of_month())
}

main.rsmod util;を追加したら、ui.rsに以下のようなコードが書けるはずです。

use crate::util;

payment
    .bind_property("date", &date, "label")
    .transform_to(|_, d: DateTime| Some(util::format_date(d)))
    .sync_create()
    .build();

少し順序が前後しましたが、これでdatenameamountの3つのラベルが用意できました。これらをhboxに追加してdisplay_uiは完成です。わかりづらいと思うので関数全体を掲載しておきます。

use crate::{util, Payment};
use gtk;
use gtk::glib::{self, DateTime};
use gtk::prelude::*;

pub fn display_ui(payment: &Payment) -> impl IsA<gtk::Widget> {
    let hbox = gtk::Box::builder()
        .orientation(gtk::Orientation::Horizontal)
        .spacing(10)
        .homogeneous(true)
        .build();
    let date = gtk::Label::builder().halign(gtk::Align::Start).build();
    payment
        .bind_property("date", &date, "label")
        .transform_to(|_, d: DateTime| Some(util::format_date(d)))
        .sync_create()
        .build();
    let name = gtk::Label::new(None);
    payment
        .bind_property("name", &name, "label")
        .sync_create()
        .build();
    let amount = gtk::Label::builder().halign(gtk::Align::End).build();
    payment
        .bind_property("amount", &amount, "label")
        .transform_to(|_, a: i64| Some(format!("{}円", a)))
        .sync_create()
        .build();
    hbox.append(&date);
    hbox.append(&name);
    hbox.append(&amount);
    hbox
}

ListBoxを作る

これで準備が整いました。出入金のリストを画面に追加しましょう。main.rsに戻ってbuild_ui関数を変更します。build_uiではvboxを用意して、その子ウィジェットとしていろいろな部品を追加しているのでした。出入金のリストをvboxの子ウィジェットとして追加していきます。

まずはPaymentListStoreを作って、それをモデルにしてListBoxを作ります。

use gtk::gio;
mod ui;

let model = gio::ListStore::new(Payment::static_type());
let list_box = gtk::ListBox::new();
list_box.bind_model(Some(&model), |item| {
    let payment = item.downcast_ref::<Payment>().unwrap();
    ui::display_ui(payment).upcast::<gtk::Widget>()
});

少しややこしいかもしれませんが、GUIアプリケーションではモデルとビューを分離するのが良いプラクティスとされています。ここではListStoreがモデル、ListBoxがビューになっています。行も我々はPaymentdisplay_uiに分けましたね。

さて、このままlist_boxvboxに追加すると、長くなったときに溢れてしまうのでScrolledWindowを噛ませてからvboxに追加しましょう。

let scrolled_window = gtk::ScrolledWindow::builder()
    .hscrollbar_policy(gtk::PolicyType::Never) // Disable horizontal scrolling
    .min_content_height(400)
    .child(&list_box)
    .build();

vbox.append(&scrolled_window);

これで画面に出入金リストが追加されました。リスト内にデータが1つもないので手動でいくつかデータを追加しておきましょう。

model.append(&Payment::new(
    "お小遣い".into(),
    1000,
    glib::DateTime::now_local().unwrap(),
));
model.append(&Payment::new(
    "きゅうり".into(),
    -150,
    glib::DateTime::now_local().unwrap(),
));

こちらも全体像がつかみづらくなったと思うので概観を掲載しておきます。

fn build_ui(app: &gtk::Application) {
    let window = /* ... */;
    let vbox = /* ... */;

    let model = /* ... */;
    model.append(/* ... */);
    model.append(/* ... */);

    let list_box = /* ... */;
    list_box.bind_model(Some(model), |item| {
        /* ... */
    });

    let scrolled_window = /* ... */;


    let label = /* ... */
    let frame = /* ... */;

    vbox.append(&frame);
    vbox.append(&scrolled_window);
    window.set_child(Some(&vbox));
    window.show();
}

この状態で実行すると、以下のような見た目になるはずです。

ListBoxを追加した画面
図 ListBoxを追加した画面

まだウィジェットを構築しただけなので出入金リストと残高が釣り合っていませんね。続いてはそこに着手しましょう。

アプリケーションの再構成

ここまででウィジェットはできましたが、それぞれが連動していません。重要な部品をひとまとめにして機能を与えましょう。

重要なのは出入金リストと残高ラベルの連動です。出入金リストはListStoreがモデルなのでした。そこで、以下のような構造体を定義しましょう。main.rsの先頭の方に以下を書きます。

#[derive(Debug)]
pub struct Wado {
    model: gio::ListStore,
    balance: gtk::Label,
}

この構造体をGTKのAPIに渡す予定はないので、GLibのオブジェクトではなくただのRustの構造体です。この構造体の初期化でそれぞれの値を初期化します。

impl Default for Wado {
    fn default() -> Self {
        let label = gtk::Label::builder()
            .use_markup(true)
            .label(format!("<big>{}円</big>", 0))
            .build();
        let model = gio::ListStore::new(Payment::static_type());

        // ...

        Self {
            model,
            balance: label,
        }
    }
}

そして、残高とラベルを連動させましょう。ListStoreitems-changedシグナルを使うとアイテムに変更があるたびにラベルを更新できます。default関数の続きを書いていきます。

let label = /* ... */;
let model = /* ... */;

model.connect_items_changed(glib::clone!(@weak label => move |m, _, _, _| {
    let balance = m
        .into_iter()
        .map(|item| item.unwrap().downcast::<Payment>().unwrap().amount())
        .sum::<i64>();
    label.set_markup(&format!("<big>{}</big>円", balance));
}));

Self { /* ...*/ }

connect_items_changedの第一引数mにはListStore自身が入っています。&ListStoreIntoIteratorを実装しているので普通のRustのデータ型のようにイテレータのAPIが使えます。

あとは便利関数をいくつか定義しておきます。

impl Wado {
    pub fn record_payment(&mut self, payment: &Payment) {
        self.model.append(payment);
    }

    pub fn model(&self) -> &gio::ListStore {
        &self.model
    }
    pub fn balance(&self) -> &gtk::Label {
        &self.balance
    }
}

これを使ってbuild_uiを書き換えましょう。

fn build_ui(app: &gtk::Application) {
    let window = /* ... */;
    let vbox = /* ... */;

    // modelの代わりにwadoを初期化
    let mut wado = Wado::default();

    wado.record_payment(/* ... */);
    wado.record_payment(/* ... */);

    let list_box = /* ... */;
    // &model ではなくwado.model()に
    list_box.bind_model(Some(wado.model()), |item| {
        /* ... */
    });

    let scrolled_window = /* ... */;


    // labelではなくwadoのbalanceを使う
    let frame = gtk::Frame::builder()
        .label("残高")
        .child(wado.balance())
        .build();

    vbox.append(&frame);
    vbox.append(&scrolled_window);
    window.set_child(Some(&vbox));
    window.show();
}

これで再度アプリケーションを実行すると残高の部分が出入金リストと対応がとれるようになっているはずです。

入力を作る

出入金リストのデータをコード内で定義していましたが、UIから入力できるようにしましょう。

記述が長くなるのでbuild_uiの中には直接書かずに独立した関数にします。main.rsに以下の関数を追加しましょう。

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

fn input_box(wado: Rc<RefCell<Wado>>) -> gtk::Box {
    let hbox = gtk::Box::builder()
        .orientation(gtk::Orientation::Horizontal)
        .build();

    // ...

    hbox
}

後でwadoglib::cloneに渡したいのでRc<RefCell<>>に包んでいます。これに入力フォームを追加していきます。

入力フォームはEntryを使って作ります。名前のフィールドは以下のように書けるでしょう。

let name = gtk::Entry::builder().placeholder_text("name").build();

input_purposeを使って目的を設定することもできます。これを設定しておくと、スマホのキーボードのようなオンスクリーン入力の際に、自動で入力を切り替えてくれます。amountならDigitsを使って以下のように書きます。

let amount = gtk::Entry::builder()
    .placeholder_text("amount")
    .input_purpose(gtk::InputPurpose::Digits)
    .build();

最終的に取り出せる値は文字列なのでこのあと処理します。

さて、dateですが適当な入力ウィジェットがGTKに見当らなかったので作りましょう。

日付ピッカーを作る

日付ピッカーはutilに作っておきましょう。ウィジェットがないと言っても、カレンダーウィジェットとポップオーバーウィジェットを組み合わせるだけです。

ButtonPopoverCalendarを組み合わせたウィジェットを作ります。ボタンを押すとカレンダーがポップアップしてきて、日付をクリックするとカレンダーがポップダウンしてその日付が選ばれた状態になるウィジェットです。

use gtk::prelude::*;

pub fn datepicker() -> (gtk::Box, gtk::Calendar) {
    let hbox = gtk::Box::builder()
        .homogeneous(false)
        .orientation(gtk::Orientation::Horizontal)
        .build();
    let button = gtk::Button::new();
    let cal = gtk::Calendar::new();
    // デフォルトで今日の日付を選択しておく
    cal.select_day(&glib::DateTime::now_local().unwrap());
    let pop = gtk::Popover::builder().child(&cal).autohide(true).build();

    button.connect_clicked(glib::clone!(@weak pop => move |_| {
        pop.popup()
    }));
    button.set_label(&format_date(cal.date()));
    cal.connect_day_selected(glib::clone!(@weak pop, @weak button => move |cal| {
        pop.popdown();
        // 日付が選ばれたらボタンのラベルに反映しておく。
        button.set_label(&format_date(cal.date()));
    }));

    hbox.append(&button);
    hbox.append(&pop);

    // 親ウィジェットに追加するためのBoxと
    // 選択された日付を取り出すためのカレンダーを返す
    (hbox, cal)
}

馴れてきた頃だと思うので解説がなくても読めるでしょうか。特にトリッキーなところはありません。

日付ピッカーを使う

日付ピッカーを作ったので残りの入力フォームを作りましょう。3項目の入力と決定ボタンを用意し、決定ボタンが押されたらそれをアプリケーションに反映させます。

fn input_box(wado: Rc<RefCell<Wado>>) -> gtk::Box {
    let hbox = /* ... */;
    let name = /* ... */;
    let amount = /* ... */;

    let (picker, cal) = util::datepicker();
    let new_button = gtk::Button::builder().label("new").build();

    new_button.connect_clicked(
        glib::clone!(@weak name, @weak amount, @weak cal, @strong wado => move |_| {
            let n = name.buffer().text().to_string();
            let amount = match amount.buffer().text().parse() {
                Ok(a) => a,
                Err(_) => {
                    // 後で `dialog` を作る
                    util::dialog("エラー", "金額は整数値で入力して下さい");
                    return;
                },
            };
            let date = cal.date();
            let payment = Payment::new(n, amount, date);
            wado.borrow_mut().record_payment(&payment);
        }),
    );

    // 並びは出入金リストと同じく日付、名前、金額の順
    hbox.append(&picker);
    hbox.append(&name);
    hbox.append(&amount);
    hbox.append(&new_button);
    hbox
}

決定ボタンが押された場合、つまりclickedが発火された場合はnameamountcalからそれぞれ値を読み出します。

ここで、amountは整数値でなければならないので、一度整数としてパースして失敗すればreturnでクロージャから抜けます。このときユーザにダイアログを出したいのですが、 Dialogは非推奨となっており、 Windowを使うように誘導されているのでそのようにします。

この処理もやや煩雑になるのでutilに切り出しましょう。

pub fn dialog(title: &str, message: &str) {
    let label = gtk::Label::new(Some(message));
    let button = gtk::Button::with_label("OK");
    let vbox = gtk::Box::builder()
        .orientation(gtk::Orientation::Vertical)
        .spacing(10)
        .build();
    vbox.append(&label);
    vbox.append(&button);
    let dialog = gtk::Window::builder().title(title).child(&vbox).build();
    button.connect_clicked(glib::clone!(@weak dialog => move |_| {
        dialog.close()
    }));
    dialog.show();
}

新しいウィンドウにメッセージラベルとボタンを追加しているだけで特に難しいことはしていません。

仕上げにここで作った入力を画面に追加しましょう。

fn build_ui(application: &gtk::Application) {
    // ...


    // wadoをRc<RefCell<>>に包んだものを用意
    let wado = Rc::new(RefCell::new(wado));

    vbox.append(/* ... */);
    vbox.append(/* ... */);
    // input_boxを新たにvboxに追加
    vbox.append(&input_box(wado));

    // ...
}

さて、ここまできたら当初の画面までできたことになります。

入力を追加した画面
図 入力を追加した画面

画面から自由に出入金を追加できるようになりました。出入金を追加すると自動で残高も変わりますね。

かなり完成に近付きましたが、まだ追加した項目を編集/削除できません。最後にその機能を追加しましょう。

項目を編集/削除する

リストの項目をクリックすると、編集/削除できるようにしましょう。

出入金を編集している画面
図 出入金を編集している画面

ラベルを変更可能なエントリにし、編集を反映または項目を削除するボタンを追加します。

それではコードを書いていきましょう。項目がクリックされたことはListBoxrow-activatedシグナルで検知できます。以下のようにシグナルに接続してウィジェットを差し替えればよさそうです。

list_box.connect_row_activated(glib::clone!(@weak model => move |_lbox, row| {
    let payment = model.item(row.index() as u32).unwrap().downcast::<Payment>().unwrap();
    // これから `edit_ui` 関数を定義する
    row.set_child(Some(&ui::edit_ui(&payment, &model, row)));
}));

ということでedit_ui関数をuiモジュールに実装しましょう。

書き出しはもう悩むことも少ないでしょう。hboxを用意します。

use gtk::{gio, glib};

pub fn edit_ui(
    payment: &Payment,
    model: &gio::ListStore,
    row: &gtk::ListBoxRow,
) -> impl IsA<gtk::Widget> {
    let hbox = gtk::Box::builder()
        .orientation(gtk::Orientation::Horizontal)
        .build();

    // ...

    hbox
}

それぞれのラベルを編集可能なEntryにします。このとき、デフォルトで入っている文字をbufferで与えておきます。中身は現在のPaymentの値です。

let name_entry = gtk::Entry::builder()
    .placeholder_text("name")
    .buffer(&gtk::EntryBuffer::builder().text(payment.name()).build())
    .build();

amountも同様に作れます。

let amount_entry = gtk::Entry::builder()
    .placeholder_text("amount")
    .buffer(
        &gtk::EntryBuffer::builder()
            .text(format!("{}", payment.amount()))
            .build(),
    )
    .input_purpose(gtk::InputPurpose::Digits)
    .build();

datedatepickerを作ってselect_dayしておきましょう。

let (picker, cal) = util::datepicker();
cal.select_day(&payment.date());

あとウィジェットを2つ、更新ボタンと削除ボタンを作ります。

let update_button = gtk::Button::builder().label("update").build();
let delete_button = gtk::Button::builder().label("delete").build();

rowからその行がリスト内の何行目かを取り出せるので、先に取り出しておきましょう。

let index = row.index() as u32;

削除ボタンは簡単に実装できます。delete_buttonが押されたら削除するだけです。

delete_button.connect_clicked(glib::clone!(@weak model => move|_|{
    model.remove(index);
}));

編集ボタンは新規作成ボタンと似ていますが、既存のオブジェクトの値を変更する点、編集UIに切り替えたウィジェットを元に戻す点などが異なります。

update_button.connect_clicked(
    glib::clone!(@weak name_entry, @weak amount_entry, @weak cal, @weak payment, @weak row, @weak model => move |_| {
        let name = name_entry.buffer().text().to_string();
        let amount = match amount_entry.buffer().text().parse::<i64>() {
            Ok(a) => a,
            Err(_) => {
                util::dialog("エラー", "金額は整数値で入力してください");
                return;
            },
        };
        let date = cal.date();
        // オブジェクトを更新
        payment.set_name(name);
        payment.set_amount(amount);
        payment.set_date(date);
        // ウィジェットを元に戻す
        row.set_child(Some(&display_ui(&payment)));
        // 残高を更新するために items-changed シグナルを発行
        model.items_changed(index, 0, 0);
    }),
);

編集が終わったら最後にitems-changedシグナルを発行して、残高を再計算させます。

最後にこれらのウィジェットをhboxに追加して完成です。

edit_uiの概観を掲載すると以下のようになります。

pub fn edit_ui(
    payment: &Payment,
    model: &gio::ListStore,
    row: &gtk::ListBoxRow,
) -> impl IsA<gtk::Widget> {
    let hbox = /* ... */;
    let name_entry = /* ... */;
    let amount_entry = /* ... */;
    let (picker, cal) = /* ... */;
    cal.select_day(&payment.date());
    let update_button = /* ... */;
    let delete_button = /* ... */;

    let index = row.index() as u32;
    update_button.connect_clicked(
        /* ... */
    );
    delete_button.connect_clicked
        /* ... */
    }));

    hbox.append(&picker);
    hbox.append(&name_entry);
    hbox.append(&amount_entry);
    hbox.append(&update_button);
    hbox.append(&delete_button);
    hbox
}

あとはこれを使うだけです。build_uiを編集して、出入金をクリックしたらedit_uiを呼び出すようにしましょう。

fn build_ui(app: &gtk::Application) {
    // ...

    let list_box = /* ... */;
    list_box.bind_model(/* ... */);

    // modelを一旦変数に束縛する
    let model = wado.model();
    // ListBoxのrow-activatedシグナルに接続して編集画面を呼び出す
    list_box.connect_row_activated(glib::clone!(@weak model => move |_lbox, row| {
        let payment = model.item(row.index() as u32).unwrap().downcast::<Payment>().unwrap();

        row.set_child(Some(&ui::edit_ui(&payment, &model, row)));
    }));

    // ...
}

これでアプリケーションは完成です。build_uiの中でいくつか項目を追加していましたが、そのコードは消してしまってよいでしょう。

項目を追加したり編集したり削除したりしてみましょう。ちゃんと動いていますか?

まとめ

今までに学んだことを活かしてgtk-rsを使ってGUIアプリケーションを作ってみました。いろいろな機能を使った気がしますが、これでもまだGTKの持つ機能は使いきれていません。例えば、XMLでUIを構築するような大規模なアプリケーションに必須となる機能にはふれていませんね。

また、今回の家計簿アプリ自体もまだまだ拡張可能性があります。扱う項目にカテゴリをつけたり、項目を日付順にソートしたり、データを永続化したりなど、簡単にできそうな機能でもたくさん思い付きます。ぜひ練習問題として取り組んでみてください。

gtk-rsにはすばらしい機能がありますが、直接さわるには煩雑だなと感じた方も多いかもしれません。Relm4というgtk-rsの上でElmのようなAPIでGUIアプリケーションを構築できるフレームワークなどもあります。興味があったら調べてみてくさい。

今回のコードはこちらに置いておきます⁠
https://github.com/KeenS/gtk-examples

おすすめ記事

記事・ニュース一覧