wxpython 实现简易画板(1)


任何用户界面工具的最基本的行为是在屏幕上绘制。

wxpython 用于绘制的主要概念是 device context(直译为设备上下文,简称 DC)。设备上下文使用一个标准的 API 来管理设备(屏幕或打印机)的绘制,如在屏幕上绘制一条直线、曲线或文本。也就是说,实现绘制功能基本过程就是:先获得待绘制设备的 DC,再通过调用 DC 的 API 实现绘制功能。

实现“画板”,需要使用的设备上下文有:

1.1. 实现思路

绘制过程:

  1. 鼠标左键按下:开始绘制,记录笔画的起点;
  2. 拖动鼠标:记录移动过程中鼠标经过的每个点的坐标。这些数据点两两相连形成最终的笔迹;
  3. 鼠标左键松开:结束绘制,保存此笔迹。

实现画板,有两种实现方法:直接法和缓冲法。简单理解,直接法相当于每绘制一次就刷新一下屏幕,当绘图比较复杂时会产生屏闪现象;缓冲法是将所有的绘制先缓存到某个内存中,然后再一次性复制到屏幕,即只刷新一次,有效的避免屏幕闪烁。

直接法和缓冲法都有一一对应的设备上下文,不能混用。直接法对应的设备上下文是 wx.ClientDCwx.PaintDC,缓冲法对应的设备上下文是 wx.BufferedDCwx.BUfferedPaintDC。其中,wx.PaintDCwx.BufferedPaintDC 都只能在 EVT_PAINT 中使用(没有为什么,就是这样规定限制的)。

1.2. 编程实现

1.2.1. 定义笔画类

笔画的信息包括:笔的颜色、粗细、样式和笔画上的每个点的坐标。必须保留笔画的信息,才能在重绘窗口的时候绘制之前绘制的直线。

class Myline():
    """笔画类,包含笔迹的颜色、粗细、样式、数据点"""
    def __init__(self, color, thick, style, datas):
        self.pen_msg = (color, thick, style)
        self.datas = datas

1.2.2. 定义画板缓冲类

  1. 直接法和缓冲法的实现都比较简单,以下都进行实现,通过 BUFFERED 进行设置。

    补充:wx.Window 类是一个很基本的类,叫做“窗口类”,许多的控件(包括按钮 Button、面板 Panel 等都是它的子类)

    import wx
    
    BUFFERED = True    # 使用缓冲法,即 double buffered
    # BUFFERED = False # 使用直接法
    
    class SimpleSketchWindow(wx.Window):
        """画板缓冲窗口"""
        def __init__(self, *args, **kw):
            super().__init__(*args, **kw)
    
            self.cur_pos = (0,0) # 当前鼠标位置
            self.cur_line = []   # 当前笔画 [(x1, y1), (x2, y2), ... ,(xn,yn)]
            self.lines = []      # 所有笔画 [line1, line2, ..., line m]
            
            self.pen_color = 'RED' # 笔迹颜色
            self.pen_thick = 4     # 笔迹粗细
            self.pen_style = wx.PENSTYLE_SOLID # 笔类型
    
            # 设置缓存区
            if BUFFERED:
                self.buffer = None
                self.InitBuffer()
    
            # 设置背景颜色
            self.SetBackgroundColour('white')
            # 设置鼠标图标为“铅笔”
            self.SetCursor(wx.Cursor(wx.CURSOR_PENCIL))
    
            # 绑定事件
            self.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
            self.Bind(wx.EVT_LEFT_UP, self.OnLeftUp)
            self.Bind(wx.EVT_MOTION, self.OnMotion)
            self.Bind(wx.EVT_PAINT, self.OnPaint) # 触发时机:窗口大小变换
            self.Bind(wx.EVT_SIZE, self.OnSize)
    
  2. 缓冲法相比直接法,在获得待绘制设备的 DC 前,多了一步“设置缓冲区”,以确保缓冲区的大小与窗口是一致的。

    注意:

    • 直接法,只需调用 wx.ClientDC() 获得指定窗口的上下文

    • 缓冲法,再调用 wx.BufferedDC() 之前,需要设置缓冲区的大小与窗口的大小一致。这里的缓冲区内存是由调用位图 Bitmap() 分配的。注意 wx.BufferedDC() 返回的设备上下文是这个缓冲区的,之后的绘图都将绘制在这个缓冲区内。当这个设备上下文销毁时,会将其内容复制到对应的窗口上。

    • 补充 wx.BufferedDC() 的用法,有助于理解:

      wx.BufferedDC(dc, buffer=NullBitmap, style=BUFFER_CLIENT_AREA)

      • dc (wx.DC) -- The underlying DC: everything drawn to this object will be flushed to this DC when this object is destroyed. You may pass None in order to just initialize the buffer, and not flush it.
        这里的形参 dc 是“底层DC”,指的是待绘制窗口的 DC(通常是调用 wx.ClientDC()来获得);而返回值 DC,指的是 buffer 的 DC,所有的绘制都在这个 buffer 上进行,当返回值 DC 被销毁时,便将 buffer 复制到形参 dc 上;形参 dc = None,相当于初始化 buffer
      • buffer (wx.Bitmap) -- Explicitly provided bitmap to be used for buffering: this is the most efficient solution as the bitmap doesn’t have to be recreated each time but it also requires more memory as the bitmap is never freed. The bitmap should have appropriate size, anything drawn outside of its bounds is clipped.
        显式提供位图用于缓冲。位图不必每次都重新创建,但它也需要适当的大小(和窗口大小对应),任何绘制在其边界之外的都将被剪切。
      • style (int) -- wx.BUFFER_CLIENT_AREA to indicate that just the client area of the window is buffered, or wx.BUFFER_VIRTUAL_AREA to indicate that the buffer bitmap covers the virtual area.
        wx.BUFFER_CLIENT_AREA 表示只缓冲了窗口的客户端区域;wx.BUFFER_VIRTUAL_AREA表示缓冲区位图覆盖了虚拟区域(如使用滚动窗口 ScrolledWindow
    • 获得对应的 DC 后,再执行绘制操作,这里的 DefaultDrawing() 默认的绘制操作,包括绘制已存在的笔画(这是有必要的,不然每当刷新窗口后会丢失部分笔画) ;以及 DoMyDrawing() 用于自定义绘制的内容。

    代码:

    def InitBuffer(self):
         """初始化缓冲区"""
         if BUFFERED:
             # 设置缓冲区与窗口的大小一致
             size = self.GetClientSize()
             self.buffer = wx.Bitmap(*size)
             # 第一个参数为None,相当于初始化 buffer
             dc = wx.BufferedDC(None, self.buffer)
         else:
             # 直接获得当前窗口的设别上下文
             dc = wx.ClientDC(self)
    
         # 默认的绘画:绘制已存在的笔迹
         self.DefaultDrawing(dc)
    
         # 添加你的绘画
         self.DoMyDrawing(dc)
    
     def DefaultDrawing(self, dc:wx.DC):
         """默认绘画"""
         # 设置背景颜色
         dc.SetBackground(wx.Brush(self.GetBackgroundColour()))
         dc.Clear() # 使用当前背景刷来清除设备上下文。
    
         # 绘制所有的笔画
         self.DrawAllLines(dc)
    
     def DrawAllLines(self, dc:wx.DC):
         """绘制所有的笔画"""
         for line in self.lines:
             # 设置笔画
             pen = wx.Pen(*line.pen_msg)
             dc.SetPen(pen)
             # 绘制直线
             for i in range(1, len(line.datas)):
                 coord = (line.datas[i-1].x, line.datas[i-1].y,
                          line.datas[i].x, line.datas[i].y)
                 dc.DrawLine(*coord)
    
     def DoMyDrawing(self, dc:wx.DC):
         """需要继承此类,然后重写此函数"""
         pass
    
  3. 绑定 EVT_SIZEEVT_PAINT 事件,缺一不可。

    EVT_SIZE 事件的触发时机为:程序运行时创建窗口,窗口被拉伸而改变大小;
    EVT_PAINT 事件的触发时机为:程序运行时创建窗口,窗口需要重新绘制时(如窗口被拉伸后,窗口从最小化恢复到正常大小等);

    需要说明的是,绘制的过程(不拉伸窗口)貌似不会触发这两个事件,但绑定少其中一个事件都会产生一些奇怪的 BUG。建议一定要绑定这两个事件,同时注意一些细节:

    def OnSize(self, event):
        """响应窗口大小改变"""
    
        # 缓冲模式:每次窗口大小变换,都需要重新设置缓冲区大小
        # 然后重画窗口
        self.InitBuffer()
    
        print("OnSize")
        event.Skip()
    
    def OnPaint(self, event):
        """响应Paint Event"""
    
        if BUFFERED:
            wx.BufferedPaintDC(self, self.buffer)
        else:
            dc = wx.PaintDC(self)
            # 重新绘制
            self.DefaultDrawing(dc)
            self.DoMyDrawing(dc)
        
        print("OnPaint")
        event.Skip()
    
    • OnSize() 执行的效果:

      重新绘制窗口。这里是直接调用 InitBuffer(),对于缓冲模式,需要在绘制窗口前,重新设置缓冲区的大小。

    • OnPaint()

      分别调用直接法和缓冲法的“Paint Event 专用”的设备上下文。直接法是 wx.PaintDC,缓冲法是 wx.BufferedPaintDC。这两个 API 都只能在 EVT_PAINT 中使用(即在这个 OnPaint函数内)。另外,对于直接法还需要“重新绘制”(这里调用self.DefaultDrawing(dc)self.DoMyDrawing(dc))。

  4. 笔画绘制的实现

    def OnLeftDown(self, event:wx.MouseEvent):
        """鼠标左键按下,记录起始坐标"""
        
        # 获得当前鼠标位置
        self.cur_pos = event.GetPosition()
        # 新笔画的起点
        self.cur_line = []
        self.cur_line.append(self.cur_pos)
    
        print("Left Down: (%d, %d)" % (self.cur_pos.x, self.cur_pos.y))
        event.Skip()
    
    def OnLeftUp(self, event:wx.MouseEvent):
        """鼠标左键松开,记录当前笔画"""
    
        if len(self.cur_line) > 1:
            self.lines.append(Myline(
                self.pen_color, self.pen_thick, self.pen_style, self.cur_line))
    
        print("Left Up: (%d, %d)" % (self.cur_pos.x, self.cur_pos.y))
        event.Skip()
    
    def OnMotion(self, event:wx.MouseEvent):
        """鼠标移动(左键拖动)"""
        if event.Dragging() and event.LeftIsDown():
            # 更新鼠标的坐标
            pre_pos = self.cur_pos
            self.cur_pos = event.GetPosition()
            self.cur_line.append(self.cur_pos)
            # 设置缓冲区
            if BUFFERED:
                # 设置缓冲区,当dc销毁时,将 buffer 复制到当前窗口上
                dc = wx.BufferedDC(wx.ClientDC(self), self.buffer)
            else:
                # 直接获得当前窗口的设别上下文
                dc = wx.ClientDC(self)
            # 绘制直线
            pen = wx.Pen(self.pen_color, self.pen_thick, self.pen_style)
            dc.SetPen(pen)
            coord = (pre_pos.x, pre_pos.y, self.cur_pos.x, self.cur_pos.y)
            dc.DrawLine(*coord)
    
            print("Drawing:", coord)
    
        event.Skip()
    

1.2.3. 继承画板缓冲类

继承的好处是,提高代码的利用率。继承后的子类可以增加新的自定义的特性,也可以重写父类的方法。

class SketchWindow(SimpleSketchWindow):

    def __init__(self, *args, **kw):
        super().__init__(*args, **kw)
    
    def DoMyDrawing(self, dc: wx.DC):
        """绘制自定义内容"""
        self.DrawLogo(dc)

    def DrawLogo(self, dc:wx.DC):
        """绘制logo"""

        dc.SetPen(wx.Pen('RED'))
        dc.DrawRectangle(5, 5, 50, 50)

        dc.SetBrush(wx.Brush("MEDIUM SEA GREEN"))
        dc.SetPen(wx.Pen('BLUE', 4))
        dc.DrawRectangle(15, 15, 50, 50)

1.2.4. 定义测试类

class SketchFrame(wx.Frame):

    def __init__(self):
        super().__init__(parent=None, id=-1, 
            title="简易的画板",
            size=(800,600)
        )
        
        self.sketch = SketchWindow(parent=self, id=-1)

        # 窗口居中
        self.Center()

if __name__ == '__main__':
    app = wx.App()
    frm = SketchFrame()
    frm.Show()
    app.MainLoop()

1.3. 运行结果

运行程序之后,可以拉伸放大缩小窗口,笔迹是不会消失的。

运行结果

1.4. 完整代码

使用版本为:Python 3.9.6 64bitwxPython 4.1.1

# -*- encoding: utf-8 -*-
# Python 3.9.6 64bit
'''
@File        : MySktech.py
@Time        : 2022/01/03 21:51
@Author      : Wreng
@Description : wxpython 实现简易的画板
@Other       : version - Python 3.9.6 64bit, wxPython 4.1.1
'''

import wx

"""
实现画板,有两种方式:直接法和缓冲法。
缓冲法,可以有效的避免屏闪;当绘图不是很复杂的时候,直接法的屏闪也不是很明显。
两种的实现也比较简单,分别由专门的dc一一对应的。
"""
BUFFERED = True    # 使用缓冲法,即 double buffered
# BUFFERED = False # 使用直接法

class Myline():
    """笔画类,包含笔迹的颜色、粗细、样式、数据点"""
    def __init__(self, color, thick, style, datas):
        self.pen_msg = (color, thick, style)
        self.datas = datas

class SimpleSketchWindow(wx.Window):
    """画板缓冲窗口"""
    def __init__(self, *args, **kw):
        super().__init__(*args, **kw)

        self.cur_pos = (0,0) # 当前鼠标位置
        self.cur_line = []   # 当前笔画 [(x1, y1), (x2, y2), ... ,(xn,yn)]
        self.lines = []      # 所有笔画 [line1, line2, ..., line m]
        
        self.pen_color = 'RED' # 笔迹颜色
        self.pen_thick = 4     # 笔迹粗细
        self.pen_style = wx.PENSTYLE_SOLID # 笔类型

        # 设置缓存区
        if BUFFERED:
            self.buffer = None
            self.InitBuffer()

        # 设置背景颜色
        self.SetBackgroundColour('white')
        # 设置鼠标图标为“铅笔”
        self.SetCursor(wx.Cursor(wx.CURSOR_PENCIL))

        # 绑定事件
        self.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
        self.Bind(wx.EVT_LEFT_UP, self.OnLeftUp)
        self.Bind(wx.EVT_MOTION, self.OnMotion)
        self.Bind(wx.EVT_PAINT, self.OnPaint) # 触发时机:窗口大小变换
        self.Bind(wx.EVT_SIZE, self.OnSize)

    def InitBuffer(self):
        """初始化缓冲区"""
        if BUFFERED:
            # 设置缓冲区与窗口的大小一致
            size = self.GetClientSize()
            self.buffer = wx.Bitmap(*size)
            # 第一个参数为None,相当于初始化 buffer
            dc = wx.BufferedDC(None, self.buffer)
        else:
            # 直接获得当前窗口的设别上下文
            dc = wx.ClientDC(self)

        # 默认的绘画:绘制已存在的笔迹
        self.DefaultDrawing(dc)

        # 添加你的绘画
        self.DoMyDrawing(dc)

    def DefaultDrawing(self, dc:wx.DC):
        """默认绘画"""
        
        # 设置背景颜色
        dc.SetBackground(wx.Brush(self.GetBackgroundColour()))
        dc.Clear() # 使用当前背景刷来清除设备上下文。

        # 绘制所有的笔画
        self.DrawAllLines(dc)

    def DrawAllLines(self, dc:wx.DC):
        """绘制所有的直线"""
        for line in self.lines:
            # 设置笔画
            pen = wx.Pen(*line.pen_msg)
            dc.SetPen(pen)
            # 绘制直线
            for i in range(1, len(line.datas)):
                coord = (line.datas[i-1].x, line.datas[i-1].y,
                         line.datas[i].x, line.datas[i].y)
                dc.DrawLine(*coord)

    def DoMyDrawing(self, dc:wx.DC):
        """需要继承此类,然后重构此函数"""
        pass

    # ====================================================================
    # 事件响应函数
    # ====================================================================
    def OnSize(self, event):
        """响应窗口大小改变"""

        # 每次窗口大小变换,都需要重新设置缓冲区大小
        self.InitBuffer()

        print("OnSize")
        event.Skip()

    def OnPaint(self, event):
        """响应Paint Event"""

        if BUFFERED:
            wx.BufferedPaintDC(self, self.buffer)
        else:
            dc = wx.PaintDC(self)
            # 重新绘制
            self.DefaultDrawing(dc)
            self.DoMyDrawing(dc)
        
        print("OnPaint")
        event.Skip()

    def OnLeftDown(self, event:wx.MouseEvent):
        """鼠标左键按下,记录起始坐标"""
        
        # 获得当前鼠标位置
        self.cur_pos = event.GetPosition()
        # 新笔画的起点
        self.cur_line = []
        self.cur_line.append(self.cur_pos)

        print("Left Down: (%d, %d)" % (self.cur_pos.x, self.cur_pos.y))
        event.Skip()

    def OnLeftUp(self, event:wx.MouseEvent):
        """鼠标左键松开,记录当前笔画"""

        if len(self.cur_line) > 1:
            self.lines.append(Myline(
                self.pen_color, self.pen_thick, self.pen_style, self.cur_line))

        print("Left Up: (%d, %d)" % (self.cur_pos.x, self.cur_pos.y))
        event.Skip()

    def OnMotion(self, event:wx.MouseEvent):
        """鼠标移动(左键拖动)"""
        if event.Dragging() and event.LeftIsDown():
            # 更新鼠标的坐标
            pre_pos = self.cur_pos
            self.cur_pos = event.GetPosition()
            self.cur_line.append(self.cur_pos)
            # 设置缓冲区
            if BUFFERED:
                # 设置缓冲区,当dc销毁时,将 buffer 复制到当前窗口上
                dc = wx.BufferedDC(wx.ClientDC(self), self.buffer)
            else:
                # 直接获得当前窗口的设别上下文
                dc = wx.ClientDC(self)
            # 绘制直线
            pen = wx.Pen(self.pen_color, self.pen_thick, self.pen_style)
            dc.SetPen(pen)
            coord = (pre_pos.x, pre_pos.y, self.cur_pos.x, self.cur_pos.y)
            dc.DrawLine(*coord)

            print("Drawing:", coord)

        event.Skip()


class SketchWindow(SimpleSketchWindow):

    def __init__(self, *args, **kw):
        super().__init__(*args, **kw)
    
    def DoMyDrawing(self, dc: wx.DC):
        """绘制自定义内容"""
        self.DrawLogo(dc)

    def DrawLogo(self, dc:wx.DC):
        """绘制logo"""

        dc.SetPen(wx.Pen('RED'))
        dc.DrawRectangle(5, 5, 50, 50)

        dc.SetBrush(wx.Brush("MEDIUM SEA GREEN"))
        dc.SetPen(wx.Pen('BLUE', 4))
        dc.DrawRectangle(15, 15, 50, 50)

class SketchFrame(wx.Frame):

    def __init__(self):
        super().__init__(parent=None, id=-1, 
            title="简易的画板",
            size=(800,600)
        )
        
        self.sketch = SketchWindow(parent=self, id=-1)

        # 窗口居中
        self.Center()

if __name__ == '__main__':
    app = wx.App()
    frm = SketchFrame()
    frm.Show()
    app.MainLoop()

1.5. 相关参考

posted @ 2022-01-04 09:39  Wreng  阅读(65)  评论(1编辑  收藏  举报