背景

最近想简单粗暴的用 Python 写一个 GUI 的小程序。因为 Tkinter 是 Python 自带的 GUI 解决方案,为了部署方便,就直接选择了 Tkinter。
本来觉得 GUI 发展这么多年以来,就算功能简陋,但也应该大差不差才对,何况我的需求本就十分简单。但事实上 Tkinter 的简陋仍然超出了我的想象。因此想写几篇文章,记录一下踩到的坑,权当吐槽。

同系列的其它文章:
Tkinter 吐槽之一:多线程与 UI 交互

问题

在一些情况下,Tkinter 原生的组件因为过于简单而无法满足我们的需求,所以可能需要通过 Frame 自定义一些组件,并响应一些事件。

比如,自定义一个带多个信息的可点击的 Frame:

import tkinter as tk

class PersonFrame(tk.Frame):
    def __init__(self, master, name, age):
        super().__init__(master=master, width=100, borderwidth=2, padx=5)

        tk.Label(self, text=name).pack(side=tk.LEFT, fill=tk.X)
        tk.Label(self, text=age, foreground='red').pack(side=tk.RIGHT)

        self.bind('<Button-1>', self._on_click)

    def _on_click(self, _):
        print('clicked')

app = tk.Tk()
frame = PersonFrame(app, 'Tom', '27').pack(fill=tk.X)
frame = PersonFrame(app, 'Jerry', '16').pack(fill=tk.X)
app.mainloop()

这个例子看上去一切都很美好,通过自定义 Frame 的方式创建了一个组件,并且绑定了组件的 鼠标单击 事件。

但是经过实际测试发现,只有当鼠标点击在 Frame 的空白位置时,才会触发 on_click 的调用。如果鼠标点在文字上,并不会有任何效果。这说明, bind 操作只对 Frame 本身生效,并不会对覆盖在其上的子元素生效,即使逻辑上存在父子关系。
因此,这个自定义的组件没有办法真正意义上被当作可点击的组件使用。

方案

根本原因,在于 Tkinter 并没有一种类似 事件冒泡 的机制,从叶子节点的组件开始向上传递。而只有 bindbind_allbind_class 这三种形式。很显然,这三种形式都是无法满足要求的。

那么思路就演变为,有没有可能把 Frame 及 Frame 的所有子元素,都绑定上同一个事件的方法呢?

def bind_recursively(widget: tk.Misc, event: str, callback: Callable):
    """Binds event recursively with it's all children. So a widget and all
    it's children will share one event and callback.

    """
    widget.bind(event, callback)
    for w in widget.children.values():
        bind_recursively(w, event, callback)

这段代码就是采用类似的思路,首先找到 widget 的所有子元素,然后以递归的方式绑定同一个事件的 callback。这样可以保证一个 widget 中的所有子元素都可以响应同样的事件。从而实现在哪里点击都一致的效果。

总结

工程设计其实就是这样,找到一些理论可行的方法,然后通过抽象、封装的方式转换成一个优雅的解决方案。 Tkinter 原生提供的东西非常有限,但是可以借鉴很多其他的思路来进行扩展,从而满足我们的需求。

07-02 00:10