Общие сведения

Как было в GTK 3

В GTK 3 деревья реализовывались посредством виджета GtkTreeView и модели на основе интерфейса GtkTreeModel, предоставляющего для виджета данные. За отрисовку отвечали специальные рендереры ячеек (GtkCellRenderer, которые можно было назначать колонкам, в том числе в одну колонку можно было поместить несколько таких рендереров. Рендерер мог отрисовывать текстовое поле, флажок, картинку, индикатор прогресса или спиннер. Традиционным подходом к оформлению виджетов было добавление для колонок GtkTreeViewColumn атрибутов рендерерам, которые указывали, из какого столбца модели данных брать цвет фона ячейки, из какой — цвет текста и т. д. Для рендереров можно было также назначить цвет фона через свойство cell-background самого рендерера.

Подобный подход к оформлению деревьев был и его основным недостатком. Рендереры не являются виджетами, не ведут себя как виджеты и не настраиваются через CSS в отличие от виджетов[1]. С ними возникало много сложностей в случае необходимости реализации чего-либо нестандартного. А в случае необходимости задействования CSS приходилось получать значение стиля из CSS-провайдера и вручную применять его к строкам таблицы или дерева (через атрибуты и колонки). Тем не менее, рендереры позволяли осуществлять достаточно быструю отрисовку информации, а ещё на них были основаны и некоторые другие виджеты, например, GtkComboBox (выпадающий список).

Объявление GtkTreeView устаревшим

В GTK 4.10 виджет GtkTreeView (как и всё сопутствующее) был объявлен устаревшим. Сложный и громоздкий подход к построению деревьев решили упростить в пользу использования обычных виджетов. В качестве замены предлагается использовать виджеты GtkListView и GtkColumnView, которые сами по себе из коробки формировать деревья не умеют, но с точки зрения прикладного интерфейса используют более простую схему с обычными виджетами для отображения данных[2]. Хотя предложенная схема более простая с точки зрения поддержки, для реализации деревьев она всё ещё сложна, поскольку деревья теперь реализуются посредством дополнительных механизмов.

Как работает GtkListView

GtkListView отображает виджеты на основе списков, реализующих интерфейс GListModel. Каждый элемент списка представляет собой какой-либо набор данных. Для отображения данных необходимы виджеты. Виджеты создаются через фабрики, наследующиеся от класса GtkListItemFactory. То есть для каждой отображаемой строки запускается создание виджета через фабрику. Затем виджету задаётся значение из строки с моделью данных, опять же через фабрику. Основной идеей является переиспользование виджетов, то есть одному и тому же виджету в разное время могут назначаться разные строки с данными. Именно за это и отвечают фабрики.

В версии GTK 4.10 существуют две фабрики: GtkBuilderListItemFactory и GtkSignalListItemFactory. Фабрика GtkBuilderListItemFactory предназначена для создания виджетов и привязке к данным по описанию, сделанному в UI-файлах, которые представляют собой описание интерфейса в формате XML. GtkSignalListItemFactory позволяет создавать и привязывать к данным виджеты через сигналы, что подходит для создания виджетов вручную в c-файлах.

Для реализации выделения строк задаётся модель выделения через интерфейс GtkSelectionModel. В версии GTK 4.10 доступны 3 модели выделения: GtkNoSelection, GtkSingleSelection и GtkMultiSelection. Какая модель для чего предназначена, понятно исходя из названий классов (без возможности выделения, выделение одной строки, выделение нескольких строк).

Дерево на основе GtkListView

Для реализации деревьев существует отдельная реализация интерфейса GListModel под названием GtkTreeListModel. Данная модель использует уже существующий объект, хранящий данные (можно использовать тип GListStore библиотеки GLib), но позволяет по запросу добавлять к строкам дочерние строки посредством функции обратного вызова. На этом моменте уже становится понятно, что новая схема работы с данными позволяет из коробки реализовывать механизм «ленивой» загрузки данных. Функция обратного вызова для заполнения дочерних строк должна создавать свой собственный GListStore, заполнять его дочерними элементами и возвращать. Оперирует GtkTreeListModel объектами класса GtkTreeListRow, которые как раз и хранят в себе указатель на список дочерних объектов.

Сворачивание и разворачивание ветви дерева осуществляется при помощи GtkTreeExpander, который также работает с моделью GtkTreeListModel.

Создаём дерево программно

В данном разделе можно ориентироваться на пример, написанный мною для демонстрации возможностей GTK 4 в плане создания деревьев. Исходный код проекта можно посмотреть в репозитории GitLab gtksqlite-demo (лицензия LGPL 2.1).

Для начала необходимо создать модель данных с помощью конструктора g_list_store_new(), указав в качестве аргумента идентификатор типа данных (число типа gulong), который будет храниться в строках. Создаётся впечатление, что в списке можно хранить G_TYPE_INT, G_TYPE_STRING или G_TYPE_ARRAY, но внутри g_list_store_append() выполняется g_object_ref()[3], что предполагает добавление лишь объектов класса GObject (с идентификатором типа G_TYPE_OBJECT) или объектов классов-наследников. Конечно, можно попытаться добавлять туда произвольные данные, но это приведёт к неопределённому поведению (может скрешится, а может и нет) и ругательствам в поток вывода (в консоль). Регистрация собственных классов осуществляется с помощью макроса G_DEFINE_TYPE() или его вариаций. Самым примитивным выбором для хранения строк с данными будет использование GObject и добавление к нему данных через g_object_set_data()/g_object_set_data_full(). В таком случае можно обращаться к колонкам с данными по строковым ключам (ключи будут преобразовываться в кварки с помощью g_quark_from_string() на глобальном уровне, хеш будет по кваркам). В более сложных случаях можно наследоваться от GObject и реализовывать дополнительный функционал (например, сигналы на изменение данных).

Следует отметить, что GListStore не завладевает добавленными в него объектами (выполняет g_object_ref(), а не g_object_ref_sink()), поэтому после добавления объекта через g_list_store_append() необходимо самостоятельно делать объекту g_object_unref(). Иначе будут утечки памяти, которые будет сложно выявить из-за механизма счётчика ссылок и наличия основного цикла программы.

Далее необходимо создать модель данных для дерева с помощью конструктора gtk_tree_list_model_new(), передав ему список с данными строк дерева и указав в качестве обработчика заполнения дочерних элементов свою собственную функцию:

static GListModel *create_list_model_cb(gpointer item, gpointer user_data)
{
    // Создаём список для хранения элементов собственного типа GtkDbRow
    GListStore *list_store = g_list_store_new(G_TYPE_DB_ROW);

    // Заполнение list_store какими-либо данными
    // ...

    return G_LIST_MODEL(list_store);
}

// ...
    GtkTreeListModel *model =
            gtk_tree_list_model_new(G_LIST_MODEL(main_ui.tree_store),
                                    FALSE, // иначе дерево работать не будет
                                    FALSE, // для динамической подгрузки
                                    create_list_model_cb, // обработчик для создания дочерних элементов
                                    NULL,
                                    NULL);

Следующим этапом создаётся модель выделения, в нашем случае выделять можно будет лишь одну строку дерева:

    GtkSingleSelection *tree_view_selection =
            gtk_single_selection_new(G_LIST_MODEL(model));

Для отображения данных в дереве требуется фабрика. Ей необходимо будет назначить обработчики на создание виджетов (сигнал setup) и на привязку к виджетам данных (сигнал bind):


// Обработчик создания виджетов:
static void tree_list_item_setup_cb(GtkListItemFactory *factory,
                                    GtkListItem *list_item,
                                    gpointer user_data)
{
    // Создаём виджет для раскрытия ветви дерева:
    GtkWidget *tree_expander = gtk_tree_expander_new();
    gtk_list_item_set_child(list_item, tree_expander);

    // Контейнер для дочерних виджетов строки (если их более одного):
    GtkWidget *box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 2);

    // Создание дочерних виджетов, каждому из которых будет назначена колонка из строки
    // ...

    // Назначаем виджету раскрытия ветви дочерний виджет:
    gtk_tree_expander_set_child(GTK_TREE_EXPANDER(tree_expander), box);
}

// Обработчик привязки виджетов к данным:
static void tree_list_item_bind_cb(GtkListItemFactory *factory,
                                   GtkListItem *list_item,
                                   gpointer user_data)
{
    GtkTreeListRow *tree_row = gtk_list_item_get_item(list_item);
    GtkWidget *tree_expander = gtk_list_item_get_child(list_item);

    // Назначаем виджету раскрытия текущую строку:
    gtk_tree_expander_set_list_row(GTK_TREE_EXPANDER(tree_expander), tree_row);

    // Получаем дочерние виджеты:
    GtkWidget *box =
            gtk_tree_expander_get_child(GTK_TREE_EXPANDER(tree_expander));

    // ...
    // Установка значений и сигналов для виджетов
    // g_signal_connect_data(...)
}

// Обработчик отвязки данных от виджета:
static void tree_list_item_unbind_cb(GtkSignalListItemFactory *self,
                              GtkListItem *list_item,
                              gpointer user_data)
{
    GtkTreeListRow *tree_row = gtk_list_item_get_item(list_item);
    GtkWidget *tree_expander = gtk_list_item_get_child(list_item);

    // Получаем дочерние виджеты:
    GtkWidget *box =
            gtk_tree_expander_get_child(GTK_TREE_EXPANDER(tree_expander));

    // ...
    // Отсоединение назначенных виджетам сигналов
    // через g_signal_handlers_disconnect_matched() или иную функцию;
}

// ...

    // Создаём фабрику и назначаем ей обработчики сигналов:
    GtkListItemFactory *tree_factory = gtk_signal_list_item_factory_new();
    g_signal_connect(
            tree_factory, "setup", G_CALLBACK(tree_list_item_setup_cb), NULL);
    g_signal_connect(
            tree_factory, "bind", G_CALLBACK(tree_list_item_bind_cb), NULL);
    g_signal_connect(
            tree_factory, "unbind", G_CALLBACK(tree_list_item_unbind_cb), NULL);

// ...

Сигнал unbind необходим для корректной отвязки данных от виджета. Например, если назначаются какие-либо обработчики событий, то их необходимо будет отвязать перед следующей привязкой новых данных (иначе могут остаться назначенными и старые, и новые обработчики, что приведёт к неопределённому поведению программы или утечке обработчиков сигналов или памяти). Необходимо заметить, что в сигнале unbind объект типа GtkTreeListRow уже не будет привязан к данным строки (функция gtk_tree_list_row_get_item() будет возвращать NULL). В особых случаях также может понадобиться использовать сигнал teardown, который выполняется перед уничтожением виджетов.

Теперь создадим непосредственно само дерево, указав ему модель выделения и фабрику для управления виджетами:

    main_ui.tree_view = gtk_list_view_new(
            GTK_SELECTION_MODEL(tree_view_selection), tree_factory);

Наконец, необходимо назначить обработчик события на активацию строки (двойной клик или нажатие ввода), чтобы разворачивать ветви дерева:

// Обработчик сигнала активации строки дерева:
static void
listview_activate_cb(GtkListView *list, guint position, gpointer unused)
{
    GListModel *list_model = G_LIST_MODEL(gtk_list_view_get_model(list));
    GtkTreeListRow *tree_row = g_list_model_get_item(list_model, position);
    
    // Раскрываем или сворачиваем ветвь дерева:
    gtk_tree_list_row_set_expanded(tree_row,
                                   !gtk_tree_list_row_get_expanded(tree_row));
	// ...
}

    // ...
    // Назначение обработчика на сигнал активации строки:
    g_signal_connect(main_ui.tree_view,
                     "activate",
                     G_CALLBACK(listview_activate_cb), // указатель на обработчик
                     NULL);

При выделении элемента дерева может потребоваться выполнять какие-либо действия, например, заполнять данными таблицу со строками GtkColumnView или заполнять данными какую-либо форму. Обработчик на выделение строки вешается не на виджет типа GtkListView, а на модель выделения. В нашем случае — на объект типа GtkSingleSelection. Данный тип наследуется от GtkSelectionModel, в котором есть сигнал selection-changed. Внутри обработчика изменения выделения получить выделенный элемент можно через метод gtk_single_selection_get_selected_item().

// Обработчик сигнала изменения выделения:
static void selection_changed_cb(GtkSingleSelection *selection_model,
                                 guint start_position,
                                 guint count,
                                 gpointer user_data)
{
    GtkTreeListRow *tree_row =
            gtk_single_selection_get_selected_item(selection_model);

    // Получаем саму строку с данными:
    gpointer row_data = gtk_tree_list_row_get_item(tree_row);

    // ...
}

    // ...
    // Назначаем обработчик сигнала изменения выделения:
    g_signal_connect(tree_view_selection,
                     "selection-changed", // название сигнала
                     G_CALLBACK(selection_changed_cb), // обработчик
                     NULL);

Разумеется, созданное дерево необходимо добавить в GtkScrolledWindow, который, в свою очередь, через какие-либо другие контейнеры необходимо поместить в окно приложения. Но в данной статье основы создания приложения на GTK не рассматриваются. По созданию простейшего приложения на GTK 4 можно обратиться к официальному мануалу GTK.

Использованные источники

  1. Scalable lists in GTK 4 // GTK Development Blog : All things GTK. — Дата обращения: 5 июня 2023.

  2. Displaying trees // List Widget Overview. — (Официальная документация GTK 4). — Дата обращения: 5 июня 2023.

  3. gliststore.c // GLib. — (Репозиторий с исходным кодом GLib). — Дата обращения: 7 июня 2023.

Комментарии (4)


  1. NeoCode
    08.06.2023 05:10

    С GTK сталкивался очень эпизодически (немного пользовался линуксом с ним, немного в кроссплатформенных виндовских приложениях). И вот что меня удивляет - это и на картинке к статье видно: откуда такие огромные расстояния между элементами? Там можно в 2, а то и в 3 раза все ужать. На диалогах огромные кнопки с маленькими надписями, так что на каких-нибудь 10-дюймовых нетбуках диалоговые окна не влезают в экран.


    1. andrey0700 Автор
      08.06.2023 05:10

      Оформление обычно в CSS задаётся (хотя для графических библиотек некоторая часть указывается в коде). В рамках окружения рабочего стола CSS для GTK задаётся темой оформления. Для разных устройств можно создавать разные темы оформления. На свой вкус можно менять уже существующие.

      Можно было бы в одной теме всё задать, но в GTK не поддерживается @media. Нашёл тему об этом ещё 2016 года, там же объясняется, почему поддержку правила @media до сих пор не добавили в GTK: responsive design.


  1. kale
    08.06.2023 05:10

    Почему не использовали Vala? Выглядело бы все попроще


    1. andrey0700 Автор
      08.06.2023 05:10

      С Vala пришлось бы разбираться и читать документацию, поскольку на Vala до сих пор не писал, ушло бы больше времени. Статья же родилась в ходе тестового задания, на которое мне давали неделю (дерево с ленивой подгрузкой данных). Я и так выбрал GTK 4 с расчётом написания статьи на Хабр, зная, что большая часть времени уйдёт на эксперименты, чтение документации и изучение существующих примеров (которых почти нет), поскольку до этого работал только с GTK 3. А так, да, с Vala код был бы читабельнее, хотя своего какого-либо мнения (плюсы/минусы) у меня по этому языку пока нет.