GTK+ 4.0 中的部件层次结构

今天,我们将有客座作者 Timm Bäder,Corebird 的维护者和 GTK+ 的贡献者,来谈谈在 GTK+ 4.0 中编写复合部件的变化。

(注意:这里的一些信息基于尚未合并到主分支的分支,但我相信它们会在不久的将来合并)

在 GTK+3 中,只有 GtkContainer 的子类可以拥有子部件。这对于我们所知道的“公共”容器子项来说很有意义,例如 GtkBox - 即开发人员可以任意添加、删除和重新排序子部件,而容器只负责布局。

然而,GTK+3 中还有一些更复杂的部件不继承自 GtkContainer,例如 GtkSpinButtonGtkSwitch。这些部件从来没有真正的 GtkWidget 子项。例如,考虑 GtkSpinButton 中的两个可点击区域。我在这里不称它们为“按钮”是有原因的,因为在 GTK+3 中,它们不是真正的 GtkButton 实例,因为 GtkSpinButton 不是 GtkContainer。相反,GtkSpinButton 必须绕过这个事实,为向上/向下区域创建两个 GdkWindow,然后在其中渲染两个图标;关心悬停和 CSS 状态;各种按钮向上/向下事件;以及 GdkWindow 的生命周期等等。为了绕过 GtkContainer 的要求,我们在 GTK+3 中引入了小工具GtkCssGadget)。在样式方面,一个小工具对应于一个 CSS 框,因此表示 CSS 树中的一个节点。在部件方面,它们被用来为非容器部件提供“类似部件的”、可使用 CSS 设置样式的子项。

GtkWidget 的更改

当然,在 GTK+4 中支持这些用例需要进行大量的更改。我在这里不会列出所有的更改(特别是那些比较难看的,比如焦点处理),但我认为其中大多数对于应用程序开发人员和自定义部件作者来说都相当有趣和重要。总的来说,我们试图摆脱特殊情况,通过尽可能地重用部件来走更通用的道路。因此,与其使用 PangoLayout 来显示文本,部件应该使用 GtkLabel。如果你有一个具有类似按钮语义的可点击区域,请尝试使用 GtkButton。如果你想在水平或垂直方向上布局部件,请使用 GtkBox。这样,我们就有一个输入和渲染都可以操作的部件树。在实践中,这意味着主要摆脱部件内部使用的所有小工具,以及独立的 GtkCssNode 实例。

遍历子部件

虽然在 GTK+3 中,每个容器都必须实现 GtkContainer::forall,并且可以使用 gtk_container_foreach()gtk_container_forall() 轻松地遍历所有子部件,但在 GTK+4 中,每个 GtkWidget 都可以有子部件,因此每个部件都可能有一个我们需要绘制、测量、大小分配等的子项列表。在部件层次结构中,可以通过以下方式访问这些子项和兄弟部件:
  • gtk_widget_get_first_child()
  • gtk_widget_get_last_child()
  • gtk_widget_get_prev_sibling()
  • gtk_widget_get_next_sibling()
因此,遍历给定 GtkWidget 的子部件的最简单方法是(在 C 中)
GtkWidget *widget;
GtkWidget *child;
for (child = gtk_widget_get_first_child (widget);
     child != NULL;
     child = gtk_widget_get_next_sibling (child))
  {
    /* Do stuff with @child */
    g_assert (gtk_widget_get_parent (child) == widget);
  }

向非容器父项添加部件

GTK+(包括 3 和 4)中的每个部件都保存一个指向其父部件的指针。可以使用 gtk_widget_set_parent() 设置此父项,并且所有 GtkContainer::add 的实现最终都必须使用此函数来设置给定子部件的父项。

在 GTK+4 中,gtk_widget_set_parent() 仍然有效,并将部件添加到父项的子部件列表的末尾。但是,我们显然也希望管理子部件的顺序,以及我们在列表中添加新子项的位置,因此我们有:

  • gtk_widget_insert_before()
  • gtk_widget_insert_after()
在父项列表中已存在的子部件之前或之后添加新的子部件。这些也可以通过传递已经设置了给定父项的子项来重新排序子部件。
由于 GTK+ 内部的许多部件目前使用 XML 形式的复合部件模板,GtkWidget 现在也有其自己的 GtkBuildable::add_child() 实现来支持此用例。例如,GtkFileChooserWidget 就是使用这种方式,它几乎完全在 XML 中定义。

部件 CSS 名称

由于我们经常需要为任意部件使用任意 CSS 节点名称,GtkWidget 现在有一个构造时属性,名为 GtkWidget:css-name,如果指定了该属性,则它将用作 CSS 节点名称。如果未指定,则将使用传递给部件的 gtk_widget_class_set_css_name() 调用的名称。如果这两个名称都没有使用,则 CSS 节点将简单地称为“widget”。

转换后的部件示例

在当前主分支(和侧分支)中,已经有一些部件从使用各种 GtkCssGadgetGdkWindowPangoLayout 转换为使用实际部件。最终目标当然是尽可能多地重用部件,从而减少代码大小和维护负担。

GtkSwitch

在 GTK+3 中,GtkSwitchGtkWidget 的直接子类(太棒了!),它使用 GdkWindow 进行输入(单击开关将启用/禁用它),使用一个 GtkCssGadget 用于部件本身,两个 PangoLayout 用于 ON/OFF 文本,以及另一个 GtkCssGadget 用于滑块。
在 GTK+ 主分支中,开关仍然有其部件级的 GtkCssGadget,因此它支持最小宽度/最小高度 CSS 属性和 CSS 边距,但滑块小工具已被 GtkButton 取代,并且两个 PangoLayoutGtkLabel 取代。这样,我们可以节省大约 300 行 gtkswitch.c 代码。理论上,我们还有更多的功能,例如可以使用 GtkLabel 支持的 text-decoration CSS 属性的有限支持,但我只是怀疑这是否非常有用。

GtkSpinButton

如前所述,GtkSpinButton 可以很容易地对向上/向下区域使用实际的 GtkButton,并且它在 GTK+ 主分支(稍后会成为 GTK+4)中也是这样做的。这摆脱了 gtkspinbutton.c 中的另外 300 行代码。通过使用 GtkButton,旧的图标助手小工具也变成了实际的 GtkImage 实例。不幸的是,我们必须在这里自己实现一些 GtkGesture 的魔力,因为 GtkSpinButton 也支持在其按钮上进行鼠标中键和右键单击,而 GtkButton::clicked 仅对单个鼠标主键单击做出反应。

GtkLevelBar

GtkLevelBar 管理一组块并为其分配不同的样式类。在 GTK+3 中,这些块都是 GtkCssGadget 实例。它们都是“哑”的,因为它们不做任何特殊的事情 - 它们只是 CSS 框,仅此而已。这就是为什么将其转换为对所有块使用 GtkWidget 并未在文件大小方面减少太多。

 

GtkProgressBar

GtkProgressBar 使用小工具来表示槽和进度节点。它还使用 PangoLayout 来显示百分比或用户给定的字符串。

在主分支中,槽和进度都是部件,PangoLayout 当然是 GtkLabel。不必监听 GtkWidget::style-changed(它会自动为部件完成)并且不必自己绘制 PangoLayout(现在由 GtkLabel 处理)可节省大约 200 行代码的不错的代码大小。

 

GtkExpander

GtkExpander 比看起来更复杂。在 GTK+3 中,它由 2 个 GtkBoxGadget 组成(这类似于 GtkBox,但不是一个窗口小部件...),一个用于标题窗口小部件左侧的箭头,标题窗口小部件和实际内容窗口小部件。在主分支中,这是使用实际的 GtkBox 和一个用于箭头的 GtkIcon(内部窗口小部件)完成的。我不确定这是否是表达 GtkExpander 功能的最佳方式,例如,我们也可以使用 GtkButton 来组合箭头+标题窗口小部件。
由于 GtkBoxGadget 已经几乎是完美的 GtkBox 克隆,因此这里的代码节省并不是很有趣,但是不必再次监听 GtkWidget::direction-changed 大约可以节省 30 行代码。

意外的 GtkBox 和 GtkButton 子类

GTK+3 包含很多小部件,这些小部件继承自另一个小部件,仅仅是为了看起来和行为方式与它们相同。这里的问题是,这些小部件还会继承父类的所有 API,而这很少是需要的。
对于 GtkBox,GTK+3 中几乎所有子类都是“意外的”,因为实际上将它们用作 GtkBox 没有任何意义,人们通常不会这样做,但它们必须是 GtkBox 子类才能满足 GTK+ 的 GtkContainer 要求。这种小部件的一个例子是 GtkFileChooserWidget。这已经是有史以来最复杂的小部件之一,但是您是否考虑过使用 gtk_container_add()gtk_box_pack_{start,end}() 向其添加小部件?这没什么意义。它是一个具有自己 API 的封闭实体。因此,在 GTK+4 中,它将是一个直接的 GtkWidget 子类,包含一个 GtkBox。也可能不是。这只是您不必关心的实现细节。(顺便说一句:GtkFileChooserButton 在 GTK+3 中是一个 GtkBox
这同样适用于 GtkButton。在 GTK+3 中,GtkButton 有很多子类,它们继承了所有 GtkButton API,但实际上不支持它。如果从 GtkLinkButton 中删除子窗口小部件会发生什么?如果设置 GtkFontButtonGtkButton:label 属性会发生什么?同样,这些是封闭的实体,它们有自己的 API 来设置和获取各种数据,并根据它们更改行为和/或外观,但这并不意味着它们支持所有 GtkButton/GtkContainer 的操作。

通用重构规则和未来

对于此重构工作,我们尝试保持 CSS 节点结构与 GTK+3 中的结构相同,即我们尝试不破坏当前在 testsuite/css/nodes.c 中的 CSS 节点测试。
GTK+ 中一些更复杂的小部件仍然严重依赖于 gadget,将它们移植为仅使用实际小部件将需要大量工作。GtkRange 在历史上是 GTK+ 中最复杂的非容器小部件之一。它既用于滚动条又用于刻度尺,因此将其移植到小部件可能首先需要另一轮重构。
另一个有趣的案例是 GtkNotebook,它结合了 gadget 和小部件的使用。例如,这里我们可以使用真正的 GtkStack 在页面之间切换,并轻松支持页面切换过渡。
当然,展望未来的另一个激动人心的方面是 Carlos 的 wip/carlosg/event-delivery 分支,它摆脱了大量的 GdkWindow 实例,并使窗口小部件输入比以往任何时候都更容易。

列表中的拖放

我最近有机会通过拖放 (DND) 实现 GtkListBox 的重新排序。这并不那么复杂。由于我没有看到拖放操作在列表框中大量使用,因此这里简要概述了要使基本功能正常工作需要什么。

设置拖动源

有两种方法可以将 GTK+ 小部件设置为拖动源(即单击并拖动将启动 DND 操作的位置)。您可以动态决定通过调用 gtk_drag_begin() 来启动拖动。但我们在这里采用更简单的方法:我们只是静态地声明我们的列表行应该是拖动源,并让 GTK+ 处理所有细节

handle = gtk_event_box_new ();
gtk_container_add (GTK_CONTAINER (handle),
        gtk_image_new_from_icon_name ("open-menu-symbolic", 1));
gtk_drag_source_set (handle,
        GDK_BUTTON1_MASK, entries, 1, GDK_ACTION_MOVE);

请注意,我在这里选择创建一个可见的拖动手柄,而不是允许在行的任何位置开始拖动。它看起来像这样

这些条目告诉 GTK+ 我们希望通过此源的拖动提供哪些数据。在我们的例子中,我们不会提供标准的 mime 类型(如 text/plain),而是创建我们自己的私有类型,并提示 GTK+ 我们不想支持拖动到其他应用程序

static GtkTargetEntry entries[] = {
   { "GTK_LIST_BOX_ROW", GTK_TARGET_SAME_APP, 0 }
};

这里的一个小陷阱是,您设置为拖动源的小部件必须具有 GdkWindow。GtkButton 或 GtkEventBox(如本例中所示)将起作用。GTK4 将提供不同的 API 来创建拖动源,从而避免需要窗口。

有了这段代码,您已经可以拖动行了,但到目前为止,还没有地方可以放下它们。让我们解决这个问题。

接受放置

与拖动相反,在拖动中,我们创建了一个可见的拖动手柄,以提示用户支持拖放,我们希望只接受列表中的任何位置的放置。执行此操作的最简单方法是让每行都成为放置目标(即可能接受放置的位置)。

gtk_drag_dest_set (row,
        GTK_DEST_DEFAULT_ALL, entries, 1, GDK_ACTION_MOVE);

这些条目与我们上面讨论的相同。GTK_DEST_DEFAULT_ALL 告诉 GTK+ 为我们处理 DND 操作的所有方面,因此我们可以使此示例保持简单。

现在我们可以在手柄上开始拖动,并且可以将其放置在其他行上。但之后什么也不会发生。我们需要做一些额外的工作才能使重新排序发生。接下来让我们这样做。

传输数据

拖放通常用于在应用程序之间传输数据。GTK+ 使用一个名为 GtkSelectionData 的数据持有者对象来执行此操作。要发送和接收数据,我们需要连接到源和目标端的信号

g_signal_connect (handle, "drag-data-get",
        G_CALLBACK (drag_data_get), NULL);
g_signal_connect (row, "drag-data-received",
        G_CALLBACK (drag_data_received), NULL);

在源端,当 GTK+ 需要数据将其发送到放置目标时,会发出 drag-data-get 信号。在我们的例子中,该函数只会将指向源小部件的指针放入选择数据中

gtk_selection_data_set (selection_data,
        gdk_atom_intern_static_string ("GTK_LIST_BOX_ROW"),
        32,
        (const guchar *)&widget,
        sizeof (gpointer));

在目标端,当 GTK+ 将其接收到的数据传递给应用程序时,会在放置目标上发出 drag-data-received。在我们的例子中,我们将从选择数据中提取指针,并重新排序该行。

handle = *(gpointer*)gtk_selection_data_get_data (selection_data);
source = gtk_widget_get_ancestor (handle, GTK_TYPE_LIST_BOX_ROW);

if (source == target)
  return;

source_list = gtk_widget_get_parent (source);
target_list = gtk_widget_get_parent (target);
position = gtk_list_box_row_get_index (GTK_LIST_BOX_ROW (target));

g_object_ref (source);
gtk_container_remove (GTK_CONTAINER (source_list), source);
gtk_list_box_insert (GTK_LIST_BOX (target_list), source, position);
g_object_unref (source);

这里唯一的技巧是,我们需要在从其父容器中删除小部件之前对其进行引用,以防止其被最终确定。

有了这个,我们就有了可重新排序的行。耶!

最后一步,让我们让它看起来更好看。

漂亮的拖动图标

到目前为止,在拖动过程中,您只会看到光标,这并没有什么帮助,而且不是很漂亮。预期的行为是拖动行的可视表示。

要实现这一点,我们连接到拖动源上的 drag-begin 信号

g_signal_connect (handle, "drag-begin",
        G_CALLBACK (drag_begin), NULL);

...并做一些额外的工作来创建一个漂亮的“拖动图标”

row = gtk_widget_get_ancestor (widget, GTK_TYPE_LIST_BOX_ROW);
gtk_widget_get_allocation (row, &alloc);
surface = cairo_image_surface_create (CAIRO_FORMAT_ARGB32,
                                      alloc.width, alloc.height);
cr = cairo_create (surface);
gtk_widget_draw (row, cr);

gtk_drag_set_icon_surface (context, surface);

cairo_destroy (cr);
cairo_surface_destroy (surface);

这看起来比实际情况更复杂——我们正在创建一个正确大小的 cairo surface,并将行小部件渲染到其中(信号是在手柄上发出的,因此我们必须找到作为祖先的行)。

不幸的是,这还没有产生完美的结果,因为列表框行通常不渲染背景或框架。为了解决这个问题,我们可以暂时向该行的样式上下文添加自定义样式类,并使用一些自定义 CSS 来确保我们获得背景和框架

context = gtk_widget_get_style_context (row);
gtk_style_context_add_class (context, "drag-icon");
gtk_widget_draw (row, cr);
gtk_style_context_remove_class (context, "drag-icon")

作为额外的改进,我们可以在 surface 上设置一个偏移量,以防止在拖动开始时出现视觉“跳跃”,方法是在 gtk_drag_set_icon_surface() 调用之前放置此代码

gtk_widget_translate_coordinates (widget, row, 0, 0, &x, &y);
cairo_surface_set_device_offset (surface, -x, -y);


瞧!

下一步

本文仅展示了通过拖放进行行重新排序的最简单设置。可以进行许多改进,其中一些很容易,一些则不太容易。

一个明显的增强功能是允许在同一应用程序中的不同列表之间拖动。这只是在 drag_data_received() 调用中谨慎处理列表小部件的问题,而且我在此处展示的代码应该已经可以用于此目的。

另一个改进之处是,根据哪个边缘更近,删除目标行之前或之后的行。同时,您可能需要修改拖放目标的高亮显示,以指示拖放将发生的边缘。这可以通过不同的方式完成,但所有这些都需要监听拖动事件和处理事件坐标,这不是我在这里想深入研究的内容。

最后,是在拖动期间滚动列表。这对于长列表很重要,如果您想将一行从顶部拖到底部——如果列表不滚动,您必须以页面增量的方式执行此操作,这太麻烦了。实现此操作的最简单方法可能是将拖动目标移动到列表部件本身,而不是单独的行。

参考文献

GTK+ Inspector

许多 GTK+ 用户和开发者已经听说过 GTK+ Inspector,这是一个用于检查、修改和理解 GTK+ 应用程序的工具。Inspector 功能非常强大,它允许主题设计者即时测试 CSS 更改并放大部件以查看最微小的细节,让开发者检查应用程序部件及其属性,并让用户玩转(并最终破坏)应用程序。

在本文中,我们将探讨 GTK+ Inspector,并展示您可以用它做什么。

前言

由于 Inspector 是一个调试工具,默认情况下它是禁用的。要开始使用 Inspector,您首先必须启用它。您可以使用 DConf Editor 轻松完成此操作

Enabling the Gtk+ Inspector with DConf Editor
使用 DConf Editor 启用 GTK+ Inspector

或者,您可以使用终端来启用它。为此,请运行以下命令

$ gsettings set org.gtk.Settings.Debug enable-inspector-keybinding true

完成!Inspector 现在已启用!

打开 Inspector

现在 Inspector 已启用,您需要运行它。Inspector 始终与应用程序关联。让我们以 GNOME 日历为例

GNOME Calendar
GNOME 日历应用程序

有多种方法可以启动 Inspector。您可以在使用应用程序时通过键入 <Ctrl> + <Shift> + D 来打开它(或者按<Ctrl> + <Shift> + I 来自动选择鼠标指针下的部件)。或者,您可以使用环境变量 GTK_DEBUG=interactive 从终端启动应用程序。

Inspector 将打开,您将看到以下窗口

Inspector on Calendar
GNOME 日历上的 Inspector 窗口

这就是您需要做的全部。现在,让我们探索 Inspector 提供的各种功能。

探索 Inspector

首先,大量的按钮和选项卡会让那些不精通应用程序检查的人感到困惑。以下是选项卡的快速解释,按顺序排列

  • 对象:公开应用程序的部件,并允许编辑属性和查看有关每个部件的详细信息。如下所述。
  • 统计信息:显示应用程序的杂项统计信息。您需要使用 GOBJECT_DEBUG=instance-count 运行应用程序。
  • 资源:显示嵌入在应用程序二进制文件中的各种资源,例如自定义图标或 GtkBuilder 文件等。
  • CSS:允许实时测试 CSS。如下所述。
  • 视觉:控制应用程序的一些视觉方面,例如文本方向、深色/浅色变体、主题、缩放因子等。
  • 常规:显示有关 GTK+ 应用程序(及其运行的会话)的杂项信息。

让我们剖析 GTK+ Inspector 的主窗口

Inspector window
Inspector 主窗口

Inspector 的这 4 个带注释的部分是最常用的。主题设计者会想要检查 (3) 和 (4),而开发者通常使用 (1) 和 (2)。

检查部件

对于开发者来说,Inspector 的实用性在于它可以让您更改屏幕上任何部件的属性。让我们从单击第一个按钮并使用鼠标光标选择一个部件开始

Selecting widgets
使用 Inspector 选择部件

您现在可以通过浏览 对象 > 属性 选项卡轻松更改该部件的属性。例如,您可以更改部件的可见性、标签的文本等等!

Editing a widget property
编辑部件属性

现在您知道如何检查 GTK+ 应用程序了,请随意尝试并探索应用程序的组织方式。更改部件的属性并查看会发生什么。大多数情况下,这是安全的,不会破坏您的 GNOME 会话或冻结您的计算机!

编辑 CSS

Inspector 对于设计者来说也是一个强大的工具。它最大的功能之一是实时 CSS 编辑器。让我们从转到 CSS 选项卡开始

CSS Editor
Inspector CSS 编辑器视图

让我们玩转 CSS!粘贴以下 CSS 代码,看看会发生什么

window stack {
    background-color: orange;
}

哇!窗口变得陌生了!该 CSS 代码更改 GtkWindow 内任何 GtkStack 部件的背景颜色。如果您想了解有关 CSS 选择器以及 GTK+ 如何使用 CSS 进行主题设置的更多信息,本文末尾有一些有用的链接。

谨慎的读者可能会问:CSS 元素的层次结构是什么?如何查看哪些 CSS 元素可用?

不用担心!GTK+ Inspector 允许您在 对象 > CSS 节点 选项卡中轻松检查 CSS 层次结构。

CSS Nodes
CSS 节点选项卡

GTK+ 部件具有已记录的 CSS 名称。您可以浏览 GTK+ 文档以查看部件的组织方式,以及如何使用 CSS 控制部件的各个方面。

不确定您的 CSS 更改是否完美?让我们放大部件以确保我们不会错过任何细节

Zooming widget using Magnifier
使用放大镜选项卡缩放部件

看起来不错?加入 -design 并与社区分享您出色的 CSS 代码片段!

总结

虽然本文探讨了 GTK+ Inspector 的一些最大方面,但这绝不是 Inspector 所有功能的详尽列表。但是,在阅读本文后,您应该可以打开 Inspector 并自行探索其更强大的功能。

有疑问?评论?建议?请过来留言,加入 GNOME IRC 网络上的 #gtk+ 频道,让我们知道您的想法!

有用的链接