An example of using WxPython to create a node interface. Part 2: Handling mouse events

  • Tutorial
In a short series of articles we will describe the use of WxPython to solve a very specific task of developing a user interface, and how to make this solution universal. This tutorial is intended for those who have already begun to study this library and want to see something more complex and holistic than the simplest examples (although it starts with relatively simple things).

In the last part, I talked about the task and began to describe the implementation process, or rather the rendering of objects. Now it's time to implement user interaction.

Part 1: Learning to draw
Part 2: Handling mouse events
Part 3: Continuing to add features + keyboard processing
Part 4: Implementing Drag & Drop
Part 5: Connecting nodes

Who cares, welcome to kat ...


Let me remind you that the last time we got a simple program that draws simple nodes on the canvas (for now rectangles with text). It's time to make the nodes moveable.

4. Highlighting objects when you hover over them


But before implementing the movement of nodes, we will make one useful feature: highlighting an object when you hover over it. Parts of this feature will subsequently come in handy when implementing the rest of the functionality. For implementation, we need to perform 3 actions:
1) Track the cursor’s movement
2) Find and save the topmost object under the cursor
3) Render the selection of this object

To track the cursor’s movement, we need to add the corresponding event handler to the canvas class:
        self.Bind(wx.EVT_MOTION, self.OnMouseMotion)

Now, when moving the cursor, the method will be called:
    def OnMouseMotion(self, evt):
        pos = self.CalcUnscrolledPosition(evt.GetPosition()).Get()
        self._objectUnderCursor = self.FindObjectUnderPoint(pos)
        self.Render()

Here three actions take place: since we are given the coordinates of the cursor relative to the window, we must first translate them into the coordinates of the canvas (since we have a scroll), then we must find the corresponding object and update the image so that the highlight of the object the user moves the cursor to appears . The method for searching for objects under the cursor is:
    def FindObjectUnderPoint(self, pos):
        #Check all objects on a canvas. Some objects may have multiple components and connections.
        for obj in reversed(self._canvasObjects):
            objUnderCursor = obj.ReturnObjectUnderCursor(pos)
            if objUnderCursor: 
                return objUnderCursor
        return None

Everything here is trivial and not very. On the one hand, we just go through all the objects and look for the one that lies under the cursor. And we are doing this in the reverse order, since we want to get the highest one, i.e. last added object. On the other hand, we use the "ReturnObjectUnderCursor" method, which returns an object to us, although it seems like we know which object we are checking. This is done with a margin for the future, so that you can make nodes that contain other objects in themselves (for example: connections to other nodes or angles to change the size of the node). So far, this method on our node simply checks if the cursor is in the rectangle:
    def ReturnObjectUnderCursor(self, pos):
        if pos[0] < self.position[0]: return None
        if pos[1] < self.position[1]: return None
        if pos[0] > self.position[0]+self.boundingBoxDimensions[0]: return None
        if pos[1] > self.position[1]+self.boundingBoxDimensions[1]: return None
        return self

So, we always know which object is under the cursor, it remains to somehow highlight it when rendering, which this code will execute during rendering:
        if self._objectUnderCursor:
            gc.PushState()
            self._objectUnderCursor.RenderHighlighting(gc)
            gc.PopState()

It remains to add the code for rendering the highlight to the node:
    def RenderHighlighting(self, gc):
        gc.SetBrush(wx.Brush('#888888', wx.TRANSPARENT))
        gc.SetPen(wx.Pen('#888888', 3, wx.DOT))
        gc.DrawRectangle(self.position[0]-3, 
                         self.position[1]-3, 
                         self.boundingBoxDimensions[0]+6, 
                         self.boundingBoxDimensions[1]+6)

Here we use a transparent brush so that when rendering we don’t overwrite what was previously rendered (i.e., the node itself).
The result is this picture:

The cursor had to be finished after the fact, so it’s a bit non-traditional :)
I won’t bring all the code here, who cares, this commit on GitHub contains it.

5. Small refactoring and adding interfaces


And again, we will not postpone for a long time the implementation of the movement of our objects, this time for a little refactoring. Since this framework should be universal, it means that the nodes here can be all sorts of different, including non-movable ones (for example, connections between objects defined by the objects themselves or some components of nodes, and it’s not enough for people to think of anything). So we need some kind of universal way of describing what is possible and what cannot be done with nodes. Anyway, I would like to introduce some kind of universal interface for nodes. True, for now we will not use abc, zope.interface or something like that, but just make a base class for objects on the canvas:
class CanvasObject(object):
    def __init__(self):
        #Supported operations
        self.clonable = False
        self.movable = False
        self.connectable = False
        self.deletable = False
        self.selectable = False
    def Render(self, gc): 
        """
        Rendering method should draw an object.
        gc: GraphicsContext object that should be used for drawing.
        """
        raise NotImplementedError()
    def RenderHighlighting(self, gc):
        """
        RenderHighlighting method should draw an object 
        with a highlighting border around it.
        gc: GraphicsContext object that should be used for drawing.
        """ 
        raise NotImplementedError()
    def ReturnObjectUnderCursor(self, pos):
        """
        ReturnObjectUnderCursor method returns a top component 
        of this object at a given position or None if position 
        is outside of all objects.
        pos: tested position as a list of x, y coordinates such as [100, 200]
        """
        raise NotImplementedError()

As you can see, we have a number of standard actions that are not supported by default. But there are 3 methods that any object on canvas should have. Which is logical, why do we need such objects on the canvas that we cannot see (Render), and as we see, poke them with the cursor (ReturnObjectUnderCursor, RenderHighlighting). And here we recall that we want to move our nodes, i.e. they must be movable, and for this there is a special class:
from MoveMe.Canvas.Objects.Base.CanvasObject import CanvasObject
class MovableObject(CanvasObject):
    def __init__(self, position):
        super(MovableObject, self).__init__()
        self.position = position
        self.movable = True

Everything is simple here, this class allows moving and also adds such a useful property as a position, so moving something from one position to another without having this very position is difficult. Now the definition of our node has become a little more complicated, since it has become the heiress of our new classes, although, in general, this is still the same good old node:
from MoveMe.Canvas.Objects.Base.CanvasObject import CanvasObject
from MoveMe.Canvas.Objects.Base.MovableObject import MovableObject
class SimpleBoxNode(MovableObject, CanvasObject):
...........

6. Moving nodes


So we got to the long-awaited implementation of moving nodes. To do this, we need to take 2 main steps: remember which object the user started dragging (i.e. which object was under the cursor at the moment the left mouse button was pressed) and update the position of the object when moving the cursor until the user releases the mouse button.
The first action is performed in:
    def OnMouseLeftDown(self, evt):
        if not self._objectUnderCursor:
            return
        if self._objectUnderCursor.movable:
            self._lastDraggingPosition = self.CalcUnscrolledPosition(evt.GetPosition()).Get()
            self._draggingObject = self._objectUnderCursor
        self.Render()

We simply remember the cursor position and the current object under the cursor as being moved if it supports movement. Unless there is still a check for the presence of an object under the cursor, since there is no point in moving the void. The second part is a little more interesting
    def OnMouseMotion(self, evt):
        pos = self.CalcUnscrolledPosition(evt.GetPosition()).Get()
        self._objectUnderCursor = self.FindObjectUnderPoint(pos)
        if not evt.LeftIsDown():
            self._draggingObject = None
        if evt.LeftIsDown() and evt.Dragging() and self._draggingObject:
            dx = pos[0]-self._lastDraggingPosition[0]
            dy = pos[1]-self._lastDraggingPosition[1]
            newX = self._draggingObject.position[0]+dx
            newY = self._draggingObject.position[1]+dy
            #Check canvas boundaries 
            newX = min(newX, self.canvasDimensions[0]-self._draggingObject.boundingBoxDimensions[0])
            newY = min(newY, self.canvasDimensions[1]-self._draggingObject.boundingBoxDimensions[1])
            newX = max(newX, 0)
            newY = max(newY, 0)
            self._draggingObject.position = [newX, newY]
            #Cursor will be at a border of a node if it goes out of canvas
            self._lastDraggingPosition = [min(pos[0], self.canvasDimensions[0]), min(pos[1], self.canvasDimensions[1])]
        self.Render()

The first check guarantees to us that if the user at some moment drives with the mouse with the left button released, it means that he definitely does not move anything. This is better than stopping the movement of the button's release event, since the cursor may be outside the window and then we will not receive this event. Then we check that we are really pulling something and begin to consider the relative movement of our object. At the moment, we do not think about what is happening with the keyboard (whether Ctrl is pressed or something else, it will be later). There is still a check for going beyond the canvas. With the verification of this, everything is not entirely simple and understandable. On the one hand, if the canvas size is fixed, then it should be so, and on the other hand, it would be nice to stretch the canvas along the way (although this is not an ideal solution). In general, at the moment,
That's all, now we can move objects along the canvas. The code lives in this commit on GitHub . And it looks like this:


PS: About typos write in a personal.

Also popular now: