GTK 2.20 中的input shape mask问题
ibus的GNOME Shell扩展

当GtkWindow绘制背景遇上Resize

Tiger Soldier posted @ 2011年5月02日 15:24 in 程序设计 with tags gtk , 6695 阅读

缘起

在实现OSD Lyrics的滚动模式的时候,我发现在拖动改变窗口大小时,窗口内容会变得支离破碎。进一步实验发现,只要是直接在窗口上绘图,必然会导致这个结果。例如下面的python代码:

 

import gtk
import cairo

def WindowExpose(widget, event):
    """Draw a circle on window"""
    import math
    print event
    drawable = widget.get_window()
    cr = drawable.cairo_create()
    width, height = drawable.get_size()
    cr.set_source_rgb(1.0, 1.0, 1.0)
    cr.paint()
    cr.set_source_rgb(0.0, 0.0, 0.0)
    cr.arc(width / 2, height / 2,
           (width + height) / 4,
           0, 2 * math.pi)
    cr.close_path()
    cr.fill()

if __name__ == '__main__':
    win = gtk.Window(gtk.WINDOW_TOPLEVEL)
    win.set_app_paintable (True)
    win.connect('expose-event',
                WindowExpose)
    win.show()
    gtk.main()

这段代码在触发expose事件的时候在窗口上画一个黑色的圆。运行时会有如下效果:

窗口背景上画圆

窗口背景上画圆

一切看起来很美好。然而当我们拉伸窗口改变大小,就不是好么好看的了:

改变大小,背景支离破碎

改变大小,背景支离破碎

为什么会这样?原因稍后解释,先给出解法。

解决

在网上试了几个关键词,都没有找到想要的答案。后来想到Chromium窗口也是用GTK+来绘制的,而测试发现Chromium没有这个问题,于是就在Chomium的代码库里找到了解决方案。只要在窗口里面加入任意容器,填满整个窗口,并给容器设置redraw_on_allocation属性,在容器上作画:

import gtk
import cairo

def WindowExpose(widget, event):
    """Draw a circle on window"""
    import math
    drawable = widget.get_window()
    cr = drawable.cairo_create()
    width, height = drawable.get_size()
    cr.set_source_rgb(1.0, 1.0, 1.0)
    cr.paint()
    cr.set_source_rgb(0.0, 0.0, 0.0)
    cr.arc(width / 2, height / 2,
           (width + height) / 4,
           0, 2 * math.pi)
    cr.close_path()
    cr.fill()

if __name__ == '__main__':
    win = gtk.Window(gtk.WINDOW_TOPLEVEL)
    win.set_app_paintable (True)
    align = gtk.Alignment(0.0, 0.0, 1.0, 1.0)
    align.set_redraw_on_allocate(True)
    align.connect('expose-event',
                  WindowExpose)
    win.add(align)
    win.show_all()
    gtk.main()

如此一来改变窗口大小,背景也依然完好了:

美好的拉伸效果

美好的拉伸效果

这里的关键是需要使用容器,以及 set_redraw_on_allocate 。为什么?先看看问题是怎么出现的。

探因

根据之前支离破碎的图像,不难猜到,在改变窗口大小时,绘图区域被限制在了扩大的区域内,通过在expose事件中加入对cairo的clip区域检测,这个猜测是正确的。正是因为在绘制新图像时,旧的依然保持在上面,才会如此支离破碎。

具体的代码在 GdkWindowgdk_window_set_cairo_clip 中找到:

static void
gdk_window_set_cairo_clip (GdkDrawable *drawable,
                           cairo_t *cr)
{
  GdkWindowObject *private = (GdkWindowObject*) drawable;

  if (!private->paint_stack)
    {
      cairo_reset_clip (cr);

      cairo_save (cr);
      cairo_identity_matrix (cr);

      cairo_new_path (cr);
      gdk_cairo_region (cr, private->clip_region_with_children);

      cairo_restore (cr);
      cairo_clip (cr);
    }
  else
    {
      /* ... */
    }
}

在调用 gdk_drawable_cairo_create 时会调用 gdk_window_set_cairo_clip ,结果是当存在 paint_stack 时,得到的 cr 对象会被clip成 paint_stack 的区域,以后的绘画就只能在里面进行了。

如此一来,要解决这个问题有两条路可走:绕过 cr 的clip,或者绕过 paint_stack

初败

要绕过cairo对象的clip,自然是要使用 cairo_reset_clip 了。然而实验却很可耻地失败鸟:无论在哪个地方设置 cairo_reset_clip ,都没有半点作用。为虾米啊?

答案要从GTK+的绘图机制里找。既然已经清空了 cr 的clip,那么在 cr 上绘画的图像应该是完整的。那么问题就出在把 cr 上画的内容搬运到 GdkWindow 上的时候了。为了防止绘图时出现闪烁,GTK+的绘图默认是双重缓冲。在绘图时得到的 cr 并不是直接作用于 GdkWindow ,而是创建了一个临时画布,在画面上作画。expose事件处理返回后,GTK+会将临时画布的内容复制到 GdkWindow 上。这个复制的过程是调用 gdk_window_end_paint 来完成的:

void
gdk_window_end_paint (GdkWindow *window)
{
  /* ... */
  paint = private->paint_stack->data;

  private->paint_stack = g_slist_delete_link (private->paint_stack,
                                              private->paint_stack);

  gdk_region_get_clipbox (paint->region, &clip_box);

  tmp_gc = _gdk_drawable_get_scratch_gc (window, FALSE);

  x_offset = -private->abs_x;
  y_offset = -private->abs_y;

  if (!paint->uses_implicit)
    {
      gdk_window_flush_outstanding_moves (window);

      full_clip = gdk_region_copy (private->clip_region_with_children);
      gdk_region_intersect (full_clip, paint->region);
      _gdk_gc_set_clip_region_internal (tmp_gc, full_clip, TRUE); /* Takes ownership of full_clip */
      gdk_gc_set_clip_origin (tmp_gc, - x_offset, - y_offset);
      gdk_draw_drawable (private->impl, tmp_gc, paint->pixmap,
                         clip_box.x - paint->x_offset,
                         clip_box.y - paint->y_offset,
                         clip_box.x - x_offset, clip_box.y - y_offset,
                         clip_box.width, clip_box.height);
    }

  /* ... */
}

看来GTK+对于clip的保证还是很严密的。想在 cr 上绕过clip?Too simple, sometimes naive.

再败

清空 cr 的clip走不通,只能另寻出路。在看到Chromium的代码后,我很疑惑为什么要多加个 GtkAlignment ,直接给窗口使用 gtk_widget_set_redraw_on_alignment 不就好了吗?

然而事情远没有这么美好,实验再一次可耻地失败鸟。何解?

深入

先来看看 gtk_widget_set_redraw_on_alignment 是干啥的。开发文档 对这个函数的介绍如下:

Sets whether the entire widget is queued for drawing when its size allocation changes. By default, this setting is TRUE and the entire widget is redrawn on every size change. If your widget leaves the upper left unchanged when made bigger, turning this setting off will improve performance.

概括一下,就是如果widget在扩大时原有的部分不变,就设为 FALSE ,否则设为 TRUE 。按理来说,给 GtkWindow 设置为 TRUE 应该可以解决问题才对,为什么不行?且看看这个属性的作用是在什么地方生效的。不难发现是在 gtk_widget_size_allocate 中:

void
gtk_widget_size_allocate (GtkWidget     *widget,
                          GtkAllocation *allocation)
{
  /* ... */
  old_allocation = widget->allocation;
  /* ... */
  size_changed = (old_allocation.width != real_allocation.width ||
                  old_allocation.height != real_allocation.height);
  /* ... */
  g_signal_emit (widget, widget_signals[SIZE_ALLOCATE], 0, &real_allocation);

  if (gtk_widget_get_mapped (widget))
    {
      if (!gtk_widget_get_has_window (widget) && GTK_WIDGET_REDRAW_ON_ALLOC (widget) && position_changed)
        {
          /* Invalidate union(old_allaction,widget->allocation) in widget->window
           */
          GdkRegion *invalidate = gdk_region_rectangle (&widget->allocation);
          gdk_region_union_with_rect (invalidate, &old_allocation);

          gdk_window_invalidate_region (widget->window, invalidate, FALSE);
          gdk_region_destroy (invalidate);
        }

      if (size_changed)
        {
          if (GTK_WIDGET_REDRAW_ON_ALLOC (widget))
            {
              /* Invalidate union(old_allaction,widget->allocation) in widget->window and descendents owned by widget
               */
              GdkRegion *invalidate = gdk_region_rectangle (&widget->allocation);
              gdk_region_union_with_rect (invalidate, &old_allocation);

              gtk_widget_invalidate_widget_windows (widget, invalidate);
              gdk_region_destroy (invalidate);
            }
        }
    }

  /* ... */
}

可以看到,一旦大小发生了变化,打上 REDRAW_ON_ALLOC 标记的widget都会受到 gtk_widget_invalidate_widget_windows 调用,从而将整个widget在size_allocate之前和之后的区域的并集重绘。这样的结果,是在收到 size-allocate 信号后触发 expose-event 信号,从而expose响应函数收到的clip区域是之前分配的矩形区域和新分配的矩形区域之并,必然覆盖整个widget的范围。

然而 GtkWindow 却没有这样的效果。通过简单的调试代码可以发现,对于 GtkWindow 内的任意widget,都会在 size-allocate 信号后收到 expose-event 信号,而唯独 GtkWindowexpose-event 信号是在 size-allocate 之前收到的。也就是说在 gtk_size_allocate 里, gtk_widget_invalidate_widget_windows 并没有被执行。

为了解开其中原因,我使用gdb跟踪进入 gtk_size_allocate 中,发现只要使用鼠标拖动改变窗口大小, size_changed 一定是 FALSE 。观察得知在调用之前 widget->allocation 就已经被更新了。到此原因终于揭晓:在拖动窗口改变大小时,widget的allocation在 gtk_size_allocate 之前就被更新,造成 GTK_WIDGET_REDRAW_ON_ALLOC 的判断从来没有被执行过。

不出意外,修改 widget->allocation 的真凶是 gtk_window_configure_event

gtk_window_configure_event (GtkWidget         *widget,
                            GdkEventConfigure *event)
{
  GtkWindow *window = GTK_WINDOW (widget);
  gboolean expected_reply = window->configure_request_count > 0;

  if (window->configure_request_count > 0)
    {
      window->configure_request_count -= 1;
      gdk_window_thaw_toplevel_updates_libgtk_only (widget->window);
    }

  if (!expected_reply &&
      (widget->allocation.width == event->width &&
       widget->allocation.height == event->height))
    {
      gdk_window_configure_finished (widget->window);
      return TRUE;
    }

  window->configure_notify_received = TRUE;

  widget->allocation.width = event->width;
  widget->allocation.height = event->height;

  _gtk_container_queue_resize (GTK_CONTAINER (widget));

  return TRUE;
}

当改变窗口大小时,实际操作是由窗口管理器(WM)完成的。WM判断新窗口的大小,然后直接请求X Window改变窗口大小。大小被改变后X Window会发送configure事件。这个事件被GTK+捕捉,并交给 gtk_window_configure_event 处理。 gtk_window_configure_event 更新窗口的allocation,然后通过 _gtk_container_queue_resize 触发 gtk_widget_size_allocate 。当 gtk_widget_size_allocate 起作用时,自然会觉得widget的大小没有发生改变了。

对于其他widget,窗口管理器不会改变它们的大小。使它们大小发生变化是GTK+调用 gtk_widget_size_allocate 完成的。这些widget不会自己处理configure事件,绝大多数也不会收到这一事件。

我不确定这是一个Bug,还是有意而为之,还是在这个机制下无法得出更好的方案,至少我们还有方法可以绕过:)

补遗

接下来还有一些零碎的东西。

为什么要用容器作画

因为窗口里必然要加入其他widget,背景要画在widget之下。既然窗口本身不行,那么将一个容器放入窗口中,再把原来应该放入窗口中的widgets放入到这个容器中。这样一来窗口的容器功能没有被破坏,而加入的容器介于widget和窗口之间,它的地位和窗口本身的地位没有不同。由于容器一般没有自己的 GdkWindow ,在容器上作画用的是 GtkWindowGdkWindow ,也就是说是在相同的X Window下作画。只要容器填满整个窗口,作画效果是一样的。

如果窗口里不打算加入任何widget,那么可以使用一些其他没有独立 GdkWindow 的widget,例如 GtkDrawingArea

其他解决方案

除了上面用的解决方案,我还尝试过其他解决方案。鉴于clip的最后一关是 gdk_window_end_paint ,它是由于双重缓冲才调用的,因此可以禁用双重缓冲。缺点有两个:一是分步作画时会产生闪烁(目前没遇到);二是expose在窗口縮小时不会调用,因此问题只解决了一半。

另一个方法是在expose时判断左上角是否在expose的区域中。如果不是,说明是缩放窗口造成的expose,放弃绘制并调用 gtk_widget_queue_draw ,这样会产生一个绘制整个区域的expose事件。这个方法的缺点也是很明显的,首先它同样没有解决缩小时不会重绘的问题,其次作图会产生延迟,加大开销。

感想

一个小问题竟然扯出这么一大堆东西,为了解决它并探明原因,花了我一天多时间。不得不说GTK+的文档严重不足,缺少对各种典型案例的系统教程。关于窗口上绘图以及不规则窗口应该是一个典型的应用了,然而在网上能找到的内容都很零碎,其中没有一个提到了这个问题。

还有就是开源的力量。如果Chromium不开放源代码,我可能很难找到好的解决方案。如果GTK+没有开放源代码,可能我永远也不会明白为什么。

另外,在一个庞大的,事件驱动的库里想找到某个现象的前因后果不是一般地困难啊 :(


登录 *


loading captcha image...
(输入验证码)
or Ctrl+Enter