设计自己的Trait编辑器

在前面的章节中我们知道,每种trait属性都对应有缺省的trait编辑器,如果在View中不指定编辑器的话,将使用缺省的编辑器构成界面。每个编辑器都可以对应有多个后台,目前支持的后台界面库有pyQt和wxPython。每种编辑器都可以有四种样式:simple, custom, text, readonly。

traitsUI为我们提供了很丰富的编辑器库,以至于我们很少有自己设计编辑器的需求,然而如果我们能方便地设计自己的编辑器,将能制作出更加专业的程序界面。

本章节将简要介绍trait编辑器的工作原理;并且制作一个新的trait编辑器,用以显示matplotlib提供的绘图控件;然后以此控件制作一个通用的绘制CSV文件数据图像的小工具。

Trait编辑器的工作原理

我们先来看下面这个小程序,它定义了一个TestStrEditor类,其中有一个名为test的trait属性,其类型为Str,在view中用Item定义要在界面中显示test属性,但是没有指定它所使用的编辑器(通过editor参数)。当执行t.configure_traits()时,traits库将自动为我们挑选文本编辑框控件作为test属性的编辑器:

from enthought.traits.api import *
from enthought.traits.ui.api import *

class TestStrEditor(HasTraits):
    test = Str
    view = View(Item("test"))

t = TestStrEditor()
t.configure_traits()

使用文本编辑框控件编辑test属性

Traits库的路径

下面的介绍需要查看traits库的源程序,因此首先你需要知道它们在哪里:

traits: site-packages\Traits-3.2.0-py2.6-win32.egg\enthought\traits, 以下简称 %traits%

traitsUI: site-packages\Traits-3.2.0-py2.6-win32.egg\enthought\traits\UI, 以下简称 %ui%

wx后台界面库: site-packages\TraitsBackendWX-3.2.0-py2.6.egg\enthought\traitsui\wx, 以下简称 %wx%

Str对象的缺省编辑器通过其create_editor方法获得:

>>> from enthought.traits.api import *
>>> s = Str()
>>> ed = s.create_editor()
>>> type(ed)
<class 'enthought.traits.ui.editors.text_editor.ToolkitEditorFactory'>
>>> ed.get()
{'auto_set': True,
 'custom_editor_class': <class 'enthought.traits.ui.wx.text_editor.CustomEditor'>,
 'enabled': True,
 'enter_set': False,
 'evaluate': <enthought.traits.ui.editors.text_editor._Identity object at 0x0427F1B0>,
 'evaluate_name': '',
 'format_func': None,
 'format_str': '',
 'invalid': '',
 'is_grid_cell': False,
 'mapping': {},
 'multi_line': True,
 'password': False,
 'readonly_editor_class': <class 'enthought.traits.ui.wx.text_editor.ReadonlyEditor'>,
 'simple_editor_class': <class 'enthought.traits.ui.wx.text_editor.SimpleEditor'>,
 'text_editor_class': <class 'enthought.traits.ui.wx.text_editor.SimpleEditor'>,
 'view': None}

create_editor方法的源代码可以在%traits%trait_types.py中的BaseStr类的定义中找到。create_editor方法得到的是一个text_editor.ToolkitEditorFactory类:

enthought.traits.ui.editors.text_editor.ToolkitEditorFactory

在%ui%editorstext_editor.py中你可以找到它的定义,它继承于EditorFactory类。EditorFactory类的代码在%ui%editor_factory.py中。EditorFactory类是Traits编辑器的核心,通过它和后台界面库联系起来。让我们来详细看看EditorFactory类中关于控件生成方面的代码:

class EditorFactory ( HasPrivateTraits ):
    # 下面四个属性描述四个类型的编辑器的类
    simple_editor_class = Property
    custom_editor_class = Property
    text_editor_class   = Property
    readonly_editor_class = Property

    # 用simple_editor_class创建实际的控件
    def simple_editor ( self, ui, object, name, description, parent ):
        return self.simple_editor_class( parent,
                                         factory     = self,
                                         ui          = ui,
                                         object      = object,
                                         name        = name,
                                         description = description )

    # 这是类的方法,它通过类的以及父类自动找到与其匹配的后台界面库中的控件类
    @classmethod
    def _get_toolkit_editor(cls, class_name):
        editor_factory_classes = [factory_class for factory_class in cls.mro()
                                  if issubclass(factory_class, EditorFactory)]
        for index in range(len( editor_factory_classes )):
            try:
                factory_class = editor_factory_classes[index]
                editor_file_name = os.path.basename(
                                sys.modules[factory_class.__module__].__file__)
                return toolkit_object(':'.join([editor_file_name.split('.')[0],
                                             class_name]), True)
            except Exception, e:
                if index == len(editor_factory_classes)-1:
                    raise e
        return None

    # simple_editor_class属性的get方法,获取属性值
    def _get_simple_editor_class(self):
        try:
            SimpleEditor = self._get_toolkit_editor('SimpleEditor')
        except:
            SimpleEditor = toolkit_object('editor_factory:SimpleEditor')
        return SimpleEditor

EditorFactory的对象有四个属性保存后台编辑器控件的类:simple_editor_class, custom_editor_class, text_editor_class, readonly_editor_class。例如前面例子中的ed对象的simple_editor_class为<class 'enthought.traits.ui.wx.text_editor.SimpleEditor'>,我们看到它用的是wx后台界面库中的text_editor中的SimpleEditor类,稍后我们将看看其内容。

EditorFactory是通过其类方法_get_toolkit_editor计算出所要用后台界面库中的类的。由于_get_toolkit_editor是类方法,它的第一个参数cls就是类本身。当调用text_editor.ToolkitEditorFactory._get_toolkit_editor()时,cls就是text_editor.ToolkitEditorFactory类。通过调用cls.mro获得cls以及其所有父类,然后一个一个地查找,从后台界面库中找到与之匹配的类,这个工作由toolkit_object函数完成。其源代码可以在%ui%toolkit.py中找到。

因为后台界面库中的类的组织结构和traits.ui是一样的,因此不需要额外的配置文件,只需要几个字符串替代操作就可以将traits.ui中的EditorFactory类和后台界面库中的实际的编辑器类联系起来。下图显示了traits.ui中的EditorFactory和后台界面库的关系。

traits.ui中的EditorFactory和后台界面库的关系

wx后台界面库中定义了所有编辑器控件,在 %wx%text_editor.py 中你可以找到产生文本框控件的类 text_editor.SimpleEditor。类名表示了控件的样式:simple, custom, text, readonly,而其文件名(模块名)则表示了控件的类型。下面是 text_editor.SimpleEditor的部分代码:

class SimpleEditor ( Editor ):

    # Flag for window styles:
    base_style = 0

    # Background color when input is OK:
    ok_color = OKColor

    # Function used to evaluate textual user input:
    evaluate = evaluate_trait

    def init ( self, parent ):
        """ Finishes initializing the editor by creating the underlying toolkit
 widget.
 """
        factory       = self.factory
        style         = self.base_style
        self.evaluate = factory.evaluate
        self.sync_value( factory.evaluate_name, 'evaluate', 'from' )

        if (not factory.multi_line) or factory.password:
            style &= ~wx.TE_MULTILINE

        if factory.password:
            style |= wx.TE_PASSWORD

        multi_line = ((style & wx.TE_MULTILINE) != 0)
        if multi_line:
            self.scrollable = True

        if factory.enter_set and (not multi_line):
            control = wx.TextCtrl( parent, -1, self.str_value,
                                   style = style | wx.TE_PROCESS_ENTER )
            wx.EVT_TEXT_ENTER( parent, control.GetId(), self.update_object )
        else:
            control = wx.TextCtrl( parent, -1, self.str_value, style = style )

        wx.EVT_KILL_FOCUS( control, self.update_object )

        if factory.auto_set:
            wx.EVT_TEXT( parent, control.GetId(), self.update_object )

        self.control = control
        self.set_tooltip()

真正产生控件的程序是在init方法中,此方法在产生界面时自动被调用,注意方法名是init,不要和对象初始化方法__init__搞混淆了。

制作matplotlib的编辑器

Enthought的官方绘图库是采用Chaco,不过如果你对matplotlib库更加熟悉的话,将matplotlib的绘图控件嵌入TraitsUI界面中将是非常有用的。下面先来看一下嵌入matplotlib控件的完整源代码:

# -*- coding: utf-8 -*-
# file name: mpl_figure_editor.py
import wx
import matplotlib
# matplotlib采用WXAgg为后台,这样才能将绘图控件嵌入以wx为后台界面库的traitsUI窗口中
matplotlib.use("WXAgg")
from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigureCanvas
from matplotlib.backends.backend_wx import NavigationToolbar2Wx
from enthought.traits.ui.wx.editor import Editor
from enthought.traits.ui.basic_editor_factory import BasicEditorFactory

class _MPLFigureEditor(Editor):
    """
 相当于wx后台界面库中的编辑器,它负责创建真正的控件
 """
    scrollable = True

    def init(self, parent):
        self.control = self._create_canvas(parent)
        self.set_tooltip()
        print dir(self.item)

    def update_editor(self):
        pass

    def _create_canvas(self, parent):
        """
 创建一个Panel, 布局采用垂直排列的BoxSizer, panel中中添加
 FigureCanvas, NavigationToolbar2Wx, StaticText三个控件
 FigureCanvas的鼠标移动事件调用mousemoved函数,在StaticText
 显示鼠标所在的数据坐标
 """
        panel = wx.Panel(parent, -1, style=wx.CLIP_CHILDREN)
        def mousemoved(event):
            panel.info.SetLabel("%s, %s" % (event.xdata, event.ydata))        
        panel.mousemoved = mousemoved
        sizer = wx.BoxSizer(wx.VERTICAL)
        panel.SetSizer(sizer)
        mpl_control = FigureCanvas(panel, -1, self.value)
        mpl_control.mpl_connect("motion_notify_event", mousemoved)
        toolbar = NavigationToolbar2Wx(mpl_control)
        sizer.Add(mpl_control, 1, wx.LEFT | wx.TOP | wx.GROW)          
        sizer.Add(toolbar, 0, wx.EXPAND|wx.RIGHT)
        panel.info = wx.StaticText(parent, -1)
        sizer.Add(panel.info)

        self.value.canvas.SetMinSize((10,10))
        return panel

class MPLFigureEditor(BasicEditorFactory):
    """
 相当于traits.ui中的EditorFactory,它返回真正创建控件的类
 """    
    klass = _MPLFigureEditor

if __name__ == "__main__":
    from matplotlib.figure import Figure    
    from enthought.traits.api import HasTraits, Instance
    from enthought.traits.ui.api import View, Item
    from numpy import sin, cos, linspace, pi

    class Test(HasTraits):
        figure = Instance(Figure, ())
        view = View(
            Item("figure", editor=MPLFigureEditor(), show_label=False),
            width = 400,
            height = 300,
            resizable = True)
        def __init__(self):
            super(Test, self).__init__()
            axes = self.figure.add_subplot(111)
            t = linspace(0, 2*pi, 200)
            axes.plot(sin(t))

    Test().configure_traits()

此程序的运行结果如下:

在TraitsUI界面中嵌入的matplotlib绘图控件

由于我们的编辑器没有simple等四种样式,也不会放到wx后台界面库的模块中,因此不能采用上节所介绍的自动查找编辑器类的办法。traits.ui为我们提供一个一个方便的类来完成这些操作:BasicEditorFactory。它的源程序可以在 %ui%basic_editor_factory.py中找到。下面是其中的一部分:

class BasicEditorFactory ( EditorFactory ):
    klass = Any

    def _get_simple_editor_class ( self ):
        return self.klass
    ...

它通过重载EditorFactory中的simple_editor_class属性,直接返回创建控件的库klass。MPLFigureEditor继承于BasicEditorFactory,指定创建控件的类为_MPLFigureEditor。

和text_editor.SimpleEditor一样,从Editor类继承,在_MPLFigureEditor类的init方法中,创建实际的控件。因为Editor类中有一个update_editor方法,在其对应的trait属性改变是会被调用,而我们的绘图控件不需要这个功能,所以重载update_editor,让它不做任何事情。

matplotlib中,在创建FigureCanvas时需要指定与其对应的Figure对象:

mpl_control = FigureCanvas(panel, -1, self.value)

这里self.value就是这个Figure对象,它在MVC的模型类Test中被定义为:

figure = Instance(Figure, ())

控件类可以通过self.value获得与其对应的模型类中的对象。因此_MPLFigureEditor中的self.value和Test类中的self.figure是同一个对象。

_create_canvas方法中的程序编写和在一个标准的wx窗口中添加控件是一样的,界面库相关的细节不是本书的重点,因此不再详细解释了。读者可以参照matplotlib和wxPython的相应文档。

CSV数据绘图工具

下面用前面介绍的matplotlib编辑器制作一个CSV数据绘图工具。用此工具打开一个CSV数据文档之后,可以绘制多个X-Y坐标图。用户可以自由地添加新的坐标图,修改坐标图的标题,选择坐标图的X轴和Y轴的数据。

下面是此程序的界面截图:

CSV数据绘图工具的界面

图中以标签页的形式显示多个绘图,用户可以从左侧的数据选择栏中选择X轴和Y轴的数据。标签页可以自由的拖动,构成上下左右分栏,并且可以隐藏左侧的数据选择栏:

使用可调整DOCK的多标签页界面方便用户对比数据

由于绘图控件是matplotlib所提供的,因此平移、缩放、保存文件等功能也一应俱全。由于所有的界面都是采用TraitsUI设计的,因此主窗口既可以用来单独显示,也可以嵌入到一个更大的界面中,运用十分灵活。

下面是完整的源程序,运行时需要和mpl_figure_editor.py放在一个文件夹下。包括注释程序一共约170行,编写时间少于一小时。

# -*- coding: utf-8 -*-
from matplotlib.figure import Figure
from mpl_figure_editor import MPLFigureEditor
from enthought.traits.ui.api import *
from enthought.traits.api import *
import csv

class DataSource(HasTraits):
    """
 数据源,data是一个字典,将字符串映射到列表
 names是data中的所有字符串的列表
 """
    data = DictStrAny
    names = List(Str)

    def load_csv(self, filename):
        """
 从CSV文件读入数据,更新data和names属性
 """
        f = file(filename)
        reader = csv.DictReader(f)
        self.names = reader.fieldnames
        for field in reader.fieldnames:
            self.data[field] = []
        for line in reader:
            for k, v in line.iteritems():
                self.data[k].append(float(v))
        f.close()        

class Graph(HasTraits):
    """
 绘图组件,包括左边的数据选择控件和右边的绘图控件
 """
    name = Str # 绘图名,显示在标签页标题和绘图标题中
    data_source = Instance(DataSource) # 保存数据的数据源
    figure = Instance(Figure) # 控制绘图控件的Figure对象
    selected_xaxis = Str # X轴所用的数据名
    selected_items = List # Y轴所用的数据列表

    clear_button = Button(u"清除") # 快速清除Y轴的所有选择的数据

    view = View(
        HSplit( # HSplit分为左右两个区域,中间有可调节宽度比例的调节手柄
            # 左边为一个组
            VGroup(
                Item("name"),   # 绘图名编辑框
                Item("clear_button"), # 清除按钮
                Heading(u"X轴数据"),  # 静态文本
                # X轴选择器,用EnumEditor编辑器,即ComboBox控件,控件中的候选数据从
                # data_source的names属性得到
                Item("selected_xaxis", editor=
                    EnumEditor(name="object.data_source.names", format_str=u"%s")),
                Heading(u"Y轴数据"), # 静态文本
                # Y轴选择器,由于Y轴可以多选,因此用CheckBox列表编辑,按两列显示
                Item("selected_items", style="custom", 
                     editor=CheckListEditor(name="object.data_source.names", 
                            cols=2, format_str=u"%s")),
                show_border = True, # 显示组的边框
                scrollable = True,  # 组中的控件过多时,采用滚动条
                show_labels = False # 组中的所有控件都不显示标签
            ),
            # 右边绘图控件
            Item("figure", editor=MPLFigureEditor(), show_label=False, width=600)
        )        
    )

    def _name_changed(self):
        """
 当绘图名发生变化时,更新绘图的标题
 """
        axe = self.figure.axes[0]
        axe.set_title(self.name)
        self.figure.canvas.draw()

    def _clear_button_fired(self):
        """
 清除按钮的事件处理
 """
        self.selected_items = []
        self.update()

    def _figure_default(self):
        """
 figure属性的缺省值,直接创建一个Figure对象
 """
        figure = Figure()
        figure.add_axes([0.05, 0.1, 0.9, 0.85]) #添加绘图区域,四周留有边距
        return figure

    def _selected_items_changed(self):
        """
 Y轴数据选择更新
 """
        self.update()

    def _selected_xaxis_changed(self):
        """
 X轴数据选择更新
 """    
        self.update()

    def update(self):
        """
 重新绘制所有的曲线
 """    
        axe = self.figure.axes[0]
        axe.clear()
        try:
            xdata = self.data_source.data[self.selected_xaxis]
        except:
            return 
        for field in self.selected_items:
            axe.plot(xdata, self.data_source.data[field], label=field)
        axe.set_xlabel(self.selected_xaxis)
        axe.set_title(self.name)
        axe.legend()
        self.figure.canvas.draw()

class CSVGrapher(HasTraits):
    """
 主界面包括绘图列表,数据源,文件选择器和添加绘图按钮
 """
    graph_list = List(Instance(Graph)) # 绘图列表
    data_source = Instance(DataSource) # 数据源
    csv_file_name = File(filter=[u"*.csv"]) # 文件选择
    add_graph_button = Button(u"添加绘图") # 添加绘图按钮

    view = View(
        # 整个窗口分为上下两个部分
        VGroup(
            # 上部分横向放置控件,因此用HGroup
            HGroup(
                # 文件选择控件
                Item("csv_file_name", label=u"选择CSV文件", width=400),
                # 添加绘图按钮
                Item("add_graph_button", show_label=False)
            ),
            # 下部分是绘图列表,采用ListEditor编辑器显示
            Item("graph_list", style="custom", show_label=False, 
                 editor=ListEditor(
                     use_notebook=True, # 是用多标签页格式显示
                     deletable=True, # 可以删除标签页
                     dock_style="tab", # 标签dock样式
                     page_name=".name") # 标题页的文本使用Graph对象的name属性
                )
        ),
        resizable = True,
        height = 0.8,
        width = 0.8,
        title = u"CSV数据绘图器"
    )

    def _csv_file_name_changed(self):
        """
 打开新文件时的处理,根据文件创建一个DataSource
 """
        self.data_source = DataSource()
        self.data_source.load_csv(self.csv_file_name)
        del self.graph_list[:]

    def _add_graph_button_changed(self):
        """
 添加绘图按钮的事件处理
 """
        if self.data_source != None:
            self.graph_list.append( Graph(data_source = self.data_source) )

if __name__ == "__main__":
    csv_grapher = CSVGrapher()
    csv_grapher.configure_traits()

程序中已经有比较详细的注释,这里就不再重复。如果你对traits库的某项用法还不太了解的话,可以直接查看其源代码,代码中都有详细的注释。下面是几个比较重点的部分:

  • 整个程序的界面处理都只是组装View对象,看不到任何关于控件操作的代码,因此大大地节省了程序的开发时间。

  • 通过配置141行的ListEditor,使其用标签页的方式显示graph_list中的每个元素,以此管理多个Graph对象。

  • 在43行中,Graph类用HSplit将其数据选择部分和绘图控件部分分开,HSplit提供的更改左右部分的比例和隐藏的功能。

  • 本书写作时所采用的traitsUI库版本为3.2,如果在标签页标题中输入中文,会出现错误,这是因为TraitsUI中还有些代码对unicode的支持不够,希望日后会有所改善。目前可以通过分析错误提示信息,修改TraitsUI库的源代码,只需要将下面提示中的770行中的str改为unicode既可以修复。

    &gt;&gt;&gt; from visual import *
    

之后就可以随心所欲的调用visual库通过的函数。需要注意的是如果你关闭了visual弹出的场景窗口的话,ipython对话也随之结束。如果你需要关闭场景窗口可以用下面的语句:

>>> scene.visible = False

在IPython中交互式地观察visual的运行结果

上图是用IPython交互式的使用visual的一个例子,可以看到通过IPython能够控制多个场景窗口。