C言語とGTK+3でツリービューを構築する

736, 2023-11-06

目次

C言語とGTK+3でツリービューを構築

C言語でGUIライブラリと言うとGTKというのがあります。
今回はGTK+3でバージョン3を使ってツリービューを構築してみたいと思います。
ツリービューは右クリックでアイテムを追加・削除できる仕組みです。

シグナルのバインドでデフォルトの動作が無効になるので、けっこう地道な作業が必要でした。

動作風景は以下になります。

c-gtk3-tree-view

ソースコード全文

/* GTK+3でツリービューを構築する
 * License: MIT
 * Since: 2023-11-06
 */
#include <gtk/gtk.h>

enum {
    COLUMN = 0,
    NUM_COLUMNS
};

/**
 * ツリービューを構築する。
 */
void populate_tree_view(GtkWidget *tree_view) {
    GtkCellRenderer *renderer;
    GtkTreeViewColumn *column;
    GtkTreeStore *store;
    GtkTreeIter parent_iter, child_iter;

    store = gtk_tree_store_new(NUM_COLUMNS, G_TYPE_STRING);

    // ルートノードを追加
    gtk_tree_store_append(store, &parent_iter, NULL);
    gtk_tree_store_set(store, &parent_iter, COLUMN, "フォルダ1", -1);

    // 子ノードを追加
    gtk_tree_store_append(store, &child_iter, &parent_iter);
    gtk_tree_store_set(store, &child_iter, COLUMN, "ファイル1-1", -1);

    gtk_tree_store_append(store, &child_iter, &parent_iter);
    gtk_tree_store_set(store, &child_iter, COLUMN, "フォルダ1-2", -1);

    // 子ノードの子ノードを追加する場合はパレントを特定の子ノードに指定する
    parent_iter = child_iter;
    gtk_tree_store_append(store, &child_iter, &parent_iter);
    gtk_tree_store_set(store, &child_iter, COLUMN, "ファイル2-1", -1);

    // ツリービューにツリーストアを設定
    gtk_tree_view_set_model(GTK_TREE_VIEW(tree_view), GTK_TREE_MODEL(store));
    g_object_unref(store);

    // カラムを設定
    renderer = gtk_cell_renderer_text_new();
    column = gtk_tree_view_column_new_with_attributes("フォルダ", renderer, "text", COLUMN, NULL);
    gtk_tree_view_append_column(GTK_TREE_VIEW(tree_view), column);

    // ルートノードを展開
    GtkTreePath *path = gtk_tree_path_new_from_indices(0, -1);
    gtk_tree_view_expand_row(GTK_TREE_VIEW(tree_view), path, FALSE);
    gtk_tree_path_free(path);
}

/**
 * 右クリックメニューで「アイテムを追加」が押されたときに呼ばれる。
 */
void on_add_item_clicked(GtkWidget *widget, gpointer data) {
    GtkTreeView *tree_view = (GtkTreeView *) data;
    GtkTreeModel *model;
    GtkTreeIter child_iter, parent_iter;
    GtkTreeSelection *selection;

    // 選択領域を取得
    selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree_view));

    // 選択されているアイテムがあれば
    if (gtk_tree_selection_get_selected(selection, &model, &parent_iter)) {    
        GtkTreeStore *store = (GtkTreeStore *) model;
        gtk_tree_store_append(store, &child_iter, &parent_iter);
        gtk_tree_store_set(store, &child_iter, COLUMN, "フォルダ1-2", -1);
    }
}

/**
 * 右クリックメニューで「アイテムを削除」が押されたときに呼ばれる。 
 */
void on_remove_item_clicked(GtkWidget *widget, gpointer data) {
    GtkTreeView *tree_view = (GtkTreeView *) data;
    GtkTreeModel *model;
    GtkTreeIter iter;
    GtkTreeSelection *selection;

    // 選択領域を取得
    selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree_view));

    // 選択されているアイテムがあれば
    if (gtk_tree_selection_get_selected(selection, &model, &iter)) {    
        // モデルからアイテムを削除
        gtk_tree_store_remove(GTK_TREE_STORE(model), &iter);
    }
}

/**
 * ツリービューのアイテムがクリックされたときに呼ばれる。 
 */
void on_item_button_pressed(GtkWidget *tree_view, GdkEventButton *event, gpointer user_data) {
    if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_SECONDARY) {
        // 右クリックの処理
        GtkTreeSelection *selection;
        GtkTreeModel *model;
        GtkTreeIter iter;

        // 選択領域を取得
        selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree_view));

        // 選択されているアイテムがあれば
        if (gtk_tree_selection_get_selected(selection, &model, &iter)) {
            // nameでメニューを作成
            GtkWidget *menu = gtk_menu_new();
            GtkWidget *add_item = gtk_menu_item_new_with_label("アイテムを追加");
            GtkWidget *remove_item = gtk_menu_item_new_with_label("アイテムを削除");
            g_signal_connect(add_item, "activate", G_CALLBACK(on_add_item_clicked), (gpointer) tree_view);
            g_signal_connect(remove_item, "activate", G_CALLBACK(on_remove_item_clicked), (gpointer) tree_view);
            gtk_menu_shell_append(GTK_MENU_SHELL(menu), add_item);
            gtk_menu_shell_append(GTK_MENU_SHELL(menu), remove_item);
            gtk_widget_show_all(menu);

            // メニューを表示
            gtk_menu_popup_at_pointer(GTK_MENU(menu), (gpointer) event);
        }
    } else if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) {
        // 左クリックの処理

        // なぜか知らないけどevent->yの位置がちょっとずれてる
        // 以下で調整
        // おそらくツリービュー上部のラベル表示によるもの
        event->y += 20;

        // ウィジェットの座標をbin_windowの座標に変換
        gint x, y;
        gtk_tree_view_convert_widget_to_bin_window_coords(
            GTK_TREE_VIEW(tree_view), event->x, event->y, &x, &y
        );

        // 座標x, yからツリーパスを得る
        GtkTreePath *path = NULL;
        if (gtk_tree_view_get_path_at_pos(GTK_TREE_VIEW(tree_view), x, y, &path, NULL, NULL, NULL)) {
            // 現在のツリーパスにフォーカスを当てる(背景色をトグルする)
            gtk_tree_view_set_cursor(GTK_TREE_VIEW(tree_view), path, NULL, FALSE);

            // クリックしているセル(アイテム)の矩形情報(座標、サイズ)を得る
            GtkTreeViewColumn *column = gtk_tree_view_get_column(GTK_TREE_VIEW(tree_view), 0);
            GdkRectangle cr;
            gtk_tree_view_get_cell_area(GTK_TREE_VIEW(tree_view), path, column, &cr);

            // トグルボタンがクリックされたか?
            // cr.x にはトグルボタンの領域は含まれていない
            // そのため x < cr.x のときトグルボタンの領域にカーソルがあることになる
            gboolean is_toggle_btn_clicked = x < cr.x;
            if (is_toggle_btn_clicked) {
                // 行が展開されている場合は閉じ、閉じている場合は展開する
                // フォルダの入れ子の展開
                gboolean is_expanded = gtk_tree_view_row_expanded(GTK_TREE_VIEW(tree_view), path);
                if (is_expanded) {
                    // 行を閉じる
                    gtk_tree_view_collapse_row(GTK_TREE_VIEW(tree_view), path);
                } else {
                    // 行を開く
                    gtk_tree_view_expand_row(GTK_TREE_VIEW(tree_view), path, FALSE);
                }
            }

            // クリックされたアイテムの名前をprintする
            GtkTreeModel *model = gtk_tree_view_get_model(GTK_TREE_VIEW(tree_view));
            GtkTreeIter iter;
            if (gtk_tree_model_get_iter(model, &iter, path)) {
                gchar *name;
                gtk_tree_model_get(model, &iter, COLUMN, &name, -1);
                g_print("クリックされたアイテムの名前: %s\n", name);
                g_free(name);
            }

            // ツリーパスを解放
            gtk_tree_path_free(path);
        }
    }
}

int main(int argc, char *argv[]) {
    GtkWidget *window;
    GtkWidget *tree_view;

    gtk_init(&argc, &argv);

    // ウィンドウを作成
    window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
    gtk_window_set_title(GTK_WINDOW(window), "フォルダツリー");
    gtk_window_set_default_size(GTK_WINDOW(window), 200, 200);
    g_signal_connect(G_OBJECT(window), "destroy", G_CALLBACK(gtk_main_quit), NULL);

    // ツリービューを作成してウィンドウに追加
    tree_view = gtk_tree_view_new();
    populate_tree_view(tree_view);
    gtk_container_add(GTK_CONTAINER(window), tree_view);

    // シグナルをバインド
    // button-press-eventをバインドするとデフォルトの動作が無効になる
    // そのため自分でデフォルトの動作を再現する
    g_signal_connect(tree_view, "button-press-event", G_CALLBACK(on_item_button_pressed), NULL);

    // ウィンドウとツリービューを表示
    gtk_widget_show_all(window);

    // メインループを開始
    gtk_main();

    return 0;
}

ソースコードの解説

main関数の処理

int main(int argc, char *argv[]) {
    GtkWidget *window;
    GtkWidget *tree_view;

    gtk_init(&argc, &argv);

    // ウィンドウを作成
    window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
    gtk_window_set_title(GTK_WINDOW(window), "フォルダツリー");
    gtk_window_set_default_size(GTK_WINDOW(window), 200, 200);
    g_signal_connect(G_OBJECT(window), "destroy", G_CALLBACK(gtk_main_quit), NULL);

    // ツリービューを作成してウィンドウに追加
    tree_view = gtk_tree_view_new();
    populate_tree_view(tree_view);
    gtk_container_add(GTK_CONTAINER(window), tree_view);

    // シグナルをバインド
    // button-press-eventをバインドするとデフォルトの動作が無効になる
    // そのため自分でデフォルトの動作を再現する
    g_signal_connect(tree_view, "button-press-event", G_CALLBACK(on_item_button_pressed), NULL);

    // ウィンドウとツリービューを表示
    gtk_widget_show_all(window);

    // メインループを開始
    gtk_main();

    return 0;
}

main関数ではまずgtk_init()でGTKを初期化します。
そのあとにウィンドウを作ってタイトルやサイズを初期化します。
ウィンドウ汚destroyシグナルにgtk_main_quit()をバインドさせて、ウィンドウが破棄されたら呼び出すようにします。

それからツリービューを作成して、populate_tree_view()という自作関数で構築します。
構築が終わったらウィンドウにツリービューを配置します。

ツリービューのbutton-press-eventシグナルににon_item_button_pressed()をバインドします。
このシグナルへのバインドによってデフォルトのツリービューの動作が無効になるので、関数内ではデフォルトの動作も再現します。

gtk_widget_show_all()でウィンドウ以下のウィジェットを全て表示。
gtk_main()でGTKのメインループを行います。

populate_tree_view関数の処理

/**
 * ツリービューを構築する。
 */
void populate_tree_view(GtkWidget *tree_view) {
    GtkCellRenderer *renderer;
    GtkTreeViewColumn *column;
    GtkTreeStore *store;
    GtkTreeIter parent_iter, child_iter;

    store = gtk_tree_store_new(NUM_COLUMNS, G_TYPE_STRING);
    ...
}

ツリービューの構築では上記のようにまずストアを作ります。

    // ルートノードを追加
    gtk_tree_store_append(store, &parent_iter, NULL);
    gtk_tree_store_set(store, &parent_iter, COLUMN, "フォルダ1", -1);

    // 子ノードを追加
    gtk_tree_store_append(store, &child_iter, &parent_iter);
    gtk_tree_store_set(store, &child_iter, COLUMN, "ファイル1-1", -1);

    gtk_tree_store_append(store, &child_iter, &parent_iter);
    gtk_tree_store_set(store, &child_iter, COLUMN, "フォルダ1-2", -1);

    // 子ノードの子ノードを追加する場合はパレントを特定の子ノードに指定する
    parent_iter = child_iter;
    gtk_tree_store_append(store, &child_iter, &parent_iter);
    gtk_tree_store_set(store, &child_iter, COLUMN, "ファイル2-1", -1);

次にストアにフォルダやファイルのアイテムを追加していきます。
親はparent_iterで子はchild_iterです。
子を親にする場合はchild_iterを親にする必要があります。

    // ツリービューにツリーストアを設定
    gtk_tree_view_set_model(GTK_TREE_VIEW(tree_view), GTK_TREE_MODEL(store));
    g_object_unref(store);

ツリービューにストアをセットします。

    // カラムを設定
    renderer = gtk_cell_renderer_text_new();
    column = gtk_tree_view_column_new_with_attributes("フォルダ", renderer, "text", COLUMN, NULL);
    gtk_tree_view_append_column(GTK_TREE_VIEW(tree_view), column);

セルレンダラーを作ってツリービューカラムにします。
それをツリービューにカラムとして追加します。

    // ルートノードを展開
    GtkTreePath *path = gtk_tree_path_new_from_indices(0, -1);
    gtk_tree_view_expand_row(GTK_TREE_VIEW(tree_view), path, FALSE);
    gtk_tree_path_free(path);


ルートノードはフォルダですが、初期状態では閉じた状態になっています。
これを展開した状態にします。
つまり、フォルダ内の他のアイテムを表示するようにします。

on_add_item_clicked関数の処理

/**
 * 右クリックメニューで「アイテムを追加」が押されたときに呼ばれる。
 */
void on_add_item_clicked(GtkWidget *widget, gpointer data) {
    GtkTreeView *tree_view = (GtkTreeView *) data;
    GtkTreeModel *model;
    GtkTreeIter child_iter, parent_iter;
    GtkTreeSelection *selection;

    // 選択領域を取得
    selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree_view));

    // 選択されているアイテムがあれば
    if (gtk_tree_selection_get_selected(selection, &model, &parent_iter)) {    
        GtkTreeStore *store = (GtkTreeStore *) model;
        gtk_tree_store_append(store, &child_iter, &parent_iter);
        gtk_tree_store_set(store, &child_iter, COLUMN, "フォルダ1-2", -1);
    }
}

この関数はアイテムの右クリックのメニューアイテム「アイテムを追加」がクリックされたときに呼ばれます。
関数の引数のdataにはツリービューが渡されますので、これをツリービューにキャストします。
そしてツリービューの選択領域から、選択中のアイテムのモデル(ストア)を取得して、そのストアにアイテムを追加します。

on_remove_item_clicked関数

/**
 * 右クリックメニューで「アイテムを削除」が押されたときに呼ばれる。 
 */
void on_remove_item_clicked(GtkWidget *widget, gpointer data) {
    GtkTreeView *tree_view = (GtkTreeView *) data;
    GtkTreeModel *model;
    GtkTreeIter iter;
    GtkTreeSelection *selection;

    // 選択領域を取得
    selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree_view));

    // 選択されているアイテムがあれば
    if (gtk_tree_selection_get_selected(selection, &model, &iter)) {    
        // モデルからアイテムを削除
        gtk_tree_store_remove(GTK_TREE_STORE(model), &iter);
    }
}

この関数はアイテムの右クリックで表示されるメニューアイテム「アイテムを削除」をクリックしたときに呼ばれる関数です。
選択領域のアイテムのモデルを取得して、そのモデル(ストア)を使ってアイテムを削除します。

on_item_button_pressed関数の処理

/**
 * ツリービューのアイテムがクリックされたときに呼ばれる。 
 */
void on_item_button_pressed(GtkWidget *tree_view, GdkEventButton *event, gpointer user_data) {
    if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_SECONDARY) {
        // 右クリックの処理
        GtkTreeSelection *selection;
        GtkTreeModel *model;
        GtkTreeIter iter;

        // 選択領域を取得
        selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree_view));

        // 選択されているアイテムがあれば
        if (gtk_tree_selection_get_selected(selection, &model, &iter)) {
            // nameでメニューを作成
            GtkWidget *menu = gtk_menu_new();
            GtkWidget *add_item = gtk_menu_item_new_with_label("アイテムを追加");
            GtkWidget *remove_item = gtk_menu_item_new_with_label("アイテムを削除");
            g_signal_connect(add_item, "activate", G_CALLBACK(on_add_item_clicked), (gpointer) tree_view);
            g_signal_connect(remove_item, "activate", G_CALLBACK(on_remove_item_clicked), (gpointer) tree_view);
            gtk_menu_shell_append(GTK_MENU_SHELL(menu), add_item);
            gtk_menu_shell_append(GTK_MENU_SHELL(menu), remove_item);
            gtk_widget_show_all(menu);

            // メニューを表示
            gtk_menu_popup_at_pointer(GTK_MENU(menu), (gpointer) event);
        }
        ...
    } else if ...
    ...
}

この関数はツリービューのアイテムがクリックされたときに呼ばれます。
event->buttonGDK_BUTTON_SECONDARYの時、つまり右クリックですが、上記の処理を行います。
やることはポップアップメニューの表示です。

メニューを作りメニューアイテムを「アイテムを追加」と「アイテムを削除」の分だけ作り、そのアイテムのactivateシグナルに関数を紐づけます。activateシグナルはアイテムがクリックされたときに発行されます。
そしてメニューにアイテムを追加して、メニューを表示。
そしてgtk_menu_popup_at_pointer()でメニューをポップアップします。

    ... else if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) {
        // 左クリックの処理

        // なぜか知らないけどevent->yの位置がちょっとずれてる
        // 以下で調整
        // おそらくツリービュー上部のラベル表示によるもの
        event->y += 20;

        // ウィジェットの座標をbin_windowの座標に変換
        gint x, y;
        gtk_tree_view_convert_widget_to_bin_window_coords(
            GTK_TREE_VIEW(tree_view), event->x, event->y, &x, &y
        );
        ...
    }

event->buttonGDK_BUTTON_PRIMARYの時はマウスの左クリックの場合が多いです。
関数の引数のeventxyにはクリック時に下にあるウィジェットからのマウスの座標が入っています。
これのevent->y20を足して調整します。なんかずれるんですよね。

それでevent->xevent->ybin_windowの座標に変換してxyにします。

        // 座標x, yからツリーパスを得る
        GtkTreePath *path = NULL;
        if (gtk_tree_view_get_path_at_pos(GTK_TREE_VIEW(tree_view), x, y, &path, NULL, NULL, NULL)) {
            // 現在のツリーパスにフォーカスを当てる(背景色をトグルする)
            gtk_tree_view_set_cursor(GTK_TREE_VIEW(tree_view), path, NULL, FALSE);

            // クリックしているセル(アイテム)の矩形情報(座標、サイズ)を得る
            GtkTreeViewColumn *column = gtk_tree_view_get_column(GTK_TREE_VIEW(tree_view), 0);
            GdkRectangle cr;
            gtk_tree_view_get_cell_area(GTK_TREE_VIEW(tree_view), path, column, &cr);
        }
        ...


座標xyからツリービューのツリーパスを取得します。
パスが取得出来たら、gtk_tree_view_set_cursor()で現在のアイテムにフォーカスを当てます。
それからクリックしているセル(アイテム)の矩形情報を取得します。
この矩形情報によってトグルボタンとマウスの当たり判定をします。

            // トグルボタンがクリックされたか?
            // cr.x にはトグルボタンの領域は含まれていない
            // そのため x < cr.x のときトグルボタンの領域にカーソルがあることになる
            gboolean is_toggle_btn_clicked = x < cr.x;
            if (is_toggle_btn_clicked) {
                // 行が展開されている場合は閉じ、閉じている場合は展開する
                // フォルダの入れ子の展開
                gboolean is_expanded = gtk_tree_view_row_expanded(GTK_TREE_VIEW(tree_view), path);
                if (is_expanded) {
                    // 行を閉じる
                    gtk_tree_view_collapse_row(GTK_TREE_VIEW(tree_view), path);
                } else {
                    // 行を開く
                    gtk_tree_view_expand_row(GTK_TREE_VIEW(tree_view), path, FALSE);
                }
            }

x < cr.xであればトグルボタン(フォルダの左にある三角形のボタン)の上にマウスのポインタが乗っていると見なします。
マウスがトグルボタン上でクリックされたらアイテムが閉じている場合は開いて、開いている場合は閉じる動作を行います。

            // クリックされたアイテムの名前をprintする
            GtkTreeModel *model = gtk_tree_view_get_model(GTK_TREE_VIEW(tree_view));
            GtkTreeIter iter;
            if (gtk_tree_model_get_iter(model, &iter, path)) {
                gchar *name;
                gtk_tree_model_get(model, &iter, COLUMN, &name, -1);
                g_print("クリックされたアイテムの名前: %s\n", name);
                g_free(name);
            }

            // ツリーパスを解放
            gtk_tree_path_free(path);

最後におまけですが、クリックされたアイテムの名前をg_print()で出力するコードを書きます。
そしてpath(ツリーパス)を解放しておわりです。

おわりに

今回はC言語とGTK+3でツリービューを構築しました。
ツリービューを構築できると、いろいろなケースで使えると思います。
何か参考になれば幸いです。



この記事のアンケートを送信する